/** * 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 { // 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 { 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 `` after hydration. */ async waitForHydration(): Promise { await this.page.waitForSelector("astro-island:not([ssr])", { timeout: 15000 }); } /** * Wait for the admin shell to be ready (hydrated and interactive) */ async waitForShell(): Promise { // 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 { 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 { // 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 { // 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 { await this.goto("/"); await this.waitForShell(); } /** * Navigate to content list for a collection */ async goToContent(collection: string): Promise { await this.goto(`/content/${collection}`); await this.waitForShell(); } /** * Navigate to create new content */ async goToNewContent(collection: string): Promise { await this.goto(`/content/${collection}/new`); await this.waitForShell(); } /** * Navigate to edit content */ async goToEditContent(collection: string, id: string): Promise { await this.goto(`/content/${collection}/${id}`); await this.waitForShell(); } /** * Navigate to media library */ async goToMedia(): Promise { await this.goto("/media"); await this.waitForShell(); } /** * Navigate to menus list */ async goToMenus(): Promise { await this.goto("/menus"); await this.waitForShell(); } /** * Navigate to edit a specific menu */ async goToMenuEditor(name: string): Promise { await this.goto(`/menus/${name}`); await this.waitForShell(); } /** * Navigate to settings */ async goToSettings(): Promise { await this.goto("/settings"); await this.waitForShell(); } /** * Navigate to setup wizard */ async goToSetup(): Promise { await this.goto("/setup"); } // ============================================ // Setup Wizard Actions // ============================================ /** * Complete the setup wizard */ async completeSetup(options: { title: string; tagline?: string; includeContent?: boolean; }): Promise { // 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): Promise { 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 { await this.fillField(field, value); } /** * Fill a form field by slug (uses #field-{slug} convention) */ async fillField(slug: string, value: string): Promise { const input = this.page.locator(`#field-${slug}`); await input.fill(value); } /** * Click the save button */ async clickSave(): Promise { await this.page.locator('button:has-text("Save")').click(); } /** * Wait for save to complete */ async waitForSaveComplete(): Promise { // 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 { // 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 { // 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 { const items = this.page.locator('[class*="grid"] > div'); return items.count(); } /** * Delete a media item by filename */ async deleteMedia(filename: string): Promise { // 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 { // 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 { // 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 { // 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 { 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 { 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 { 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 { 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 { // 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 { 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 { 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 { 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 { 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 { await this.page.locator("select").first().selectOption(locale); await this.waitForLoading(); } // ============================================ // Assertions // ============================================ /** * Assert we're on the dashboard */ async expectDashboard(): Promise { await expect(this.page).toHaveURL(ADMIN_DASHBOARD_PATTERN); } /** * Assert we're on the setup page */ async expectSetupPage(): Promise { await expect(this.page).toHaveURL(SETUP_PAGE_PATTERN); } /** * Assert a toast message appears */ async expectToast(text: string): Promise { await expect(this.page.locator('[role="status"]', { hasText: text })).toBeVisible(); } /** * Assert content exists in list */ async expectContentInList(title: string): Promise { await expect(this.page.locator("td", { hasText: title })).toBeVisible(); } /** * Assert content does not exist in list */ async expectContentNotInList(title: string): Promise { await expect(this.page.locator("td", { hasText: title })).not.toBeVisible(); } /** * Assert menu exists in list */ async expectMenuInList(label: string): Promise { await expect(this.page.locator("h3", { hasText: label })).toBeVisible(); } /** * Assert page title */ async expectPageTitle(title: string): Promise { await expect(this.page.locator("h1").first()).toContainText(title); } }