import type { Locator, Page, } from '@playwright/test'; import { BasePage } from './base-page'; /** * Page object for the root comparison view. Encapsulates locators for the * primary controls so tests don't hardcode aria-labels or DOM structure. * * Selection flow: clicking a font row assigns it to whichever side * (`A` = "Left Font" / Primary, `B` = "Right Font" / Secondary) is currently * active in the Sidebar — there's no per-row A/B toggle. */ export class ComparisonPage extends BasePage { readonly searchInput: Locator; readonly previewInput: Locator; readonly slider: Locator; readonly primarySideButton: Locator; readonly secondarySideButton: Locator; readonly primaryFont: Locator; readonly secondaryFont: Locator; readonly fontList: Locator; constructor(page: Page) { super(page); this.searchInput = page.getByRole('textbox', { name: 'Search typefaces' }); this.previewInput = page.getByRole('textbox', { name: 'Preview text' }); this.slider = page.getByRole('slider', { name: 'Font comparison slider' }); // ARIA-controls couples the side toggle to the font display it targets — copy-independent. this.primarySideButton = page.locator('[aria-controls="primary-font"]'); this.secondarySideButton = page.locator('[aria-controls="secondary-font"]'); this.primaryFont = page.locator('#primary-font'); this.secondaryFont = page.locator('#secondary-font'); this.fontList = page.locator('[data-font-list]'); } /** * Open the root page and wait for the main controls to be interactable. * Uses lg+ viewport for the preview input to be visible. */ async open() { await this.goto('/'); await this.searchInput.waitFor({ state: 'visible' }); } async searchFor(query: string) { await this.searchInput.fill(query); } async setPreviewText(text: string) { await this.previewInput.fill(text); } /** * Switch which side the next font click will assign to. */ async selectSide(side: 'A' | 'B') { const button = side === 'A' ? this.primarySideButton : this.secondarySideButton; await button.click(); } /** * Read which side is currently active from `aria-pressed`. * Falls back to A when neither button reports pressed (initial state in some flows). */ async activeSide(): Promise<'A' | 'B' | null> { const [primaryPressed, secondaryPressed] = await Promise.all([ this.primarySideButton.getAttribute('aria-pressed'), this.secondarySideButton.getAttribute('aria-pressed'), ]); if (primaryPressed === 'true') { return 'A'; } if (secondaryPressed === 'true') { return 'B'; } return null; } /** * Search for a font and click the matching list row. The row's accessible * name is the font name itself (rendered by FontApplicator). */ async pickFont(name: string) { await this.searchFor(name); const row = this.fontList.getByRole('button', { name, exact: true }); await row.click(); } /** * Assign fontA to side A and fontB to side B in one call. */ async pickPair(fontA: string, fontB: string) { await this.selectSide('A'); await this.pickFont(fontA); await this.selectSide('B'); await this.pickFont(fontB); } /** * Read aria-valuenow off the comparison slider. */ async sliderValue(): Promise { const value = await this.slider.getAttribute('aria-valuenow'); return Number(value); } /** * Snapshot the glyphdiff:* localStorage entries. */ async readStorage(): Promise> { return await this.page.evaluate(() => { const out: Record = {}; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i)!; if (key.startsWith('glyphdiff:')) { out[key] = localStorage.getItem(key); } } return out; }); } /** * Whether the document.fonts FontFaceSet contains a fully-loaded face for * the named family. Counts only faces registered via the FontFace API — * system-installed fallbacks (which `document.fonts.check` honours) are * excluded, so a `false` here is meaningful in negative assertions. */ async fontLoaded(name: string): Promise { return await this.page.evaluate(target => { for (const face of document.fonts) { // FontFace.family is wrapped in quotes only if the literal was; // strip any surrounding quotes before comparing. const family = face.family.replace(/^["']|["']$/g, ''); if (family === target && face.status === 'loaded') { return true; } } return false; }, name); } }