From 24f084ae77868c54a99f8b2c0c130fd3141ffb88 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 28 May 2026 15:52:40 +0300 Subject: [PATCH] test: add e2e suite for core comparison flows Adds 17 new specs across six files plus the supporting POM infrastructure: - fixtures with auto-opened comparison + typography helpers - TypographyMenu POM reading values from ComboControl aria-labels - ComparisonPage extended with side selection, font picking, slider-value reader, FontFaceSet inspection, storage snapshot - compare-flow: A/B selection, aria-pressed state, storage write - preview-text: input binding + slider character rendering - slider: keyboard ARIA contract (Arrow / Shift+Arrow / Page / Home / End) - font-loading: FontFaceSet contains selected, excludes unrelated - persistence: font selection + typography settings survive reload - typography: symmetric increase/decrease for all four controls --- e2e/compare-flow.test.ts | 39 +++++++++++++ e2e/fixtures.ts | 35 ++++++++++++ e2e/font-loading.test.ts | 22 +++++++ e2e/pages/comparison-page.ts | 108 +++++++++++++++++++++++++++++++++++ e2e/pages/typography-menu.ts | 73 +++++++++++++++++++++++ e2e/persistence.test.ts | 41 +++++++++++++ e2e/preview-text.test.ts | 21 +++++++ e2e/slider.test.ts | 46 +++++++++++++++ e2e/typography.test.ts | 44 ++++++++++++++ 9 files changed, 429 insertions(+) create mode 100644 e2e/compare-flow.test.ts create mode 100644 e2e/fixtures.ts create mode 100644 e2e/font-loading.test.ts create mode 100644 e2e/pages/typography-menu.ts create mode 100644 e2e/persistence.test.ts create mode 100644 e2e/preview-text.test.ts create mode 100644 e2e/slider.test.ts create mode 100644 e2e/typography.test.ts diff --git a/e2e/compare-flow.test.ts b/e2e/compare-flow.test.ts new file mode 100644 index 0000000..9727f9a --- /dev/null +++ b/e2e/compare-flow.test.ts @@ -0,0 +1,39 @@ +import { + expect, + test, +} from './fixtures'; + +test.describe('compare flow', () => { + test('selects fontA and fontB onto opposite sides', async ({ comparison }) => { + await comparison.pickPair('Inter', 'Roboto'); + + // Each side's header region exposes the font name independently. + await expect(comparison.primaryFont).toContainText('Inter'); + await expect(comparison.secondaryFont).toContainText('Roboto'); + + // Slider is rendered and interactive once both fonts are picked. + await expect(comparison.slider).toBeVisible(); + }); + + test('reflects active side via aria-pressed', async ({ comparison }) => { + await comparison.selectSide('B'); + expect(await comparison.activeSide()).toBe('B'); + await expect(comparison.secondarySideButton).toHaveAttribute('aria-pressed', 'true'); + await expect(comparison.primarySideButton).toHaveAttribute('aria-pressed', 'false'); + }); + + test('persists selection through the comparisonStore localStorage', async ({ comparison }) => { + await comparison.pickPair('Inter', 'Roboto'); + + // Wait for the store debounce to flush to localStorage. + await expect.poll(async () => { + const storage = await comparison.readStorage(); + return storage['glyphdiff:comparison']; + }).toMatch(/inter/i); + + const storage = await comparison.readStorage(); + const state = JSON.parse(storage['glyphdiff:comparison']!); + expect(state.fontAId).toBe('inter'); + expect(state.fontBId).toBe('roboto'); + }); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..3bf8e5c --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,35 @@ +import { test as base } from '@playwright/test'; +import { ComparisonPage } from './pages/comparison-page'; +import { TypographyMenu } from './pages/typography-menu'; + +type Fixtures = { + /** + * Opened ComparisonPage with the root view loaded. + */ + comparison: ComparisonPage; + /** + * Typography menu helper bound to the same page. + */ + typography: TypographyMenu; +}; + +/** + * Custom test that auto-opens the comparison view before each spec. + * Playwright gives each test a fresh BrowserContext by default, so + * localStorage is empty unless a test seeds it. + */ +export const test = base.extend({ + comparison: async ({ page }, use) => { + const view = new ComparisonPage(page); + await view.open(); + await use(view); + }, + // Depends on `comparison` so the root page is opened before the menu is + // consulted — TypographyMenu has no markup of its own to load. + typography: async ({ comparison, page }, use) => { + void comparison; + await use(new TypographyMenu(page)); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/font-loading.test.ts b/e2e/font-loading.test.ts new file mode 100644 index 0000000..c86a02c --- /dev/null +++ b/e2e/font-loading.test.ts @@ -0,0 +1,22 @@ +import { + expect, + test, +} from './fixtures'; + +test.describe('font loading', () => { + test('selected fonts land in the FontFaceSet with status="loaded"', async ({ comparison }) => { + await comparison.pickPair('Inter', 'Roboto'); + + await expect.poll(() => comparison.fontLoaded('Inter')).toBe(true); + await expect.poll(() => comparison.fontLoaded('Roboto')).toBe(true); + }); + + test('an unrelated font remains absent from the FontFaceSet', async ({ comparison }) => { + await comparison.pickPair('Inter', 'Roboto'); + + // "Audiowide" is unlikely to be on the system AND was not selected, so + // no FontFace should ever have been registered for it. This guards + // against the loader over-fetching neighbouring fonts. + await expect.poll(() => comparison.fontLoaded('Audiowide')).toBe(false); + }); +}); diff --git a/e2e/pages/comparison-page.ts b/e2e/pages/comparison-page.ts index d456604..a42c450 100644 --- a/e2e/pages/comparison-page.ts +++ b/e2e/pages/comparison-page.ts @@ -7,19 +7,37 @@ 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('/'); @@ -33,4 +51,94 @@ export class ComparisonPage extends BasePage { 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); + } } diff --git a/e2e/pages/typography-menu.ts b/e2e/pages/typography-menu.ts new file mode 100644 index 0000000..d043320 --- /dev/null +++ b/e2e/pages/typography-menu.ts @@ -0,0 +1,73 @@ +import type { + Locator, + Page, +} from '@playwright/test'; + +/** + * Typography settings menu — desktop layout exposes inline ComboControls with + * increase/decrease buttons. The current value is encoded in the trigger + * button's aria-label as `${controlLabel}: ${value}` (e.g. "Size: 24"). + */ +export type TypographyControl = 'size' | 'weight' | 'leading' | 'tracking'; + +const LABELS: Record = { + size: { + increase: 'Increase Font Size', + decrease: 'Decrease Font Size', + trigger: 'Size', + }, + weight: { + increase: 'Increase Font Weight', + decrease: 'Decrease Font Weight', + trigger: 'Weight', + }, + leading: { + increase: 'Increase Line Height', + decrease: 'Decrease Line Height', + trigger: 'Leading', + }, + tracking: { + increase: 'Increase Letter Spacing', + decrease: 'Decrease Letter Spacing', + trigger: 'Tracking', + }, +}; + +export class TypographyMenu { + constructor(private readonly page: Page) {} + + increase(control: TypographyControl): Locator { + return this.page.getByRole('button', { name: LABELS[control].increase }); + } + + decrease(control: TypographyControl): Locator { + return this.page.getByRole('button', { name: LABELS[control].decrease }); + } + + /** + * Trigger button whose aria-label encodes the current value, e.g. "Size: 24". + */ + trigger(control: TypographyControl): Locator { + return this.page.getByRole('button', { name: new RegExp(`^${LABELS[control].trigger}:\\s`) }); + } + + /** + * Parse the numeric value out of the trigger button's aria-label. + * Returns null if the label can't be read yet. + */ + async readValue(control: TypographyControl): Promise { + const label = await this.trigger(control).getAttribute('aria-label'); + if (!label) { + return null; + } + const match = label.match(/:\s*(-?\d+(?:\.\d+)?)/); + return match ? Number(match[1]) : null; + } + + async bump(control: TypographyControl, direction: 'up' | 'down', times = 1) { + const button = direction === 'up' ? this.increase(control) : this.decrease(control); + for (let i = 0; i < times; i++) { + await button.click(); + } + } +} diff --git a/e2e/persistence.test.ts b/e2e/persistence.test.ts new file mode 100644 index 0000000..8496f6c --- /dev/null +++ b/e2e/persistence.test.ts @@ -0,0 +1,41 @@ +import { + expect, + test, +} from './fixtures'; + +test.describe('persistence', () => { + test('restores selected fonts after reload', async ({ comparison, page }) => { + await comparison.pickPair('Inter', 'Roboto'); + + // Confirm the store has flushed before reloading — otherwise we race + // the debounce and may reload with empty storage. + await expect.poll(async () => { + const storage = await comparison.readStorage(); + return storage['glyphdiff:comparison']; + }).toMatch(/roboto/i); + + await page.reload(); + await comparison.searchInput.waitFor({ state: 'visible' }); + + await expect(comparison.primaryFont).toContainText('Inter'); + await expect(comparison.secondaryFont).toContainText('Roboto'); + }); + + test('restores typography settings after reload', async ({ comparison, typography, page }) => { + const baseline = await typography.readValue('size'); + await typography.bump('size', 'up', 2); + + const bumped = await typography.readValue('size'); + expect(bumped).not.toBe(baseline); + + await expect.poll(async () => { + const storage = await comparison.readStorage(); + return storage['glyphdiff:comparison:typography']; + }).not.toBeNull(); + + await page.reload(); + await comparison.searchInput.waitFor({ state: 'visible' }); + + expect(await typography.readValue('size')).toBe(bumped); + }); +}); diff --git a/e2e/preview-text.test.ts b/e2e/preview-text.test.ts new file mode 100644 index 0000000..f138363 --- /dev/null +++ b/e2e/preview-text.test.ts @@ -0,0 +1,21 @@ +import { + expect, + test, +} from './fixtures'; + +test.describe('preview text', () => { + test('drives the slider character rendering', async ({ comparison }) => { + await comparison.pickPair('Inter', 'Roboto'); + await comparison.setPreviewText('Sphinx'); + + // Each grapheme renders as a `.char-wrap` cell in the slider once + // both fonts are loaded. Six glyphs → six cells. + await expect(comparison.slider.locator('.char-wrap')).toHaveCount(6); + }); + + test('preserves the typed value in the input', async ({ comparison }) => { + const text = 'Sphinx of black quartz'; + await comparison.setPreviewText(text); + await expect(comparison.previewInput).toHaveValue(text); + }); +}); diff --git a/e2e/slider.test.ts b/e2e/slider.test.ts new file mode 100644 index 0000000..af8cd81 --- /dev/null +++ b/e2e/slider.test.ts @@ -0,0 +1,46 @@ +import { + expect, + test, +} from './fixtures'; + +/** + * Slider position is spring-animated; aria-valuenow reflects the current + * value, not the target. All assertions use `toHaveAttribute` so Playwright + * polls until the spring settles. + */ +test.describe('comparison slider', () => { + test.beforeEach(async ({ comparison }) => { + await comparison.pickPair('Inter', 'Roboto'); + await comparison.slider.focus(); + }); + + test('keyboard navigation snaps to End and Home', async ({ comparison }) => { + await comparison.slider.press('End'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '100'); + + await comparison.slider.press('Home'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0'); + }); + + test('arrow keys nudge by one, Shift+Arrow by ten', async ({ comparison }) => { + await comparison.slider.press('Home'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0'); + + await comparison.slider.press('ArrowRight'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '1'); + + await comparison.slider.press('Shift+ArrowRight'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '11'); + }); + + test('PageUp / PageDown move by ten', async ({ comparison }) => { + await comparison.slider.press('Home'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0'); + + await comparison.slider.press('PageUp'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '10'); + + await comparison.slider.press('PageDown'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0'); + }); +}); diff --git a/e2e/typography.test.ts b/e2e/typography.test.ts new file mode 100644 index 0000000..3e0cccc --- /dev/null +++ b/e2e/typography.test.ts @@ -0,0 +1,44 @@ +import { + expect, + test, +} from './fixtures'; +import type { TypographyControl } from './pages/typography-menu'; + +/** + * Each control's trigger button advertises its current value via aria-label + * ("Size: 24"). We bump in one direction, then back, and assert the value + * tracks symmetrically. + */ +const controls: TypographyControl[] = ['size', 'weight', 'leading', 'tracking']; + +test.describe('typography settings', () => { + for (const control of controls) { + test(`${control}: increase then decrease returns to baseline`, async ({ typography }) => { + const baseline = await typography.readValue(control); + expect(baseline).not.toBeNull(); + + await typography.bump(control, 'up'); + const bumped = await typography.readValue(control); + expect(bumped).not.toBe(baseline); + expect(bumped! > baseline!).toBe(true); + + await typography.bump(control, 'down'); + const restored = await typography.readValue(control); + expect(restored).toBe(baseline); + }); + } + + test('font size step is reflected in the persisted typography state', async ({ comparison, typography }) => { + await typography.bump('size', 'up'); + const expected = await typography.readValue('size'); + + await expect.poll(async () => { + const storage = await comparison.readStorage(); + const raw = storage['glyphdiff:comparison:typography']; + if (!raw) { + return null; + } + return JSON.parse(raw).fontSize ?? null; + }).toBe(expected); + }); +});