348 lines
8.4 KiB
TypeScript
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 };
|