p19-bug-fixes
This commit is contained in:
271
e2e/tests/fixtures/accessibility.ts
vendored
Normal file
271
e2e/tests/fixtures/accessibility.ts
vendored
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Accessibility helpers for WCAG 2.2 AA testing
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* WCAG 2.2 AA contrast ratio requirements
|
||||
*/
|
||||
export const WCAG_CONTRAST = {
|
||||
normalText: 4.5,
|
||||
largeText: 3,
|
||||
uiComponents: 3,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Check if an element has visible focus indicator
|
||||
*/
|
||||
export async function hasFocusVisible(
|
||||
page: Page,
|
||||
locator: Locator,
|
||||
): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
(element: Element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
const outline = style.outlineWidth;
|
||||
const boxShadow = style.boxShadow;
|
||||
|
||||
// Check for outline or box-shadow focus indicator
|
||||
return (
|
||||
(outline && outline !== "0px" && outline !== "none") ||
|
||||
(boxShadow && boxShadow !== "none")
|
||||
);
|
||||
},
|
||||
(await locator.elementHandle()) as Element,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get computed color values for an element
|
||||
*/
|
||||
export async function getComputedColors(
|
||||
page: Page,
|
||||
locator: Locator,
|
||||
): Promise<{
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
}> {
|
||||
return page.evaluate(
|
||||
(element: Element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
};
|
||||
},
|
||||
(await locator.elementHandle()) as Element,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate relative luminance of a color
|
||||
* https://www.w3.org/TR/WCAG20/#relativeluminancedef
|
||||
*/
|
||||
export function getLuminance(r: number, g: number, b: number): number {
|
||||
const [rs, gs, bs] = [r, g, b].map((val) => {
|
||||
const sRGB = val / 255;
|
||||
return sRGB <= 0.03928 ? sRGB / 12.92 : ((sRGB + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RGB color string
|
||||
*/
|
||||
export function parseRGB(
|
||||
color: string,
|
||||
): { r: number; g: number; b: number } | null {
|
||||
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
r: parseInt(match[1], 10),
|
||||
g: parseInt(match[2], 10),
|
||||
b: parseInt(match[3], 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RGBA color string
|
||||
*/
|
||||
export function parseRGBA(
|
||||
color: string,
|
||||
): { r: number; g: number; b: number; a: number } | null {
|
||||
const match = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
r: parseInt(match[1], 10),
|
||||
g: parseInt(match[2], 10),
|
||||
b: parseInt(match[3], 10),
|
||||
a: parseFloat(match[4]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate contrast ratio between two luminances
|
||||
* https://www.w3.org/TR/WCAG20/#contrast-ratiodef
|
||||
*/
|
||||
export function getContrastRatio(lum1: number, lum2: number): number {
|
||||
const lighter = Math.max(lum1, lum2);
|
||||
const darker = Math.min(lum1, lum2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check contrast ratio between two colors
|
||||
*/
|
||||
export function checkContrast(color1: string, color2: string): number | null {
|
||||
const rgb1 = parseRGB(color1) || parseRGBA(color1);
|
||||
const rgb2 = parseRGB(color2) || parseRGBA(color2);
|
||||
|
||||
if (!rgb1 || !rgb2) return null;
|
||||
|
||||
const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
|
||||
const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);
|
||||
|
||||
return getContrastRatio(lum1, lum2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert element meets WCAG AA contrast requirements
|
||||
*/
|
||||
export async function assertContrast(
|
||||
page: Page,
|
||||
locator: Locator,
|
||||
minRatio: number = WCAG_CONTRAST.normalText,
|
||||
): Promise<void> {
|
||||
const colors = await getComputedColors(page, locator);
|
||||
const ratio = checkContrast(colors.color, colors.backgroundColor);
|
||||
|
||||
if (ratio === null) {
|
||||
throw new Error("Could not calculate contrast ratio");
|
||||
}
|
||||
|
||||
if (ratio < minRatio) {
|
||||
throw new Error(
|
||||
`Contrast ratio ${ratio.toFixed(2)} is below required ${minRatio} ` +
|
||||
`(color: ${colors.color}, background: ${colors.backgroundColor})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element has accessible name
|
||||
*/
|
||||
export async function hasAccessibleName(locator: Locator): Promise<boolean> {
|
||||
const name = await locator.getAttribute("aria-label");
|
||||
const labelledBy = await locator.getAttribute("aria-labelledby");
|
||||
const title = await locator.getAttribute("title");
|
||||
|
||||
// Check for text content if it's a button or link
|
||||
const tagName = await locator.evaluate((el) => el.tagName.toLowerCase());
|
||||
let textContent = "";
|
||||
if (tagName === "button" || tagName === "a") {
|
||||
textContent = (await locator.textContent()) || "";
|
||||
}
|
||||
|
||||
return !!(name || labelledBy || title || textContent.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accessible name for an element
|
||||
*/
|
||||
export async function getAccessibleName(
|
||||
page: Page,
|
||||
locator: Locator,
|
||||
): Promise<string | null> {
|
||||
return page.evaluate(
|
||||
(element: Element) => {
|
||||
// Check aria-label
|
||||
const ariaLabel = element.getAttribute("aria-label");
|
||||
if (ariaLabel) return ariaLabel;
|
||||
|
||||
// Check aria-labelledby
|
||||
const labelledBy = element.getAttribute("aria-labelledby");
|
||||
if (labelledBy) {
|
||||
const labelElement = document.getElementById(labelledBy);
|
||||
if (labelElement) return labelElement.textContent;
|
||||
}
|
||||
|
||||
// Check title attribute
|
||||
const title = element.getAttribute("title");
|
||||
if (title) return title;
|
||||
|
||||
// Check text content for interactive elements
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
if (tagName === "button" || tagName === "a") {
|
||||
return element.textContent?.trim() || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
(await locator.elementHandle()) as Element,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test keyboard navigation through a set of elements
|
||||
*/
|
||||
export async function testKeyboardNavigation(
|
||||
page: Page,
|
||||
selectors: string[],
|
||||
): Promise<void> {
|
||||
// Focus first element
|
||||
await page.focus(selectors[0]);
|
||||
|
||||
for (let i = 0; i < selectors.length; i++) {
|
||||
const activeElement = await page.evaluate(
|
||||
() =>
|
||||
document.activeElement?.getAttribute("data-testid") ||
|
||||
document.activeElement?.getAttribute("aria-label") ||
|
||||
document.activeElement?.textContent?.trim() ||
|
||||
document.activeElement?.tagName,
|
||||
);
|
||||
|
||||
// Check focus is visible
|
||||
const hasFocus = await page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
if (!active || active === document.body) return false;
|
||||
|
||||
const style = window.getComputedStyle(active);
|
||||
return (
|
||||
(style.outlineWidth && style.outlineWidth !== "0px") ||
|
||||
(style.boxShadow && style.boxShadow !== "none")
|
||||
);
|
||||
});
|
||||
|
||||
if (!hasFocus) {
|
||||
throw new Error(`Focus not visible on element: ${activeElement}`);
|
||||
}
|
||||
|
||||
// Press Tab to move to next element
|
||||
if (i < selectors.length - 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert focus is contained within a modal
|
||||
*/
|
||||
export async function assertFocusContained(
|
||||
page: Page,
|
||||
modalSelector: string,
|
||||
): Promise<void> {
|
||||
const isContained = await page.evaluate((selector: string) => {
|
||||
const modal = document.querySelector(selector);
|
||||
if (!modal) return false;
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
return modal.contains(activeElement);
|
||||
}, modalSelector);
|
||||
|
||||
if (!isContained) {
|
||||
throw new Error("Focus is not contained within modal");
|
||||
}
|
||||
}
|
||||
164
e2e/tests/fixtures/selectors.ts
vendored
Normal file
164
e2e/tests/fixtures/selectors.ts
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Stable selector strategy for ClawFort UI elements
|
||||
*
|
||||
* Strategy (in order of preference):
|
||||
* 1. data-testid attributes (most stable)
|
||||
* 2. ARIA roles with accessible names
|
||||
* 3. Semantic HTML elements with text content
|
||||
* 4. ID selectors (for unique elements)
|
||||
* 5. Structural selectors (last resort)
|
||||
*/
|
||||
|
||||
export const SELECTORS = {
|
||||
// Header controls
|
||||
header: {
|
||||
root: "header",
|
||||
logo: 'a[href="/"]',
|
||||
languageSelect: "#language-select",
|
||||
themeMenuButton: "#theme-menu-button",
|
||||
themeMenu: "#theme-menu",
|
||||
themeOption: (theme: string) => `[data-theme-option="${theme}"]`,
|
||||
},
|
||||
|
||||
// Skip link
|
||||
skipLink: 'a[href="#main-content"]',
|
||||
|
||||
// Hero section
|
||||
hero: {
|
||||
root: 'article[itemscope][itemtype="https://schema.org/NewsArticle"]:first-of-type',
|
||||
headline: "h1",
|
||||
summary: ".hero-summary",
|
||||
meta: ".hero-meta",
|
||||
readButton: 'button:has-text("Read TL;DR")',
|
||||
sourceLink: "a.source-link",
|
||||
image: 'img[fetchpriority="high"]',
|
||||
latestPill: ".hero-latest-pill",
|
||||
timePill: ".hero-time-pill",
|
||||
},
|
||||
|
||||
// News feed
|
||||
feed: {
|
||||
root: 'section:has(h2:has-text("Recent News"))',
|
||||
articles:
|
||||
'article[itemscope][itemtype="https://schema.org/NewsArticle"]:not(:first-of-type)',
|
||||
article: (id: number) => `#news-${id}`,
|
||||
articleTitle: "h3",
|
||||
articleSummary: ".news-card-summary",
|
||||
articleReadButton: 'button:has-text("Read TL;DR")',
|
||||
articleSource: "a.source-link",
|
||||
},
|
||||
|
||||
// Summary modal
|
||||
summaryModal: {
|
||||
root: '[role="dialog"][aria-modal="true"]:has-text("TL;DR")',
|
||||
closeButton: 'button:has-text("Close")',
|
||||
headline: "h2",
|
||||
image: "img",
|
||||
tldrSection: 'h3:has-text("TL;DR")',
|
||||
tldrList: "ul",
|
||||
summarySection: 'h3:has-text("Summary")',
|
||||
summaryBody: ".modal-body-text",
|
||||
sourceSection: 'h3:has-text("Source and Citation")',
|
||||
sourceLink: 'a:has-text("Read Full Article")',
|
||||
shareSection: 'h3:has-text("Share")',
|
||||
shareX: '[aria-label="Share on X"]',
|
||||
shareWhatsApp: '[aria-label="Share on WhatsApp"]',
|
||||
shareLinkedIn: '[aria-label="Share on LinkedIn"]',
|
||||
shareCopy: '[aria-label="Copy article link"]',
|
||||
copySuccess: "text=Permalink copied.",
|
||||
poweredBy: "text=Powered by Perplexity",
|
||||
},
|
||||
|
||||
// Policy modals
|
||||
policyModal: {
|
||||
root: '[role="dialog"][aria-modal="true"]:has(h2)',
|
||||
closeButton: 'button:has-text("Close")',
|
||||
termsTitle: 'h2:has-text("Terms of Use")',
|
||||
attributionTitle: 'h2:has-text("Attribution and Ownership Disclaimer")',
|
||||
},
|
||||
|
||||
// Footer
|
||||
footer: {
|
||||
root: "footer",
|
||||
poweredBy: 'a[href*="perplexity"]',
|
||||
termsLink: 'button:has-text("Terms of Use")',
|
||||
attributionLink: 'button:has-text("Attribution")',
|
||||
githubLink: 'a:has-text("GitHub")',
|
||||
contactLink: 'a[href^="mailto:"]',
|
||||
contactHint: "#contact-hint",
|
||||
copyright: "text=All rights reserved",
|
||||
},
|
||||
|
||||
// Back to top
|
||||
backToTop: {
|
||||
root: '[aria-label="Back to top"]',
|
||||
icon: "svg",
|
||||
},
|
||||
|
||||
// Theme menu
|
||||
themeMenu: {
|
||||
root: "#theme-menu",
|
||||
options: '[role="menuitem"]',
|
||||
option: (theme: string) => `[data-theme-option="${theme}"]`,
|
||||
},
|
||||
|
||||
// Empty state
|
||||
emptyState: {
|
||||
root: '.text-6xl:has-text("🤖")',
|
||||
heading: 'h2:has-text("No News Yet")',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get a stable locator string for a control
|
||||
*/
|
||||
export function getSelector(path: string): string {
|
||||
const parts = path.split(".");
|
||||
let current: any = SELECTORS;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current[part] === undefined) {
|
||||
throw new Error(`Invalid selector path: ${path}`);
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
if (typeof current === "function") {
|
||||
throw new Error(`Selector path requires parameter: ${path}`);
|
||||
}
|
||||
|
||||
return current as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test ID attributes that should be added to the frontend for better test stability
|
||||
*/
|
||||
export const RECOMMENDED_TEST_IDS = {
|
||||
// Header
|
||||
"header-root": "header",
|
||||
"header-logo": "header-logo",
|
||||
"language-select": "language-select",
|
||||
"theme-menu-button": "theme-menu-button",
|
||||
|
||||
// Hero
|
||||
"hero-article": "hero-article",
|
||||
"hero-headline": "hero-headline",
|
||||
"hero-read-button": "hero-read-button",
|
||||
|
||||
// Feed
|
||||
"feed-section": "feed-section",
|
||||
"feed-article": (id: number) => `feed-article-${id}`,
|
||||
"feed-read-button": (id: number) => `feed-read-button-${id}`,
|
||||
|
||||
// Modal
|
||||
"summary-modal": "summary-modal",
|
||||
"summary-modal-close": "summary-modal-close",
|
||||
"summary-modal-headline": "summary-modal-headline",
|
||||
|
||||
// Footer
|
||||
"footer-root": "footer",
|
||||
"footer-contact": "footer-contact",
|
||||
|
||||
// Back to top
|
||||
"back-to-top": "back-to-top",
|
||||
};
|
||||
347
e2e/tests/fixtures/test.ts
vendored
Normal file
347
e2e/tests/fixtures/test.ts
vendored
Normal file
@@ -0,0 +1,347 @@
|
||||
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 };
|
||||
107
e2e/tests/fixtures/themes.ts
vendored
Normal file
107
e2e/tests/fixtures/themes.ts
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Theme profile helpers for testing across light/dark/contrast themes
|
||||
*/
|
||||
|
||||
export type Theme = "light" | "dark" | "contrast";
|
||||
|
||||
export const THEMES: Theme[] = ["light", "dark", "contrast"];
|
||||
|
||||
/**
|
||||
* Theme-specific CSS custom property values
|
||||
*/
|
||||
export const THEME_VALUES = {
|
||||
light: {
|
||||
"--cf-bg": "#f8fafc",
|
||||
"--cf-text": "#0f172a",
|
||||
"--cf-text-strong": "#0f172a",
|
||||
"--cf-text-muted": "#475569",
|
||||
"--cf-link": "#1d4ed8",
|
||||
"--cf-link-hover": "#1e40af",
|
||||
"--cf-link-visited": "#6d28d9",
|
||||
"--cf-card-bg": "#ffffff",
|
||||
},
|
||||
dark: {
|
||||
"--cf-bg": "#0f172a",
|
||||
"--cf-text": "#f1f5f9",
|
||||
"--cf-text-strong": "#e2e8f0",
|
||||
"--cf-text-muted": "#94a3b8",
|
||||
"--cf-link": "#93c5fd",
|
||||
"--cf-link-hover": "#bfdbfe",
|
||||
"--cf-link-visited": "#c4b5fd",
|
||||
"--cf-card-bg": "#1e293b",
|
||||
},
|
||||
contrast: {
|
||||
"--cf-bg": "#000000",
|
||||
"--cf-text": "#ffffff",
|
||||
"--cf-text-strong": "#ffffff",
|
||||
"--cf-text-muted": "#f8fafc",
|
||||
"--cf-link": "#ffff80",
|
||||
"--cf-link-hover": "#ffff00",
|
||||
"--cf-link-visited": "#ffb3ff",
|
||||
"--cf-card-bg": "#000000",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* WCAG 2.2 AA contrast ratio requirements
|
||||
*/
|
||||
export const WCAG_CONTRAST = {
|
||||
normalText: 4.5,
|
||||
largeText: 3,
|
||||
uiComponents: 3,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Set theme on the page
|
||||
*/
|
||||
export async function setTheme(page: any, theme: Theme): Promise<void> {
|
||||
// Open theme menu
|
||||
await page.click("#theme-menu-button");
|
||||
|
||||
// Click theme option
|
||||
await page.click(`[data-theme-option="${theme}"]`);
|
||||
|
||||
// Wait for theme to apply
|
||||
await page.waitForSelector(`html[data-theme="${theme}"]`, { timeout: 5000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get computed CSS custom property value
|
||||
*/
|
||||
export async function getCssVariable(
|
||||
page: any,
|
||||
variable: string,
|
||||
): Promise<string> {
|
||||
return page.evaluate((varName: string) => {
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
}, variable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a theme is active
|
||||
*/
|
||||
export async function assertThemeActive(
|
||||
page: any,
|
||||
theme: Theme,
|
||||
): Promise<void> {
|
||||
const themeAttr = await page.getAttribute("html", "data-theme");
|
||||
if (themeAttr !== theme) {
|
||||
throw new Error(`Expected theme "${theme}" but got "${themeAttr}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a test across all themes
|
||||
*/
|
||||
export function testAllThemes(
|
||||
name: string,
|
||||
testFn: (theme: Theme) => Promise<void> | void,
|
||||
): void {
|
||||
for (const theme of THEMES) {
|
||||
test(`${name} - ${theme} theme`, async () => {
|
||||
await testFn(theme);
|
||||
});
|
||||
}
|
||||
}
|
||||
151
e2e/tests/fixtures/viewports.ts
vendored
Normal file
151
e2e/tests/fixtures/viewports.ts
vendored
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Viewport profile helpers for responsive testing
|
||||
*/
|
||||
|
||||
export type ViewportSize = "mobile" | "tablet" | "desktop" | "widescreen";
|
||||
|
||||
export interface ViewportDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const VIEWPORTS: Record<ViewportSize, ViewportDimensions> = {
|
||||
mobile: { width: 375, height: 667 }, // iPhone SE / similar
|
||||
tablet: { width: 768, height: 1024 }, // iPad / similar
|
||||
desktop: { width: 1280, height: 720 }, // Standard desktop
|
||||
widescreen: { width: 1920, height: 1080 }, // Large desktop
|
||||
} as const;
|
||||
|
||||
export const VIEWPORT_SIZES: ViewportSize[] = [
|
||||
"mobile",
|
||||
"tablet",
|
||||
"desktop",
|
||||
"widescreen",
|
||||
];
|
||||
|
||||
/**
|
||||
* Breakpoint definitions matching CSS media queries
|
||||
*/
|
||||
export const BREAKPOINTS = {
|
||||
sm: 640, // Small devices
|
||||
md: 768, // Medium devices (tablet)
|
||||
lg: 1024, // Large devices (desktop)
|
||||
xl: 1280, // Extra large
|
||||
"2xl": 1536, // 2X large
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Set viewport size
|
||||
*/
|
||||
export async function setViewport(
|
||||
page: any,
|
||||
size: ViewportSize,
|
||||
): Promise<void> {
|
||||
const dimensions = VIEWPORTS[size];
|
||||
await page.setViewportSize(dimensions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current viewport size
|
||||
*/
|
||||
export async function getViewport(page: any): Promise<ViewportDimensions> {
|
||||
return page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert viewport is at expected size
|
||||
*/
|
||||
export async function assertViewport(
|
||||
page: any,
|
||||
size: ViewportSize,
|
||||
): Promise<void> {
|
||||
const expected = VIEWPORTS[size];
|
||||
const actual = await getViewport(page);
|
||||
|
||||
if (actual.width !== expected.width || actual.height !== expected.height) {
|
||||
throw new Error(
|
||||
`Expected viewport ${size} (${expected.width}x${expected.height}) ` +
|
||||
`but got ${actual.width}x${actual.height}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element has horizontal overflow
|
||||
*/
|
||||
export async function hasHorizontalOverflow(
|
||||
page: any,
|
||||
selector: string,
|
||||
): Promise<boolean> {
|
||||
return page.evaluate((sel: string) => {
|
||||
const element = document.querySelector(sel);
|
||||
if (!element) return false;
|
||||
return element.scrollWidth > element.clientWidth;
|
||||
}, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element is clipped (overflow hidden with content exceeding bounds)
|
||||
*/
|
||||
export async function isClipped(page: any, selector: string): Promise<boolean> {
|
||||
return page.evaluate((sel: string) => {
|
||||
const element = document.querySelector(sel);
|
||||
if (!element) return false;
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
// Check if element has overflow hidden and children exceed bounds
|
||||
const children = element.children;
|
||||
for (const child of children) {
|
||||
const childRect = child.getBoundingClientRect();
|
||||
if (childRect.right > rect.right || childRect.bottom > rect.bottom) {
|
||||
return (
|
||||
style.overflow === "hidden" ||
|
||||
style.overflowX === "hidden" ||
|
||||
style.overflowY === "hidden"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a test across all viewport sizes
|
||||
*/
|
||||
export function testAllViewports(
|
||||
name: string,
|
||||
testFn: (size: ViewportSize) => Promise<void> | void,
|
||||
): void {
|
||||
for (const size of VIEWPORT_SIZES) {
|
||||
test(`${name} - ${size}`, async () => {
|
||||
await testFn(size);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check sticky element position
|
||||
*/
|
||||
export async function getStickyPosition(
|
||||
page: any,
|
||||
selector: string,
|
||||
): Promise<{ top: number; isSticky: boolean }> {
|
||||
return page.evaluate((sel: string) => {
|
||||
const element = document.querySelector(sel);
|
||||
if (!element) return { top: 0, isSticky: false };
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: rect.top,
|
||||
isSticky: style.position === "sticky" || style.position === "fixed",
|
||||
};
|
||||
}, selector);
|
||||
}
|
||||
Reference in New Issue
Block a user