import { test as base, expect, type Locator, Page } from "@playwright/test"; /** * Viewport profiles for responsive testing */ export const VIEWPORT_PROFILES = { mobile: { width: 375, height: 667 }, tablet: { width: 768, height: 1024 }, desktop: { width: 1280, height: 720 }, widescreen: { width: 1920, height: 1080 }, } as const; /** * Theme profiles for accessibility testing */ export const THEME_PROFILES = { light: "light", dark: "dark", contrast: "contrast", } as const; export type ThemeProfile = keyof typeof THEME_PROFILES; export type ViewportProfile = keyof typeof VIEWPORT_PROFILES; /** * Article data shape for deterministic testing */ export interface TestArticle { id: number; headline: string; summary: string; source_url: string; source_citation: string; published_at: string; image_url: string; summary_image_url?: string; tldr_points?: string[]; summary_body?: string; language?: string; } /** * Test fixture interface */ export interface ClawFortFixtures { /** * Navigate to application with optional article permalink */ gotoApp: (options?: { articleId?: number; policy?: "terms" | "attribution"; }) => Promise; /** * Set theme preference on the page */ setTheme: (theme: ThemeProfile) => Promise; /** * Set viewport to a named profile */ setViewport: (profile: ViewportProfile) => Promise; /** * Wait for hero section to be loaded */ waitForHero: () => Promise; /** * Wait for news feed to be loaded */ waitForFeed: () => Promise; /** * Get hero article data from page */ getHeroArticle: () => Promise; /** * Get feed articles data from page */ getFeedArticles: () => Promise; /** * Open summary modal for an article */ openSummaryModal: (articleId: number) => Promise; /** * Close summary modal */ closeSummaryModal: () => Promise; /** * Check if summary modal is open */ isSummaryModalOpen: () => Promise; /** * Get stable selector for critical controls */ getControl: (name: string) => Locator; /** * Wait for app to be fully initialized */ waitForAppReady: () => Promise; } /** * Extended test with ClawFort fixtures */ export const test = base.extend({ gotoApp: async ({ page }, use) => { await use(async (options = {}) => { let url = "/"; if (options.articleId) { url = `/?article=${options.articleId}`; } else if (options.policy) { url = `/?policy=${options.policy}`; } await page.goto(url); await page.waitForLoadState("networkidle"); }); }, setTheme: async ({ page }, use) => { await use(async (theme: ThemeProfile) => { // Open theme menu const themeButton = page.locator("#theme-menu-button"); await themeButton.click(); // Select theme option const themeOption = page.locator(`[data-theme-option="${theme}"]`); await themeOption.click(); // Wait for theme to apply await page.waitForSelector(`html[data-theme="${theme}"]`, { timeout: 5000, }); }); }, setViewport: async ({ page }, use) => { await use(async (profile: ViewportProfile) => { const viewport = VIEWPORT_PROFILES[profile]; await page.setViewportSize(viewport); }); }, waitForHero: async ({ page }, use) => { await use(async () => { const hero = page .locator( 'article[itemscope][itemtype="https://schema.org/NewsArticle"]', ) .first(); await hero.waitFor({ state: "visible", timeout: 15000 }); return hero; }); }, waitForFeed: async ({ page }, use) => { await use(async () => { const feed = page.locator('section:has(h2:has-text("Recent News"))'); await feed.waitFor({ state: "visible", timeout: 15000 }); return feed; }); }, getHeroArticle: async ({ page }, use) => { await use(async () => { const hero = await page .locator( 'article[itemscope][itemtype="https://schema.org/NewsArticle"]', ) .first(); // Check if hero exists and has content const count = await hero.count(); if (count === 0) return null; // Extract hero article data const headline = await hero .locator("h1") .textContent() .catch(() => null); const summary = await hero .locator(".hero-summary") .textContent() .catch(() => null); const id = await hero .getAttribute("id") .then((id) => (id ? parseInt(id.replace("news-", "")) : null)); if (!headline || !id) return null; return { id, headline, summary: summary || "", source_url: "", source_citation: "", published_at: new Date().toISOString(), image_url: "", }; }); }, getFeedArticles: async ({ page }, use) => { await use(async () => { const articles = await page .locator( 'article[itemscope][itemtype="https://schema.org/NewsArticle"]', ) .all(); const feedArticles: TestArticle[] = []; for (const article of articles.slice(1)) { // Skip hero const id = await article .getAttribute("id") .then((id) => (id ? parseInt(id.replace("news-", "")) : null)); const headline = await article .locator("h3") .textContent() .catch(() => null); const summary = await article .locator(".news-card-summary") .textContent() .catch(() => null); if (id && headline) { feedArticles.push({ id, headline, summary: summary || "", source_url: "", source_citation: "", published_at: new Date().toISOString(), image_url: "", }); } } return feedArticles; }); }, openSummaryModal: async ({ page }, use) => { await use(async (articleId: number) => { // Find article and click its "Read TL;DR" button const article = page.locator(`#news-${articleId}`); const readButton = article.locator('button:has-text("Read TL;DR")'); await readButton.click(); // Wait for modal to appear const modal = page .locator('[role="dialog"][aria-modal="true"]') .filter({ hasText: "TL;DR" }); await modal.waitFor({ state: "visible", timeout: 5000 }); return modal; }); }, closeSummaryModal: async ({ page }, use) => { await use(async () => { const closeButton = page.locator( '[role="dialog"] button:has-text("Close")', ); await closeButton.click(); // Wait for modal to disappear const modal = page .locator('[role="dialog"][aria-modal="true"]') .filter({ hasText: "TL;DR" }); await modal.waitFor({ state: "hidden", timeout: 5000 }); }); }, isSummaryModalOpen: async ({ page }, use) => { await use(async () => { const modal = page .locator('[role="dialog"][aria-modal="true"]') .filter({ hasText: "TL;DR" }); return await modal.isVisible().catch(() => false); }); }, getControl: async ({ page }, use) => { await use((name: string) => { // Stable selector strategy: prefer test-id, then role, then accessible name const selectors: Record = { "theme-menu": "#theme-menu-button", "language-select": "#language-select", "back-to-top": '[aria-label="Back to top"]', "share-x": '[aria-label="Share on X"]', "share-whatsapp": '[aria-label="Share on WhatsApp"]', "share-linkedin": '[aria-label="Share on LinkedIn"]', "share-copy": '[aria-label="Copy article link"]', "modal-close": '[role="dialog"] button:has-text("Close")', "terms-link": 'button:has-text("Terms of Use")', "attribution-link": 'button:has-text("Attribution")', "hero-read-more": 'article:first-of-type button:has-text("Read TL;DR")', "skip-link": 'a[href="#main-content"]', }; const selector = selectors[name]; if (!selector) { throw new Error( `Unknown control: ${name}. Available controls: ${Object.keys(selectors).join(", ")}`, ); } return page.locator(selector); }); }, waitForAppReady: async ({ page }, use) => { await use(async () => { // Wait for page to be fully loaded await page.waitForLoadState("networkidle"); // Wait for Alpine.js to initialize await page.waitForFunction( () => { return ( document.querySelector("html")?.hasAttribute("data-theme") || document.readyState === "complete" ); }, { timeout: 10000 }, ); // Wait for either hero or feed to be visible await Promise.race([ page.waitForSelector("article[itemscope]", { timeout: 15000 }), page.waitForSelector('.text-6xl:has-text("🤖")', { timeout: 15000 }), // No news state ]); }); }, }); export { expect };