Files
clawfort/e2e/tests/fixtures/test.ts
2026-02-13 16:57:45 -05:00

348 lines
8.4 KiB
TypeScript

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<void>;
/**
* Set theme preference on the page
*/
setTheme: (theme: ThemeProfile) => Promise<void>;
/**
* Set viewport to a named profile
*/
setViewport: (profile: ViewportProfile) => Promise<void>;
/**
* Wait for hero section to be loaded
*/
waitForHero: () => Promise<Locator>;
/**
* Wait for news feed to be loaded
*/
waitForFeed: () => Promise<Locator>;
/**
* Get hero article data from page
*/
getHeroArticle: () => Promise<TestArticle | null>;
/**
* Get feed articles data from page
*/
getFeedArticles: () => Promise<TestArticle[]>;
/**
* Open summary modal for an article
*/
openSummaryModal: (articleId: number) => Promise<Locator>;
/**
* Close summary modal
*/
closeSummaryModal: () => Promise<void>;
/**
* Check if summary modal is open
*/
isSummaryModalOpen: () => Promise<boolean>;
/**
* Get stable selector for critical controls
*/
getControl: (name: string) => Locator;
/**
* Wait for app to be fully initialized
*/
waitForAppReady: () => Promise<void>;
}
/**
* Extended test with ClawFort fixtures
*/
export const test = base.extend<ClawFortFixtures>({
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<string, string> = {
"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 };