Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
606 lines
17 KiB
TypeScript
606 lines
17 KiB
TypeScript
/**
|
|
* Admin Page Object for E2E tests
|
|
*
|
|
* Provides a clean API for interacting with the EmDash admin UI.
|
|
*/
|
|
|
|
import { type Page, expect } from "@playwright/test";
|
|
|
|
// Regex patterns
|
|
const ADMIN_URL_PATTERN = /\/_emdash\/admin/;
|
|
const ADMIN_DASHBOARD_PATTERN = /\/_emdash\/admin\/?$/;
|
|
const CONTENT_ID_EXTRACTION_PATTERN = /\/content\/[^/]+\/([^/]+)$/;
|
|
const MENU_URL_PATTERN = /\/_emdash\/admin\/menus\//;
|
|
const SETUP_PAGE_PATTERN = /\/_emdash\/admin\/setup/;
|
|
|
|
export class AdminPage {
|
|
readonly page: Page;
|
|
readonly baseUrl = "/_emdash/admin";
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
}
|
|
|
|
/**
|
|
* Authenticate using dev bypass (creates session)
|
|
* Call this before accessing protected pages.
|
|
*
|
|
* Navigates through the bypass URLs which sets cookies in the browser context.
|
|
*/
|
|
async devBypassAuth(): Promise<void> {
|
|
// Navigate to setup bypass - this sets up the site AND creates a session
|
|
// The redirect param sends us to auth bypass to ensure cookies are set
|
|
await this.page.goto("/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin/");
|
|
|
|
// Wait for the redirect to complete and admin shell to appear
|
|
await this.page.waitForURL(ADMIN_URL_PATTERN, { timeout: 30000 });
|
|
|
|
// Wait for page to be usable. Race networkidle (Vite dep re-optimization) against
|
|
// the hydration signal so HMR websocket can't stall us indefinitely.
|
|
await Promise.race([
|
|
this.page.waitForLoadState("networkidle").catch(() => {}),
|
|
this.waitForHydration().catch(() => {}),
|
|
]);
|
|
|
|
// Remove any vite error overlay that appeared during SSR
|
|
await this.dismissViteOverlay();
|
|
|
|
// If we got a server error, reload — the error is usually transient
|
|
const hasErrorOverlay = await this.page.locator("vite-error-overlay").count();
|
|
if (hasErrorOverlay > 0) {
|
|
await this.dismissViteOverlay();
|
|
await this.page.reload();
|
|
}
|
|
|
|
// Wait for the shell to fully hydrate
|
|
await this.waitForShell();
|
|
}
|
|
|
|
/**
|
|
* Navigate to an admin page
|
|
*/
|
|
async goto(path = "/"): Promise<void> {
|
|
const url = path === "/" ? this.baseUrl : `${this.baseUrl}${path}`;
|
|
await this.page.goto(url);
|
|
}
|
|
|
|
/**
|
|
* Wait for React hydration to complete.
|
|
* Astro removes the `ssr` attribute from `<astro-island>` after hydration.
|
|
*/
|
|
async waitForHydration(): Promise<void> {
|
|
await this.page.waitForSelector("astro-island:not([ssr])", { timeout: 15000 });
|
|
}
|
|
|
|
/**
|
|
* Wait for the admin shell to be ready (hydrated and interactive)
|
|
*/
|
|
async waitForShell(): Promise<void> {
|
|
// Dismiss vite error overlay if present (from previous request errors)
|
|
await this.dismissViteOverlay();
|
|
|
|
// Wait for sidebar to appear (indicates manifest loaded and React hydrated)
|
|
const maxRetries = 3;
|
|
let lastError: unknown;
|
|
for (let i = 0; i < maxRetries; i++) {
|
|
try {
|
|
// Wait for both sidebar and hydration signal
|
|
await this.page.waitForSelector('aside[aria-label="Admin navigation"]', {
|
|
timeout: 15000,
|
|
});
|
|
await this.waitForHydration();
|
|
lastError = undefined;
|
|
break;
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (i < maxRetries - 1) {
|
|
// Server may be restarting (Vite re-optimization). Retry with reload.
|
|
// Wrap in try/catch since reload itself can fail if server is mid-restart.
|
|
try {
|
|
await this.dismissViteOverlay();
|
|
await this.page.reload({ waitUntil: "load" });
|
|
await this.dismissViteOverlay();
|
|
} catch {
|
|
// Server still down — wait for it to come back before next retry
|
|
await this.page.waitForLoadState("load").catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (lastError) {
|
|
throw lastError;
|
|
}
|
|
|
|
// Dismiss the onboarding "Welcome" modal if it appears
|
|
await this.dismissOnboardingModal();
|
|
}
|
|
|
|
/**
|
|
* Dismiss the onboarding "Welcome" modal if it appears
|
|
*/
|
|
async dismissOnboardingModal(): Promise<void> {
|
|
const getStartedBtn = this.page.locator('button:has-text("Get Started")');
|
|
if (await getStartedBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await getStartedBtn.click();
|
|
await this.page
|
|
.locator("[data-base-ui-inert]")
|
|
.waitFor({ state: "hidden", timeout: 5000 })
|
|
.catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dismiss vite-error-overlay if present
|
|
*/
|
|
async dismissViteOverlay(): Promise<void> {
|
|
// Remove vite-error-overlay from DOM if present — it has aria-hidden="true"
|
|
// so Playwright's isVisible() won't detect it, but it still blocks pointer events
|
|
await this.page
|
|
.evaluate(() => {
|
|
document.querySelectorAll("vite-error-overlay").forEach((el) => el.remove());
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
/**
|
|
* Wait for loading states to complete
|
|
*/
|
|
async waitForLoading(): Promise<void> {
|
|
// Wait for loading text and spinners to disappear
|
|
await this.page
|
|
.locator("text=Loading")
|
|
.waitFor({ state: "hidden", timeout: 15000 })
|
|
.catch(() => {});
|
|
await this.page
|
|
.locator(".animate-spin")
|
|
.waitFor({ state: "hidden", timeout: 10000 })
|
|
.catch(() => {});
|
|
}
|
|
|
|
// ============================================
|
|
// Navigation
|
|
// ============================================
|
|
|
|
/**
|
|
* Navigate to dashboard
|
|
*/
|
|
async goToDashboard(): Promise<void> {
|
|
await this.goto("/");
|
|
await this.waitForShell();
|
|
}
|
|
|
|
/**
|
|
* Navigate to content list for a collection
|
|
*/
|
|
async goToContent(collection: string): Promise<void> {
|
|
await this.goto(`/content/${collection}`);
|
|
await this.waitForShell();
|
|
}
|
|
|
|
/**
|
|
* Navigate to create new content
|
|
*/
|
|
async goToNewContent(collection: string): Promise<void> {
|
|
await this.goto(`/content/${collection}/new`);
|
|
await this.waitForShell();
|
|
}
|
|
|
|
/**
|
|
* Navigate to edit content
|
|
*/
|
|
async goToEditContent(collection: string, id: string): Promise<void> {
|
|
await this.goto(`/content/${collection}/${id}`);
|
|
await this.waitForShell();
|
|
}
|
|
|
|
/**
|
|
* Navigate to media library
|
|
*/
|
|
async goToMedia(): Promise<void> {
|
|
await this.goto("/media");
|
|
await this.waitForShell();
|
|
}
|
|
|
|
/**
|
|
* Navigate to menus list
|
|
*/
|
|
async goToMenus(): Promise<void> {
|
|
await this.goto("/menus");
|
|
await this.waitForShell();
|
|
}
|
|
|
|
/**
|
|
* Navigate to edit a specific menu
|
|
*/
|
|
async goToMenuEditor(name: string): Promise<void> {
|
|
await this.goto(`/menus/${name}`);
|
|
await this.waitForShell();
|
|
}
|
|
|
|
/**
|
|
* Navigate to settings
|
|
*/
|
|
async goToSettings(): Promise<void> {
|
|
await this.goto("/settings");
|
|
await this.waitForShell();
|
|
}
|
|
|
|
/**
|
|
* Navigate to setup wizard
|
|
*/
|
|
async goToSetup(): Promise<void> {
|
|
await this.goto("/setup");
|
|
}
|
|
|
|
// ============================================
|
|
// Setup Wizard Actions
|
|
// ============================================
|
|
|
|
/**
|
|
* Complete the setup wizard
|
|
*/
|
|
async completeSetup(options: {
|
|
title: string;
|
|
tagline?: string;
|
|
includeContent?: boolean;
|
|
}): Promise<void> {
|
|
// Fill title
|
|
await this.page.fill("#title", options.title);
|
|
|
|
// Fill tagline if provided
|
|
if (options.tagline) {
|
|
await this.page.fill("#tagline", options.tagline);
|
|
}
|
|
|
|
// Handle content checkbox if it exists
|
|
if (options.includeContent !== undefined) {
|
|
const checkbox = this.page.locator("#includeContent");
|
|
if (await checkbox.isVisible()) {
|
|
const isChecked = await checkbox.isChecked();
|
|
if (options.includeContent && !isChecked) {
|
|
await checkbox.click();
|
|
} else if (!options.includeContent && isChecked) {
|
|
await checkbox.click();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Submit
|
|
await this.page.click('button[type="submit"]');
|
|
}
|
|
|
|
// ============================================
|
|
// Content CRUD Actions
|
|
// ============================================
|
|
|
|
/**
|
|
* Create new content with field data
|
|
*/
|
|
async createContent(collection: string, data: Record<string, string>): Promise<string> {
|
|
await this.goToNewContent(collection);
|
|
|
|
// Fill in form fields
|
|
for (const [field, value] of Object.entries(data)) {
|
|
await this.fillField(field, value);
|
|
}
|
|
|
|
// Save
|
|
await this.clickSave();
|
|
await this.waitForSaveComplete();
|
|
|
|
// Return the new content ID from URL
|
|
const url = this.page.url();
|
|
const match = url.match(CONTENT_ID_EXTRACTION_PATTERN);
|
|
return match?.[1] || "";
|
|
}
|
|
|
|
/**
|
|
* Update content field
|
|
*/
|
|
async updateField(field: string, value: string): Promise<void> {
|
|
await this.fillField(field, value);
|
|
}
|
|
|
|
/**
|
|
* Fill a form field by slug (uses #field-{slug} convention)
|
|
*/
|
|
async fillField(slug: string, value: string): Promise<void> {
|
|
const input = this.page.locator(`#field-${slug}`);
|
|
await input.fill(value);
|
|
}
|
|
|
|
/**
|
|
* Click the save button
|
|
*/
|
|
async clickSave(): Promise<void> {
|
|
await this.page.locator('button:has-text("Save")').click();
|
|
}
|
|
|
|
/**
|
|
* Wait for save to complete
|
|
*/
|
|
async waitForSaveComplete(): Promise<void> {
|
|
// Wait for the save button to show "Saved" or stop showing "Saving..."
|
|
await this.page
|
|
.getByRole("button", { name: "Saved" })
|
|
.waitFor({ timeout: 10000 })
|
|
.catch(() => {});
|
|
await this.waitForLoading();
|
|
}
|
|
|
|
/**
|
|
* Delete content item by clicking delete button
|
|
*/
|
|
async deleteContentItem(title: string): Promise<void> {
|
|
// Find the row with this title and click delete
|
|
const row = this.page.locator("tr", { hasText: title });
|
|
await row.locator('button[aria-label*="Delete"]').click();
|
|
|
|
// Handle confirmation
|
|
this.page.once("dialog", (dialog) => dialog.accept());
|
|
}
|
|
|
|
// ============================================
|
|
// Media Library Actions
|
|
// ============================================
|
|
|
|
/**
|
|
* Upload a file to media library
|
|
*/
|
|
async uploadMedia(filePath: string): Promise<void> {
|
|
// Click upload button to trigger file input
|
|
const fileInput = this.page.locator('input[type="file"]');
|
|
await fileInput.setInputFiles(filePath);
|
|
|
|
// Wait for upload to complete
|
|
await this.page.waitForResponse(
|
|
(response) => response.url().includes("/api/media") && response.status() === 200,
|
|
);
|
|
await this.waitForLoading();
|
|
}
|
|
|
|
/**
|
|
* Get count of media items
|
|
*/
|
|
async getMediaCount(): Promise<number> {
|
|
const items = this.page.locator('[class*="grid"] > div');
|
|
return items.count();
|
|
}
|
|
|
|
/**
|
|
* Delete a media item by filename
|
|
*/
|
|
async deleteMedia(filename: string): Promise<void> {
|
|
// Hover over the item to show delete button
|
|
const item = this.page.locator(`[alt="${filename}"]`).first();
|
|
await item.hover();
|
|
|
|
// Click delete
|
|
const deleteBtn = this.page.locator('button:has-text("Delete")').first();
|
|
await deleteBtn.click();
|
|
|
|
// Handle confirmation
|
|
this.page.once("dialog", (dialog) => dialog.accept());
|
|
}
|
|
|
|
// ============================================
|
|
// Menu Actions
|
|
// ============================================
|
|
|
|
/**
|
|
* Create a new menu
|
|
*/
|
|
async createMenu(name: string, label: string): Promise<void> {
|
|
// Click create menu button
|
|
await this.page.getByRole("button", { name: "Create Menu" }).first().click();
|
|
|
|
// Fill form
|
|
await this.page.getByLabel("Name").fill(name);
|
|
await this.page.getByLabel("Label").fill(label);
|
|
|
|
// Submit and wait for navigation
|
|
await Promise.all([
|
|
this.page.waitForURL(MENU_URL_PATTERN, {
|
|
timeout: 15000,
|
|
}),
|
|
this.page.getByRole("button", { name: "Create" }).click(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Add a custom link to current menu
|
|
*/
|
|
async addMenuLink(label: string, url: string): Promise<void> {
|
|
// Click add link button
|
|
await this.page.getByRole("button", { name: "Add Custom Link" }).first().click();
|
|
|
|
// Wait for dialog to appear
|
|
await this.page.waitForSelector('[role="dialog"]', { state: "visible", timeout: 5000 });
|
|
|
|
// Fill form (scope to dialog to avoid ambiguity)
|
|
const dialog = this.page.locator('[role="dialog"]');
|
|
await dialog.getByLabel("Label").fill(label);
|
|
await dialog.getByLabel("URL").fill(url);
|
|
|
|
// Submit
|
|
await dialog.getByRole("button", { name: "Add" }).click();
|
|
|
|
// Wait for dialog to close
|
|
await this.page.waitForSelector('[role="dialog"]', { state: "hidden" });
|
|
}
|
|
|
|
/**
|
|
* Delete a menu
|
|
*/
|
|
async deleteMenu(name: string): Promise<void> {
|
|
// Find menu row and click delete
|
|
const menuRow = this.page.locator(`a[href*="/menus/${name}"]`).first();
|
|
const row = menuRow.locator("..");
|
|
await row.locator('button:has(svg[class*="Trash"])').click();
|
|
|
|
// Confirm deletion
|
|
await this.page.click('button:has-text("Delete"):not([disabled])');
|
|
}
|
|
|
|
/**
|
|
* Get list of menu items
|
|
*/
|
|
async getMenuItems(): Promise<string[]> {
|
|
const items = this.page.locator(".border.rounded-lg.p-4 .font-medium");
|
|
const texts = await items.allTextContents();
|
|
return texts;
|
|
}
|
|
|
|
// ============================================
|
|
// i18n / Translation Actions
|
|
// ============================================
|
|
|
|
/**
|
|
* Get the locale column values from the content list table.
|
|
* Returns empty array if locale column is not shown.
|
|
*/
|
|
async getLocaleColumnValues(): Promise<string[]> {
|
|
const cells = this.page.locator("table tbody tr td span.rounded.bg-kumo-tint");
|
|
return cells.allTextContents();
|
|
}
|
|
|
|
/**
|
|
* Get the locale badge shown in the content editor header.
|
|
* Returns null if no locale badge is visible.
|
|
*/
|
|
async getEditorLocaleBadge(): Promise<string | null> {
|
|
const badge = this.page
|
|
.locator("span.rounded.bg-kumo-tint.text-xs.font-semibold.uppercase")
|
|
.first();
|
|
if (await badge.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
return badge.textContent();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get available translation locales from the translations sidebar.
|
|
* Returns an array of locale codes shown in the sidebar.
|
|
*/
|
|
async getTranslationSidebarLocales(): Promise<string[]> {
|
|
const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))");
|
|
const localeCodes = sidebar.locator("span.text-xs.font-semibold.uppercase");
|
|
return localeCodes.allTextContents();
|
|
}
|
|
|
|
/**
|
|
* Click the "Translate" button for a specific locale in the translations sidebar.
|
|
*/
|
|
async clickTranslate(locale: string): Promise<void> {
|
|
// Find the translation row for this locale (the div containing the locale code)
|
|
const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))");
|
|
const localeRow = sidebar.locator(`div:has(> div > span.uppercase:text-is("${locale}"))`);
|
|
await localeRow.getByRole("button", { name: "Translate" }).click();
|
|
}
|
|
|
|
/**
|
|
* Click "Edit" link for an existing translation in the sidebar.
|
|
*/
|
|
async clickEditTranslation(locale: string): Promise<void> {
|
|
const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))");
|
|
const localeRow = sidebar.locator(`div:has(> div > span.uppercase:text-is("${locale}"))`);
|
|
await localeRow.getByRole("link", { name: "Edit" }).click();
|
|
}
|
|
|
|
/**
|
|
* Check if a "Translate" button exists for a locale in the translations sidebar.
|
|
*/
|
|
async hasTranslateButton(locale: string): Promise<boolean> {
|
|
const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))");
|
|
const localeRow = sidebar.locator(`div:has(> div > span.uppercase:text-is("${locale}"))`);
|
|
return localeRow
|
|
.getByRole("button", { name: "Translate" })
|
|
.isVisible({ timeout: 3000 })
|
|
.catch(() => false);
|
|
}
|
|
|
|
/**
|
|
* Check if an "Edit" link exists for a locale in the translations sidebar.
|
|
*/
|
|
async hasEditTranslationLink(locale: string): Promise<boolean> {
|
|
const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))");
|
|
const localeRow = sidebar.locator(`div:has(> div > span.uppercase:text-is("${locale}"))`);
|
|
return localeRow
|
|
.getByRole("link", { name: "Edit" })
|
|
.isVisible({ timeout: 3000 })
|
|
.catch(() => false);
|
|
}
|
|
|
|
/**
|
|
* Get the locale switcher select value from the content list.
|
|
*/
|
|
async getLocaleFilterValue(): Promise<string | null> {
|
|
const select = this.page.locator("select").first();
|
|
if (await select.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
return select.inputValue();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Change the locale filter in the content list.
|
|
*/
|
|
async setLocaleFilter(locale: string): Promise<void> {
|
|
await this.page.locator("select").first().selectOption(locale);
|
|
await this.waitForLoading();
|
|
}
|
|
|
|
// ============================================
|
|
// Assertions
|
|
// ============================================
|
|
|
|
/**
|
|
* Assert we're on the dashboard
|
|
*/
|
|
async expectDashboard(): Promise<void> {
|
|
await expect(this.page).toHaveURL(ADMIN_DASHBOARD_PATTERN);
|
|
}
|
|
|
|
/**
|
|
* Assert we're on the setup page
|
|
*/
|
|
async expectSetupPage(): Promise<void> {
|
|
await expect(this.page).toHaveURL(SETUP_PAGE_PATTERN);
|
|
}
|
|
|
|
/**
|
|
* Assert a toast message appears
|
|
*/
|
|
async expectToast(text: string): Promise<void> {
|
|
await expect(this.page.locator('[role="status"]', { hasText: text })).toBeVisible();
|
|
}
|
|
|
|
/**
|
|
* Assert content exists in list
|
|
*/
|
|
async expectContentInList(title: string): Promise<void> {
|
|
await expect(this.page.locator("td", { hasText: title })).toBeVisible();
|
|
}
|
|
|
|
/**
|
|
* Assert content does not exist in list
|
|
*/
|
|
async expectContentNotInList(title: string): Promise<void> {
|
|
await expect(this.page.locator("td", { hasText: title })).not.toBeVisible();
|
|
}
|
|
|
|
/**
|
|
* Assert menu exists in list
|
|
*/
|
|
async expectMenuInList(label: string): Promise<void> {
|
|
await expect(this.page.locator("h3", { hasText: label })).toBeVisible();
|
|
}
|
|
|
|
/**
|
|
* Assert page title
|
|
*/
|
|
async expectPageTitle(title: string): Promise<void> {
|
|
await expect(this.page.locator("h1").first()).toContainText(title);
|
|
}
|
|
}
|