Compare commits

...

3 Commits

Author SHA1 Message Date
Ilia Mashkov 24f084ae77 test: add e2e suite for core comparison flows
Workflow / build (pull_request) Failing after 3m47s
Workflow / e2e (pull_request) Has been skipped
Workflow / publish (pull_request) Has been skipped
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
2026-05-28 15:52:40 +03:00
Ilia Mashkov b9e21a66d3 fix(comparisonStore): preserve stored selection on cold load
The seed-defaults effect fired whenever fontA/fontB were still
undefined, including the window between constructor reading storage
and the per-id batch resolving. On a slow batch or fast catalog the
effect clobbered storage with catalog[0]/catalog[N-1], losing the
user's pick on reload.

Now bails when storage already holds IDs, and reads storage via
untrack so per-font selection writes don't re-trigger the effect.

Adds a deterministic regression test that controls catalog/batch
ordering via mockImplementation timing.
2026-05-28 14:58:18 +03:00
Ilia Mashkov 7a9422b574 test: add aria attributes to tested components 2026-05-28 14:05:14 +03:00
13 changed files with 509 additions and 14 deletions
+39
View File
@@ -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');
});
});
+35
View File
@@ -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<Fixtures>({
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';
+22
View File
@@ -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);
});
});
+108
View File
@@ -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<number> {
const value = await this.slider.getAttribute('aria-valuenow');
return Number(value);
}
/**
* Snapshot the glyphdiff:* localStorage entries.
*/
async readStorage(): Promise<Record<string, string | null>> {
return await this.page.evaluate(() => {
const out: Record<string, string | null> = {};
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<boolean> {
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);
}
}
+73
View File
@@ -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<TypographyControl, { increase: string; decrease: string; trigger: string }> = {
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<number | null> {
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();
}
}
}
+41
View File
@@ -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);
});
});
+21
View File
@@ -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);
});
});
+46
View File
@@ -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');
});
});
+44
View File
@@ -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);
});
});
@@ -102,7 +102,7 @@ export class ComparisonStore {
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
$effect.root(() => {
// Effect 1: Sync batch results → fontA / fontB
// Sync batch results → fontA / fontB
$effect(() => {
const fonts = this.#fontsByIdsStore.fonts;
if (fonts.length === 0) {
@@ -124,7 +124,7 @@ export class ComparisonStore {
}
});
// Effect 2: Trigger font loading whenever selection or weight changes
// Trigger font loading whenever selection or weight changes
$effect(() => {
const fa = this.#fontA;
const fb = this.#fontB;
@@ -154,24 +154,38 @@ export class ComparisonStore {
}
});
// Effect 3: Set default fonts when storage is empty
// Set default fonts when storage is empty
$effect(() => {
if (this.#fontA && this.#fontB) {
return;
}
const fonts = fontCatalogStore.fonts;
if (fonts.length >= 2) {
untrack(() => {
const id1 = fonts[0].id;
const id2 = fonts[fonts.length - 1].id;
storage.value = { fontAId: id1, fontBId: id2 };
this.#fontsByIdsStore.setIds([id1, id2]);
});
// Don't clobber a pending rehydration - only seed when storage is empty.
// Untracked: only the catalog load should drive this effect, not the
// user's storage writes that happen as a result of normal selection.
const hasStoredSelection = untrack(() => {
return storage.value.fontAId !== null || storage.value.fontBId !== null;
});
if (hasStoredSelection) {
return;
}
const fonts = fontCatalogStore.fonts;
if (fonts.length < 2) {
return;
}
untrack(() => {
const id1 = fonts[0].id;
const id2 = fonts[fonts.length - 1].id;
storage.value = { fontAId: id1, fontBId: id2 };
this.#fontsByIdsStore.setIds([id1, id2]);
});
});
// Effect 4: Pin fontA/fontB so eviction never removes on-screen fonts
// Pin fontA/fontB so eviction never removes on-screen fonts
$effect(() => {
const fa = this.#fontA;
const fb = this.#fontB;
@@ -165,6 +165,46 @@ describe('ComparisonStore', () => {
expect(mockStorage._value.fontBId).toBe(mockFontB.id);
});
});
/**
* Regression: when storage already holds the user's selection, the
* seed-defaults effect must bail out — even if it fires before the
* per-id batch returns (catalog wins the race on slow networks or
* cold reloads). Pre-fix the effect only checked fontA/fontB, both
* still undefined at this point, and clobbered storage with whatever
* the catalog had as fonts[0] / fonts[N-1].
*/
it('should not overwrite stored IDs when batch is still in flight', async () => {
const seededA = UNIFIED_FONTS.lato;
const seededB = UNIFIED_FONTS.montserrat;
mockStorage._value.fontAId = seededA.id;
mockStorage._value.fontBId = seededB.id;
// Catalog defaults differ from the stored selection — if the
// effect mis-seeds, storage will flip to roboto / open-sans.
(fontCatalogStore as any).fonts = [mockFontA, mockFontB];
// Delay the batch so the catalog-driven effect runs first.
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockImplementation(
() => new Promise(r => setTimeout(() => r([seededA, seededB]), 50)),
);
const store = new ComparisonStore();
// Let the catalog effect run; storage must be untouched.
await new Promise(r => setTimeout(r, 10));
expect(mockStorage._value.fontAId).toBe(seededA.id);
expect(mockStorage._value.fontBId).toBe(seededB.id);
// Batch resolves with the seeded selection — fontA/B must match.
await vi.waitFor(() => {
expect(store.fontA?.id).toBe(seededA.id);
expect(store.fontB?.id).toBe(seededB.id);
}, { timeout: 2000 });
expect(mockStorage._value.fontAId).toBe(seededA.id);
expect(mockStorage._value.fontBId).toBe(seededB.id);
});
});
describe('Aggregate Loading State', () => {
@@ -93,7 +93,11 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');
<!-- Font names + slider % + theme toggle -->
<div class="flex items-center gap-3 md:gap-8 shrink-0 select-none">
<div class="hidden lg:flex items-center gap-6">
<div class="flex flex-col items-end leading-tight gap-0.5">
<div
id="primary-font"
aria-label="Primary font"
class="flex flex-col items-end leading-tight gap-0.5"
>
<TechText class="uppercase" variant="default" size="sm">
{fontAName}
</TechText>
@@ -106,7 +110,11 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');
class="h-8 rotate-12"
/>
<div class="flex flex-col items-start leading-tight gap-0.5">
<div
id="secondary-font"
aria-label="Secondary font"
class="flex flex-col items-start leading-tight gap-0.5"
>
<TechText class="uppercase" variant="default" size="sm">
{fontBName}
</TechText>
@@ -72,6 +72,8 @@ let {
<ToggleButton
size="sm"
active={comparisonStore.side === 'A'}
aria-controls="primary-font"
aria-pressed={comparisonStore.side === 'A'}
onclick={() => comparisonStore.side = 'A'}
class="flex-1 tracking-wide font-bold uppercase"
>
@@ -82,6 +84,8 @@ let {
size="sm"
class="flex-1 tracking-wide font-bold uppercase"
active={comparisonStore.side === 'B'}
aria-controls="secondary-font"
aria-pressed={comparisonStore.side === 'B'}
onclick={() => comparisonStore.side = 'B'}
>
<span>Right Font</span>