Merge pull request 'Feature/playwright' (#43) from feature/playwright into main
Reviewed-on: #43
This commit was merged in pull request #43.
This commit is contained in:
@@ -50,8 +50,34 @@ jobs:
|
|||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
run: yarn test:component --reporter=verbose --logHeapUsage
|
run: yarn test:component --reporter=verbose --logHeapUsage
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: mcr.microsoft.com/playwright:v1.59.0-jammy
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare yarn@stable --activate
|
||||||
|
- name: Persistent Yarn Cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .yarn/cache
|
||||||
|
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-yarn-
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --immutable
|
||||||
|
- name: Build Svelte SPA
|
||||||
|
run: yarn build
|
||||||
|
- name: E2E Tests
|
||||||
|
timeout-minutes: 15
|
||||||
|
run: yarn test:e2e
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: build # Only runs if tests/lint pass
|
# Runs if lint, unit-, component-, e2e-tests pass
|
||||||
|
needs: [build, e2e]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.ref == 'refs/heads/main' # Only deploy from main branch
|
if: github.ref == 'refs/heads/main' # Only deploy from main branch
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -50,3 +50,6 @@ storybook-static
|
|||||||
# Tests
|
# Tests
|
||||||
coverage/
|
coverage/
|
||||||
.aider*
|
.aider*
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
.playwright/
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared base for all page objects. Subclasses extend this and expose
|
||||||
|
* domain-specific locators + actions — never raw selectors leaking into tests.
|
||||||
|
*/
|
||||||
|
export abstract class BasePage {
|
||||||
|
protected constructor(protected readonly page: Page) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a path relative to baseURL.
|
||||||
|
*/
|
||||||
|
async goto(path = '/') {
|
||||||
|
await this.page.goto(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
} from '@playwright/test';
|
||||||
|
import { ComparisonPage } from './pages/comparison-page';
|
||||||
|
|
||||||
|
test.describe('smoke', () => {
|
||||||
|
test('loads the comparison view with its primary controls', async ({ page }) => {
|
||||||
|
const view = new ComparisonPage(page);
|
||||||
|
await view.open();
|
||||||
|
|
||||||
|
await expect(view.searchInput).toBeVisible();
|
||||||
|
await expect(view.previewInput).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a search query', async ({ page }) => {
|
||||||
|
const view = new ComparisonPage(page);
|
||||||
|
await view.open();
|
||||||
|
await view.searchFor('Inter');
|
||||||
|
|
||||||
|
await expect(view.searchInput).toHaveValue('Inter');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+47
-3
@@ -1,10 +1,54 @@
|
|||||||
import { defineConfig } from '@playwright/test';
|
import {
|
||||||
|
defineConfig,
|
||||||
|
devices,
|
||||||
|
} from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E config. Tests run against the production build via `vite preview` on port 4173.
|
||||||
|
* Locally: all three browser engines run in parallel.
|
||||||
|
* CI: chromium only, workers=1 — the runner has 6GB RAM and `yarn build` already
|
||||||
|
* spikes 1–2GB, so we keep the E2E peak bounded.
|
||||||
|
*/
|
||||||
|
const isCI = !!process.env.CI;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
testDir: 'e2e',
|
||||||
|
testMatch: /.*\.test\.ts$/,
|
||||||
|
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: isCI,
|
||||||
|
retries: isCI ? 2 : 0,
|
||||||
|
workers: isCI ? 1 : undefined,
|
||||||
|
|
||||||
|
reporter: isCI
|
||||||
|
? [['html', { open: 'never' }], ['github']]
|
||||||
|
: [['html', { open: 'on-failure' }], ['list']],
|
||||||
|
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:4173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: isCI
|
||||||
|
? [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'yarn build && yarn preview',
|
command: 'yarn build && yarn preview',
|
||||||
port: 4173,
|
port: 4173,
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: !isCI,
|
||||||
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
testDir: 'e2e',
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export class ComparisonStore {
|
|||||||
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
||||||
|
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
// Effect 1: Sync batch results → fontA / fontB
|
// Sync batch results → fontA / fontB
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const fonts = this.#fontsByIdsStore.fonts;
|
const fonts = this.#fontsByIdsStore.fonts;
|
||||||
if (fonts.length === 0) {
|
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(() => {
|
$effect(() => {
|
||||||
const fa = this.#fontA;
|
const fa = this.#fontA;
|
||||||
const fb = this.#fontB;
|
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(() => {
|
$effect(() => {
|
||||||
if (this.#fontA && this.#fontB) {
|
if (this.#fontA && this.#fontB) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fonts = fontCatalogStore.fonts;
|
// Don't clobber a pending rehydration - only seed when storage is empty.
|
||||||
if (fonts.length >= 2) {
|
// Untracked: only the catalog load should drive this effect, not the
|
||||||
untrack(() => {
|
// user's storage writes that happen as a result of normal selection.
|
||||||
const id1 = fonts[0].id;
|
const hasStoredSelection = untrack(() => {
|
||||||
const id2 = fonts[fonts.length - 1].id;
|
return storage.value.fontAId !== null || storage.value.fontBId !== null;
|
||||||
storage.value = { fontAId: id1, fontBId: id2 };
|
});
|
||||||
this.#fontsByIdsStore.setIds([id1, id2]);
|
|
||||||
});
|
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(() => {
|
$effect(() => {
|
||||||
const fa = this.#fontA;
|
const fa = this.#fontA;
|
||||||
const fb = this.#fontB;
|
const fb = this.#fontB;
|
||||||
|
|||||||
@@ -165,6 +165,46 @@ describe('ComparisonStore', () => {
|
|||||||
expect(mockStorage._value.fontBId).toBe(mockFontB.id);
|
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', () => {
|
describe('Aggregate Loading State', () => {
|
||||||
|
|||||||
@@ -93,7 +93,11 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');
|
|||||||
<!-- Font names + slider % + theme toggle -->
|
<!-- Font names + slider % + theme toggle -->
|
||||||
<div class="flex items-center gap-3 md:gap-8 shrink-0 select-none">
|
<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="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">
|
<TechText class="uppercase" variant="default" size="sm">
|
||||||
{fontAName}
|
{fontAName}
|
||||||
</TechText>
|
</TechText>
|
||||||
@@ -106,7 +110,11 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');
|
|||||||
class="h-8 rotate-12"
|
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">
|
<TechText class="uppercase" variant="default" size="sm">
|
||||||
{fontBName}
|
{fontBName}
|
||||||
</TechText>
|
</TechText>
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ let {
|
|||||||
<ToggleButton
|
<ToggleButton
|
||||||
size="sm"
|
size="sm"
|
||||||
active={comparisonStore.side === 'A'}
|
active={comparisonStore.side === 'A'}
|
||||||
|
aria-controls="primary-font"
|
||||||
|
aria-pressed={comparisonStore.side === 'A'}
|
||||||
onclick={() => comparisonStore.side = 'A'}
|
onclick={() => comparisonStore.side = 'A'}
|
||||||
class="flex-1 tracking-wide font-bold uppercase"
|
class="flex-1 tracking-wide font-bold uppercase"
|
||||||
>
|
>
|
||||||
@@ -82,6 +84,8 @@ let {
|
|||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 tracking-wide font-bold uppercase"
|
class="flex-1 tracking-wide font-bold uppercase"
|
||||||
active={comparisonStore.side === 'B'}
|
active={comparisonStore.side === 'B'}
|
||||||
|
aria-controls="secondary-font"
|
||||||
|
aria-pressed={comparisonStore.side === 'B'}
|
||||||
onclick={() => comparisonStore.side = 'B'}
|
onclick={() => comparisonStore.side = 'B'}
|
||||||
>
|
>
|
||||||
<span>Right Font</span>
|
<span>Right Font</span>
|
||||||
|
|||||||
+2
-1
@@ -40,7 +40,8 @@
|
|||||||
"src/**/*.d.ts",
|
"src/**/*.d.ts",
|
||||||
"vitest.config*.ts",
|
"vitest.config*.ts",
|
||||||
"vitest.setup*.ts",
|
"vitest.setup*.ts",
|
||||||
"vitest.types.d.ts"
|
"vitest.types.d.ts",
|
||||||
|
"playwright.config*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
|||||||
Reference in New Issue
Block a user