textmachine/frontend/scripts/scenes.mjs

1272 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Scenarios of INTERACTION: click → frame → check of the state. Half of pack S3.5 a still frame
// does not accept — tabs, the drag handle, modals and spoilers exist only in motion.
//
// node scripts/scenes.mjs every scenario, frames into .shots/scenes/
// node scripts/scenes.mjs tabs drag only the named ones
//
// A failing scenario fails the command: this is acceptance, not a demonstration.
import { mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const shotsDir = resolve(root, '.shots/scenes');
const toolingRoot = resolve(root, '.tooling/root');
if (existsSync(toolingRoot)) {
const libs = resolve(toolingRoot, 'usr/lib/x86_64-linux-gnu');
process.env.LD_LIBRARY_PATH = [libs, process.env.LD_LIBRARY_PATH].filter(Boolean).join(':');
process.env.XDG_DATA_HOME = resolve(toolingRoot, 'usr/share');
}
const { chromium } = await import('playwright');
const { default: AxeBuilder } = await import('@axe-core/playwright');
const { build, preview } = await import('vite');
// The words a scene clicks are the product's own: interface wording comes from the message
// catalogue, chapter headings and bank terms from the fixtures the fake server answers with. A copy
// pasted in here goes stale in silence, and a scene looking for a word nobody prints any more finds
// nothing.
//
// ⚠ The specifier is BUILT rather than written out: Node resolves a module only with its `.ts`
// extension, while `tsc` rejects that extension until `allowImportingTsExtensions` is on — and
// tsconfig.json is outside this zone. The JSDoc types below go through the extensionless path,
// which only TypeScript reads, so the keys and the shape of the fixtures are still checked.
const load = (/** @type {string} */ path) => import(new URL(path, import.meta.url).href);
/** @typedef {import('../src/api/schema').components['schemas']} Schemas */
/** @typedef {import('../src/i18n/ru').MessageKey} MessageKey */
/** @type {Record<MessageKey, string>} */
const ru = (await load('../src/i18n/ru.ts')).ru;
/** @type {{ book: Schemas['Book'], chapters: Schemas['Chapter'][], notes: Schemas['Note'][] }} */
const { book, chapters, notes } = await load('../src/mock/book.ts');
/** @type {{ terms: Schemas['BankTerm'][], bankTerms: Schemas['BankTerm'][] }} */
const { terms, bankTerms } = await load('../src/mock/bank.ts');
/** @type {{ scaleTerms: Schemas['BankTerm'][] }} */
const { scaleTerms } = await load('../src/mock/scale.ts');
/** @typedef {import('playwright').Page} Page */
/** @typedef {import('playwright').Locator} Locator */
/** @typedef {(name: string) => Promise<unknown>} Shot */
/**
* Interface wording by catalogue key — a scene looks for exactly what the screen prints. An unknown
* key throws: a locator built out of `undefined` matches ANY node of its role, and the scene would
* go green having checked nothing.
* @param {MessageKey} key @param {Record<string, string | number>} [variables]
*/
function say(key, variables = {}) {
const line = ru[key];
if (line === undefined) throw new Error(`no such key in the message catalogue: ${key}`);
return line.replace(/\{(\w+)\}/g, (whole, name) => String(variables[name] ?? whole));
}
/**
* The heading a chapter carries in the fixture: the tree prints it, the scene clicks it. By id and
* not by position — the fixture deliberately keeps a chapter with no heading at all and one with no
* number, and both would shift under an index.
* @param {string} id
*/
function heading(id) {
const found = chapters.find((row) => row.id === id)?.heading;
if (found === null || found === undefined || found === '') {
throw new Error(`fixture chapter ${id} carries no heading`);
}
return found;
}
/**
* The translated side of a bank term in the fixture. Terms are looked up by id rather than repeated
* as a word: the fixture owns the wording, and a pasted copy goes stale in silence.
* @param {Schemas['BankTerm'][]} bank @param {string} id
*/
function dstOf(bank, id) {
const dst = bank.find((row) => row.id === id)?.dst;
if (dst === undefined || dst === '') throw new Error(`fixture term ${id} carries no translation`);
return dst;
}
/** @param {boolean} condition @param {string} what */
function check(condition, what) {
if (!condition) throw new Error(`scenario failed: ${what}`);
console.log(`${what}`);
}
/**
* An axe run over the OPEN state. `npm run shot` checks routes only, that is, a screen that opens on
* a click (the bank table, the modals) never saw the accessibility gate at all.
* The policy is the same as in shot.mjs: contrast goes into the report, the rest fails the scenario.
* @param {Page} page @param {string} what
*/
async function audit(page, what) {
// Let the animations play out. Bought by the acceptance run of 10.08: axe, started in the middle
// of the 220ms appearance of the card, was measuring SEMI-TRANSPARENT text and printing contrast
// nodes that are not there on the settled frame.
await settle(page);
const axe = await new AxeBuilder({ page }).analyze();
const blocking = axe.violations.filter((v) => v.id !== 'color-contrast');
for (const v of axe.violations.filter((v) => v.id === 'color-contrast')) {
console.log(` contrast (accepted as is): ${v.nodes.length} node(s)`);
}
if (blocking.length > 0) {
for (const v of blocking) {
console.error(`${v.id}: ${v.help}${v.nodes.length} node(s)`);
// The node is printed right away: without it a "single violation" has to be hunted by hand.
for (const node of v.nodes)
console.error(` ${node.target.join(' ')}${node.html.slice(0, 160)}`);
}
throw new Error(`Accessibility: ${blocking.length} violation(s) — ${what}`);
}
console.log(` ✓ accessibility gate: ${what}`);
}
/** @type {Record<string, (page: Page, shot: Shot) => Promise<void>>} */
const scenes = {
// Remark 2 plus the prompt's acceptance: five single clicks — one preview tab.
/** @param {Page} page @param {Shot} shot */
async tabs(page, shot) {
await open(page, '/showcase');
const tabs = page.locator('[role="tab"]');
// Tree rows are looked for INSIDE the left panel: the same chapter names stand in the summary
// of notes on the right as well, and with no area to search in the locator points at two things
// at once.
/** @param {string} name */
const chapter = (name) => page.locator('nav').getByText(name, { exact: true });
for (const name of [
heading('ch_1'),
heading('ch_3'),
heading('ch_5'),
heading('ch_7'),
heading('ch_6'),
]) {
await chapter(name).click();
}
await shot('tabs-preview');
check(
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
'after five single clicks exactly one preview tab',
);
const before = await tabs.count();
await chapter(heading('ch_7')).dblclick();
check(
(await page.locator('[role="tab"][data-preview="true"]').count()) === 0,
'a double click on a chapter pins the tab — no preview is left',
);
await chapter(heading('ch_8')).click();
await shot('tabs-pinned');
check(
(await tabs.count()) === before + 1 &&
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
'the next single click opens a NEW preview tab beside the pinned one',
);
// A double click on the tab itself — the second way of pinning, from the VS Code model.
await page.locator('[role="tab"][data-preview="true"]').dblclick();
check(
(await page.locator('[role="tab"][data-preview="true"]').count()) === 0,
'a double click on the preview tab pins it',
);
// ⚠ A double click PAST a chapter row must pin nothing. The previous implementation took the
// "current selection", and neither the book nor empty space moves the selection — so somebody
// else's tab got pinned (found by an adversarial review with a measurement, not by reasoning).
await chapter(heading('ch_10')).click();
const pinnedBefore = await tabs.count();
await page.locator('nav').getByText(book.title, { exact: true }).dblclick();
check(
(await tabs.count()) === pinnedBefore &&
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
'a double click on the row of the BOOK does not touch the tabs',
);
// The keyboard twin of the double click: Enter on the selected row pins it.
await page.keyboard.press('Enter');
check(
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
'Enter on the row of the book does not touch the tabs either',
);
await chapter(heading('ch_10')).click();
await page.keyboard.press('Enter');
check(
(await page.locator('[role="tab"][data-preview="true"]').count()) === 0,
'Enter on the selected chapter row pins the tab',
);
// ⚠ Opening a chapter must not flash a card. Watch OPACITY, not the DOM: the card is held back
// until the wait is worth explaining. The press is held 80ms, as a hand holds it — that is the
// window the chapter is read in, before the click completes.
await page.evaluate(() => {
/** @type {number[]} */
const seen = [];
Object.assign(window, { __cards: seen });
const tick = () => {
const card = document.querySelector('main h2')?.closest('div');
if (card && Number(getComputedStyle(card).opacity) > 0.02) seen.push(1);
if (seen.length < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
const row = await chapter(heading('ch_9')).boundingBox();
if (!row) throw new Error('chapter row not found');
await page.mouse.move(row.x + row.width / 2, row.y + row.height / 2);
await page.mouse.down();
await page.waitForTimeout(80);
await page.mouse.up();
await page.waitForTimeout(900);
check(
(await page.evaluate(() => Reflect.get(window, '__cards'))).length === 0,
'opening a chapter does not flash a waiting card before the text',
);
// Second-round remark 4: a short title was clipped before its longer neighbour, because the tabs
// shared the shortage of space in proportion to their width. The row scrolls now, and clipping
// happens ONLY to a tab that has run into its own limit.
const clipped = await page.locator('[role="tab"]').evaluateAll((nodes) =>
nodes
.map((node) => {
const title = node.querySelector('span');
// ⚠ Without this the guard would go green in silence: let the title stop being a `span`,
// and `title` becomes null, `cut` becomes false, and "none is clipped" a truth about
// nothing.
if (!title) throw new Error('the tab has no span title — the clipping guard went blind');
const max = parseFloat(getComputedStyle(node).maxWidth);
return {
text: title.textContent ?? '',
cut: title.scrollWidth > title.clientWidth + 1,
atMax: node.getBoundingClientRect().width >= max - 1,
};
})
.filter((tab) => tab.cut && !tab.atMax),
);
check(
clipped.length === 0,
`no tab is clipped without having run into its width limit (clipped: ${clipped.length})`,
);
// Acceptance finding 8.2: the selected row has a blue ground and the state is PERMANENT (an open
// chapter is selected always), while its content is drawn in quiet greys. axe does not catch
// this — the contrast of non-text it does not check at all, and the row with the counter it
// never saw.
// The row picked is the one that has EVERYTHING: the notes counter, the icon and the progress
// track — otherwise the guard would silently leave half of the pairs unchecked.
await chapter(heading('ch_2')).click();
const faint = await contrastInSelectedRow(page);
check(
faint.length === 0,
`everything on the selected row is readable: ${faint.map((part) => `${part.what} ${part.ratio}:1 < ${part.floor}`).join(' · ') || 'not a single pair below the floor'}`,
);
},
// Ф-19: switching a tab no longer unmounts the panel, the scroll lives on.
/** @param {Page} page @param {Shot} shot */
async scroll(page, shot) {
await open(page, '/scale');
const bank = page.locator(`[role="grid"][aria-label="${say('bank.tableLabel')}"]`);
await bank.evaluate((node) => node.scrollTo(0, 9000));
const before = await bank.evaluate((node) => node.scrollTop);
check(before > 8000, `the bank scroll came to rest at ${String(before)}px`);
await page.getByRole('tab', { name: say('about.tab') }).click();
await page.getByRole('tab', { name: say('bank.tab') }).click();
await shot('scroll-kept');
const after = await bank.evaluate((node) => node.scrollTop);
check(
after === before,
`after leaving for the neighbouring tab and coming back the offset is the same (${String(after)})`,
);
},
// Remark 18: the resize strip does not stick after the drag is released.
/** @param {Page} page @param {Shot} shot */
async drag(page, shot) {
await open(page, '/showcase');
const separator = page.locator('[role="separator"]').first();
const box = await separator.boundingBox();
if (!box) throw new Error('separator not found');
const y = box.y + box.height / 2;
await page.mouse.move(box.x + box.width / 2, y);
await page.mouse.down();
await page.mouse.move(box.x + 90, y, { steps: 12 });
await shot('drag-active');
const dragging = await handleStyle(separator);
// The numbers come from vojo (style.css.ts): 0.55 in the drag and the pill growing to 48px.
check(
dragging.opacity === '0.55',
`in the drag the pill shows through at ${dragging.opacity}, as in vojo`,
);
check(
dragging.height === '48px',
`in the drag the pill grows to ${dragging.height}, as in vojo`,
);
await page.mouse.up();
await page.mouse.move(box.x + 400, y + 200);
await shot('drag-released');
const released = await handleStyle(separator);
check(
released.opacity === '0',
`after the release the pill went out (opacity ${released.opacity}), the strip did not stick`,
);
check(
(await page.locator('[role="separator"]').first().getAttribute('data-separator')) === 'focus',
'the library HOLDS data-separator="focus" meanwhile — sticking is cured by style, not by luck',
);
},
// Remarks 7, 8, 13, 20: the context panel — the summary of notes, the bank with spoilers, an
// honest state instead of a stub button.
/** @param {Page} page @param {Shot} shot */
async context(page, shot) {
await open(page, '/showcase');
const panel = page.locator('aside');
await panel.getByRole('tab', { name: say('notes.tab') }).click();
await shot('context-notes');
// Looked for in ITS OWN list: with keep-alive the neighbouring tab is in the DOM too, and
// `[role="option"]` over the whole panel counts the rows of the bank along with it.
const summary = panel.locator(
`[role="listbox"][aria-label="${say('notes.listLabel')}"] [role="option"]`,
);
// Exactly as many as there are notes in the book's fixture. The check is not cosmetic: a row key
// built out of the chapter collapsed three notes of one chapter into a single row.
check(
(await summary.count()) === notes.length,
'the summary shows ALL the notes of the book, several in one chapter included',
);
await summary.first().click();
check(
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
'a row of the summary opens its chapter as a preview tab',
);
},
// Second-round remark 7: the bank is a TABLE in the right panel (not a tab in the centre), with
// columns, a search, a filter by kind and a spoiler on the description, not on the translation.
/** @param {Page} page @param {Shot} shot */
async bank(page, shot) {
await open(page, '/showcase');
const panel = page.locator('aside');
await panel.getByRole('tab', { name: say('bank.tab') }).click();
await shot('bank-table');
const table = panel.getByRole('grid', { name: say('bank.tableLabel') });
check(await table.isVisible(), 'the bank lives in the right panel and is shown as a table');
check(
(await page.getByRole('grid', { name: say('bank.tableLabel') }).count()) === 1,
'there is exactly one bank table in the application — no duplicate in the centre',
);
// The WHOLE set is asserted at once: a check for "no «state» column" would pass on a «signature»
// column just as well, that is, it would guard a word and not the make-up of the screen.
// Compared in lower case: the headings are set in small caps by CSS means, and `innerText`
// returns them already upper-cased — what has to be checked is the SET of columns, not the
// typesetting.
const columns = (await table.getByRole('columnheader').allInnerTexts()).map((name) =>
name.toLowerCase(),
);
const expected = [
say('bank.columnTerm'),
say('bank.columnTranslation'),
say('bank.columnKind'),
say('bank.columnSense'),
].map((name) => name.toLowerCase());
check(
JSON.stringify(columns) === JSON.stringify(expected),
`the columns of the table are exactly those: ${columns.join(' · ')} — no signing state among them`,
);
// Owner's report, 10.08: the header's ground sat on a rowgroup of ZERO height and so painted
// nothing — scrolled rows read straight through the column titles. Ask what is drawn there.
await table.evaluate((node) => {
node.scrollTop = 400;
});
// What catches the defect is the GROUND'S OPACITY: `elementsFromPoint` follows paint order, not
// transparency (checked against the counterfactual). The hit test is only a gate — while a
// scroll runs the virtualizer takes the pointer off the content and the grid itself answers.
const header = await page
.waitForFunction(
() => {
const node = document.querySelector('[role="grid"]');
const th = node?.querySelector('[role="columnheader"]');
if (!th) return null;
const box = th.getBoundingClientRect();
const [top] = document.elementsFromPoint(
box.left + box.width / 2,
box.top + box.height / 2,
);
if (top === node) return null; // the pointer is still off — the scroll has not settled
const parts = getComputedStyle(th).backgroundColor.match(/[\d.]+/g) ?? [];
return { role: top?.getAttribute('role'), opacity: Number(parts[3] ?? 1) };
},
null,
{ timeout: 5000 },
)
.then((handle) => handle.jsonValue());
check(
header?.role === 'columnheader' && header.opacity === 1,
`a scrolled row does not show through the header (on top ${String(header?.role)}, ground opacity ${String(header?.opacity)})`,
);
await table.evaluate((node) => {
node.scrollTop = 0;
});
// Owner's report, 10.08: a panel squeezed to its stop and let back out left the table
// squeezed, with a gap down its right side. Columns must fill the table at ANY width.
const filled = async (/** @type {string} */ what) => {
const spread = await columnSpread(table);
// Against the CONTENT width, not the window: the latter holds arithmetically whenever there
// is room to spare, so on its own it guards almost nothing.
check(
Math.abs(spread.sum - spread.content) <= 1,
`${what}: the columns fill the table whole (${spread.sum} of ${spread.content}, window ${spread.grid})`,
);
// ⚠ And it does not run past the panel: columns MUST give way when space runs short. A narrow
// panel catches it (before the fix: content 555, window 520).
check(
spread.content <= spread.grid + 1,
`${what}: the table does not run past the edge of the panel (content ${spread.content}, window ${spread.grid})`,
);
return spread.cut;
};
/** @param {string} what @param {string[]} cut */
const fits = (what, cut) =>
check(
cut.length === 0,
`${what}: the columns sized by content show it whole (clipped: ${cut.join(' · ') || 'nothing'})`,
);
fits('at its own width', await filled('at its own width'));
// At its stop the columns are cramped, and it shows whether they give way. Clipping is legitimate
// here, running past the panel is not.
// ⚠ Each drag's offsets count from ITS OWN start: letting the panel back out needs a second drag.
await pull(page, [400]);
await filled('the panel squeezed to its stop');
await pull(page, [-200]);
fits(
'after being squeezed to the stop and let back out',
await filled('after being squeezed to the stop and let back out'),
);
// The spoiler covers the DESCRIPTION, the translation stays readable.
const spoilered = dstOf(terms, 't_13');
const row = table.getByRole('row').filter({ hasText: spoilered });
// The colour is taken apart into numbers rather than compared with a string: a literal colour in
// the code is forbidden by the gate, and the comparison has to be against the property anyway,
// not against the way the browser prints it.
const covered = await row.locator('[data-sense]').evaluate((node) => {
const style = getComputedStyle(node);
return { blur: style.filter, clips: style.overflow !== 'visible' };
});
// ⚠ The second half guards the square edge: a blur mixes in what lies BEYOND the ink, so
// anything clipping at the glyphs turns it into a rectangle.
check(
covered.blur.startsWith('blur(') && !covered.clips,
`the description is covered by a real blur and clipped by nothing (${covered.blur}, clipping ${String(covered.clips)})`,
);
check(
await row.getByText(spoilered, { exact: true }).isVisible(),
'the translation itself is visible meanwhile',
);
await row.locator('[data-sense]').hover();
await page.waitForTimeout(250); // the colour transition is 140ms — else we read mid-animation
check(
!(await isCovered(row.locator('[data-sense]'))),
'hovering the cell reveals the description',
);
// A blur covers nothing from a screen reader, so the description is taken out of the
// accessibility tree, and what reveals it is a toggle — the only way that works for the mouse,
// and for the keyboard, and for the reader.
check(
(await row.locator('[data-sense]').getAttribute('aria-hidden')) === 'true',
'while it is not revealed, the description is out of the accessibility tree',
);
await panel.getByRole('button', { name: say('bank.showSenses') }).click();
await page.waitForTimeout(300);
const revealed = await row.locator('[data-sense]').evaluate((node) => {
const parts = getComputedStyle(node).color.match(/[\d.]+/g) ?? [];
return {
hidden: node.getAttribute('aria-hidden'),
filter: getComputedStyle(node).filter,
// A revealed line does NOT fade out: a tail mask read as the end of the line going dark
// (owner's report, 10.08), and over a blur it clipped the whole thing into a rectangle.
mask: getComputedStyle(node).maskImage,
opacity: Number(parts[3] ?? 1),
pressed: document.querySelector('[aria-pressed]')?.getAttribute('aria-pressed'),
};
});
check(
revealed.hidden === null &&
revealed.opacity === 1 &&
revealed.filter === 'none' &&
revealed.mask === 'none',
`the toggle reveals the descriptions for the eyes and for the reader alike (${JSON.stringify(revealed)})`,
);
await panel.getByRole('button', { name: say('bank.hideSenses') }).click();
await page.waitForTimeout(250);
// Walking with the arrows does NOT reveal the descriptions: the spoiler would otherwise be
// opened one row per keypress, while the keyboard is served by the toggle above.
await page.mouse.move(0, 0);
// The CELL is clicked: `role="row"` itself is a container of zero height, there is nothing on it
// to click, and a person lands in a cell as well.
await table.getByRole('rowheader').first().click();
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(250);
const walked = await page.evaluate(() => {
const cell = document.activeElement?.closest('[role="row"]')?.querySelector('[data-sense]');
return cell ? getComputedStyle(cell).filter !== 'none' : null;
});
check(
walked === true,
`walking the rows from the keyboard does not open the descriptions (covered: ${walked})`,
);
// ⚠ The spoiler must hold in FORCED COLOURS too: the glyph-shadow version vanished there
// outright — the system drops `text-shadow` — and printed every description in plain text.
await page.emulateMedia({ forcedColors: 'active' });
await page.waitForTimeout(200);
const forced = await row
.locator('[data-sense]')
.evaluate((node) => ({ filter: getComputedStyle(node).filter }));
check(
forced.filter.startsWith('blur('),
`in high-contrast mode the description stays covered (${JSON.stringify(forced)})`,
);
await page.emulateMedia({ forcedColors: 'none' });
await page.waitForTimeout(200);
// The spoiler must not leak past the blur. Tooltips on the term and on the translation are
// legal — what is checked is exactly what is covered: the text of the description occurs in no
// `title` of the row, and a mouse selection does not copy it — the text of a covered description
// remains text of the page.
const titles = await row
.locator('[title]')
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('title') ?? ''));
// Against the row's ACTUAL description, not a literal from the fixture: a nailed-down word
// turns the check silently vacuous the moment the fixture changes.
const hidden = (await row.locator('[data-sense]').innerText()).trim();
check(
titles.length > 0 && hidden.length > 0 && titles.every((title) => !title.includes(hidden)),
`the tooltips of the row (${String(titles.length)}) do not print the covered description «${hidden}»`,
);
const copied = await row.locator('[data-sense]').evaluate((node) => {
const range = document.createRange();
range.selectNodeContents(node);
const selection = getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
const text = selection?.toString() ?? '';
selection?.removeAllRanges();
return text;
});
check(copied === '', `selecting a covered description copies nothing (taken: «${copied}»)`);
// A narrow panel drops columns instead of squeezing them: the kind column comes only on a wide
// one. The description is covered on ALL rows, not on the selected ones: the spoiler is a
// property of the column. Both the cursor and the FOCUS are taken away: the row they stand on
// would otherwise stay revealed.
await page.mouse.move(0, 0);
await page.getByRole('searchbox', { name: say('bank.search') }).focus();
await page.waitForTimeout(250);
const senses = await table
.locator('[role="row"] [data-sense]')
.evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).filter !== 'none'));
check(
senses.length > 3 && senses.every(Boolean),
`the description is covered in all ${String(senses.length)} rows, not in single ones`,
);
const rows = () => table.getByRole('row').count();
const all = await rows();
await page.getByRole('radio', { name: new RegExp(`^${say('kind.name')}`) }).click();
const names = await rows();
check(names < all, `the filter by kind narrows the table (${String(all)}${String(names)})`);
check(
(await table.getByRole('columnheader', { name: say('bank.columnKind') }).count()) === 0,
'on a tab of one kind the kind column goes: it would repeat the name of that tab in every row',
);
await shot('bank-kind');
// ⚠ Every frame of the return, not just where it settles: a hidden column whose width was
// dropped comes back as a plain share for one frame — the neighbour jumped 13px.
await page.evaluate(() => {
/** @type {string[]} */
const seen = [];
Object.assign(window, { __layouts: seen });
const tick = () => {
seen.push(
[...document.querySelectorAll('aside [role="columnheader"]')]
.map((head) => ({ x: head.getBoundingClientRect().left, head }))
.sort((a, b) => a.x - b.x)
.map(
({ head }) => `${head.textContent}=${Math.round(head.getBoundingClientRect().width)}`,
)
.join(' '),
);
if (seen.length < 90) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
await page.getByRole('radio', { name: new RegExp(`^${say('bank.kindAll')}`) }).click();
await page.waitForTimeout(1600);
check(
await table.getByRole('columnheader', { name: say('bank.columnKind') }).isVisible(),
'on "all" the kind column comes back — there it is the only way to see the kind of a row',
);
const layouts = await page.evaluate(() => {
const seen = /** @type {string[]} */ (Reflect.get(window, '__layouts'));
return [...new Set(seen.filter((row) => row.split(' ').length === 4))];
});
check(
layouts.length === 1,
`the return to "all" does not jerk the columns: layouts over 90 frames ${layouts.length} (${layouts.join(' | ')})`,
);
await page.getByRole('searchbox', { name: say('bank.search') }).fill(dstOf(terms, 't_9'));
// We wait for the SETTLING (the table's repaint is deferred), while the number is asserted by
// the check itself.
await page.waitForTimeout(700);
await shot('bank-search');
check((await rows()) === 2, `the search left the header and one row (${await rows()} in all)`);
check(
await panel
.getByText(say('bank.shown', { shown: 1, total: bankTerms.length }), { exact: false })
.isVisible(),
'the foot says how many rows of the bank are left after the narrowing',
);
// Nothing found: the table KEEPS its head, and the message stands right under it rather than at
// the foot of the panel (an empty collection makes the library reserve the whole height).
await page.getByRole('searchbox', { name: say('bank.search') }).fill('zzz');
await page.waitForTimeout(700);
await shot('bank-empty');
const nothing = await panel.evaluate((node) => {
const head = node.querySelector('[role="columnheader"]');
const message = node.querySelector('[role="grid"]')?.parentElement?.querySelector('p');
return {
heads: node.querySelectorAll('[role="columnheader"]').length,
rows: node.querySelectorAll('[role="grid"] [role="row"]').length,
gap: Math.round(
(message?.getBoundingClientRect().top ?? 0) - (head?.getBoundingClientRect().bottom ?? 0),
),
};
});
check(
nothing.heads === 4 && nothing.rows === 1,
`on an empty result the head and its ${String(nothing.heads)} columns remain, with no data rows`,
);
check(
nothing.gap >= 0 && nothing.gap < 40,
`the message stands right under the head, not at the foot of the panel (gap ${String(nothing.gap)}px)`,
);
await page.getByRole('searchbox', { name: say('bank.search') }).fill('');
await page.waitForTimeout(700);
await audit(page, 'the bank table');
},
// Acceptance finding 8.1, kept as a GUARD rather than as a fix: a table mounted inside a hidden
// keep-alive tab was reported to lose its measured widths. Measured here instead of believed, and
// it does not happen — a layout query inside a `content-visibility: hidden` subtree forces the
// layout it needs, so the virtualizer has its rows and `read()` has its columns (probe: 116 cells
// while hidden, widths 121·122·133·232, identical to the tab that was never left). The scene
// stays because the scenario is real and nothing else covers it.
/** @param {Page} page @param {Shot} shot */
async hidden(page, shot) {
await page.goto(`${origin}/scale`, { waitUntil: 'load' });
const panel = page.locator('aside');
// We leave the bank BEFORE it has answered: the tabs are drawn at once, the data are not.
await panel.getByRole('tab', { name: say('about.tab') }).click();
await page.waitForFunction(() => document.documentElement.dataset.screen === 'ready', null, {
timeout: 15_000,
});
await page.evaluate(() => document.fonts.ready);
await panel.getByRole('tab', { name: say('bank.tab') }).click();
await page.waitForTimeout(600);
await shot('hidden-bank');
const table = panel.getByRole('grid', { name: say('bank.tableLabel') });
const hiddenThenShown = await columnSpread(table);
// The same table that was never left, for comparison. Checking "nothing is clipped" alone would
// not do: the fallback the defect would leave behind is equal shares, and equal shares clip
// nothing in a panel this wide — the guard has to compare the WIDTHS with the ones the same
// content produces when the tab was visible all along.
await open(page, '/scale');
await page.waitForTimeout(600);
const neverHidden = await columnSpread(
page.locator('aside').getByRole('grid', { name: say('bank.tableLabel') }),
);
check(
hiddenThenShown.widths.join('·') === neverHidden.widths.join('·'),
`a table mounted in a hidden tab measures the same as one that was never left ` +
`(${hiddenThenShown.widths.join('·')} against ${neverHidden.widths.join('·')})`,
);
check(
hiddenThenShown.cut.length === 0,
`and it clips nothing (${hiddenThenShown.cut.join(' · ') || 'nothing'})`,
);
},
// Debt S3.6: zoom at 125/150/200% and the narrow panel were measured by hand, and no guard was
// left. For the layout, browser zoom is the same viewport in CSS pixels: 1440 at 150% gives 960.
/** @param {Page} page @param {Shot} shot */
async zoom(page, shot) {
const base = page.viewportSize() ?? { width: 1440, height: 900 };
// The panel library persists its layout, so a scene that squeezes the window would hand the
// next one a shell it never asked for — and `perf`, which runs after this, measures a drag.
await open(page, '/showcase');
const stored = await page.evaluate(() => JSON.stringify(localStorage));
/** @type {{percent: number, width: number, height: number}[]} */
const steps = [
{ percent: 125, width: 1152, height: 720 },
{ percent: 150, width: 960, height: 600 },
{ percent: 200, width: 720, height: 450 },
];
for (const { percent, width, height } of steps) {
await page.setViewportSize({ width, height });
await open(page, '/showcase');
await page
.locator('aside')
.getByRole('tab', { name: say('bank.tab') })
.click();
await page.waitForTimeout(400);
await shot(`zoom-${percent}`);
const layout = await page.evaluate(() => {
const footer = document.querySelector('footer');
const gaps = document.querySelectorAll('[role="separator"]').length;
return {
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
panels: [...document.querySelectorAll('nav, main, aside')].map((node) =>
Math.round(node.getBoundingClientRect().width),
),
edges: [...document.querySelectorAll('nav, main, aside')].map((node) => ({
left: Math.round(node.getBoundingClientRect().left),
right: Math.round(node.getBoundingClientRect().right),
})),
// How much of the centre its own slot cuts off: the reader's second column goes first.
centreCut: Math.max(
0,
Math.round(
(document.querySelector('main')?.getBoundingClientRect().right ?? 0) -
(document.querySelector('aside')?.getBoundingClientRect().left ?? 0) +
gaps * 0,
),
),
statusCut: [...(footer?.querySelectorAll('span') ?? [])].reduce(
(worst, span) => Math.max(worst, span.scrollWidth - span.clientWidth),
0,
),
};
});
check(
layout.overflow <= 0,
`${percent}%: the window does not run sideways (overflow ${layout.overflow}px)`,
);
// Not "each is wider than zero" — the library cannot give a panel a negative width, so that
// form would hold on any layout at all. What is asked is that the three still stand side by
// side inside the window, in order and without lying on top of one another.
check(
layout.edges.length === 3 &&
layout.edges.every(({ left, right }) => left >= 0 && right <= width + 1) &&
layout.edges.every(({ left }, index) => left >= (layout.edges[index - 1]?.left ?? 0)),
`${percent}%: the three panels stand side by side inside the window (${layout.panels.join(' · ')})`,
);
// The centre keeps a floor of its own and its CONTENT is cut by it long before the window is:
// printed rather than asserted, because the floor is the owner's question of interface density
// (BACKLOG Ф-36/Ф-54) and a gate red by design would only teach people to skip the command.
console.log(` centre content cut by ${String(layout.centreCut)}px at ${percent}%`);
// Not "the strip is inside the window" — the footer's bottom is the window's by construction.
// What zoom actually threatens is the TEXT in it: it is the first thing to be cut.
check(
layout.statusCut === 0,
`${percent}%: the text of the status strip is not cut (${String(layout.statusCut)}px past its box)`,
);
const table = page.locator('aside').getByRole('grid', { name: say('bank.tableLabel') });
const spread = await columnSpread(table);
// The width check alone would go green on a table that drew nothing (0 <= anything), so the
// columns are counted first.
check(
spread.sum > 0,
`${percent}%: the bank table is drawn at all (${spread.sum}px of columns)`,
);
check(
spread.content <= spread.grid + 1,
`${percent}%: the bank table does not run past the edge of the panel (${spread.content} at window ${spread.grid})`,
);
await audit(page, `the shell at ${percent}%`);
}
await page.setViewportSize(base);
await page.evaluate((was) => {
localStorage.clear();
for (const [key, value] of Object.entries(JSON.parse(was))) localStorage.setItem(key, value);
}, stored);
},
// The table's performance on the long tail: 1200 terms, scrolling and search. The numbers are
// printed into the report; the thresholds are coarse and catch a collapse, not a wobble
// (measurement, not belief).
/** @param {Page} page @param {Shot} shot */
async perf(page, shot) {
await open(page, '/scale');
const panel = page.locator('aside');
await panel.getByRole('tab', { name: say('bank.tab') }).click();
const table = panel.getByRole('grid', { name: say('bank.tableLabel') });
const scroll = await table.evaluate(async (node) => {
const startedAt = node.scrollTop;
/** @type {number[]} */
const frames = [];
let last = performance.now();
let running = true;
const tick = (/** @type {number} */ now) => {
frames.push(now - last);
last = now;
if (running) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
for (let step = 0; step < 40; step += 1) {
node.scrollTop += 400;
await new Promise((done) => requestAnimationFrame(done));
}
running = false;
frames.sort((a, b) => a - b);
return {
median: Math.round(frames[Math.floor(frames.length / 2)] ?? 0),
worst: Math.round(frames.at(-1) ?? 0),
rows: node.querySelectorAll('[role="row"]').length,
nodes: node.querySelectorAll('*').length,
// The distance travelled: without it "cheap frames" and "the scroll did not move" are
// indistinguishable, and any breakage would make the measurement GREENER.
travelled: node.scrollTop - startedAt,
};
});
await shot('perf-scrolled');
console.log(
` scrolling 1200 terms: frame median ${scroll.median}ms, worst ${scroll.worst}ms; ` +
`${scroll.rows} rows / ${scroll.nodes} nodes in the DOM`,
);
check(scroll.travelled > 5000, `the table really scrolled by ${scroll.travelled}px`);
check(
scroll.rows > 10,
`and the rows are drawn meanwhile (${scroll.rows}) — we measured no emptiness`,
);
check(
scroll.median <= 34,
`the median frame while scrolling is ${scroll.median}ms (threshold 34)`,
);
check(
scroll.rows <= 80,
`${scroll.rows} rows out of 1200 are held in the DOM — the virtualizer is alive`,
);
// Two DIFFERENT quantities, and they must not be confused: the response of the field is what a
// person feels with their fingers, while the settling of the table lags on purpose
// (useDeferredValue), so that typing does not wait for the collection.
const search = page.getByRole('searchbox', { name: say('bank.search') });
await search.click();
const before = await table.getByRole('row').count();
const rowsSelector = '[role="grid"] [role="row"]';
// The query MUST narrow the result SHARPLY: a single syllable is in almost every term of this
// fixture, the number of rows in the DOM would not change — the measurement would be timing the
// wait and not the repaint. A whole translated term is specific enough. Taken out of the fixture
// BEFORE the stopwatch starts: nothing of ours belongs inside the window being measured.
const query = dstOf(scaleTerms, 't_5');
// FIVE rounds and the MEDIAN, not one round divided by its length. The quantity is the same —
// milliseconds per keystroke — but a single round is an instrument the machine's own load moves:
// measured 43·49 ms on a quiet machine and 64·79 ms with a dozen agents running, on a build that
// an A/B against the pre-session one showed to be FASTER (39·41·43·44·47 against 42·43·44·46·48).
// The threshold below has not moved; what moved is the noise of the reading.
/** @type {number[]} */
const echoes = [];
let settled = 0;
for (let round = 0; round < 5; round += 1) {
await search.fill('');
await page.waitForTimeout(300);
await search.click();
const typed = Date.now();
await page.keyboard.type(query);
await page.waitForFunction(
(want) =>
/** @type {HTMLInputElement | null} */ (document.querySelector('input[type="search"]'))
?.value === want,
query,
{ timeout: 5000 },
);
echoes.push(Math.round((Date.now() - typed) / query.length));
await page.waitForFunction(
({ was, selector }) => document.querySelectorAll(selector).length < was,
{ was: before, selector: rowsSelector },
{ timeout: 5000 },
);
settled = Date.now() - typed;
}
echoes.sort((a, b) => a - b);
const echo = echoes[Math.floor(echoes.length / 2)] ?? 0;
console.log(
` search over 1200 terms: the field answers in ${echo}ms (of ${echoes.join('·')}), ` +
`the table settles in ${settled}ms`,
);
// The threshold comes from a measurement, not out of the head: before the fix it was ~150 ms per
// character (the whole shell repainted), after it 4045. 60 separates one from the other.
check(echo <= 60, `the input field answers in ${echo}ms per character (threshold 60)`);
check(settled <= 700, `the table settles in ${settled}ms (threshold 700)`);
// Dragging the panel with the bank open — what the owner complained about in the words
// "fucking lags".
await search.fill('');
const drag = await dragFrames(page);
console.log(
` dragging the panel with the bank: median ${drag.median}ms, p90 ${drag.p90}ms, worst ${drag.worst}ms, ` +
`frames longer than 50ms — ${drag.janky} of ${drag.frames}`,
);
check(
drag.moved > 100,
`the panel really travelled during the drag (by ${drag.moved}px at the peak)`,
);
check(drag.p90 <= 34, `the p90 frame during the drag is ${drag.p90}ms (threshold 34)`);
check(drag.janky <= 3, `long frames over the drag ${drag.janky} (threshold 3)`);
},
// Remarks 11, 12, 4: three modal windows instead of stub tabs and a second search button.
/** @param {Page} page @param {Shot} shot */
async overlays(page, shot) {
await open(page, '/showcase');
// ⚠ Everything is looked for INSIDE the window. Because of keep-alive the unselected panels stay
// in the DOM (that is the whole point of Ф-19), so a global locator by role finds the hidden row
// of the neighbouring tab and waits for it to be visible until the timeout.
const modal = page.getByRole('dialog');
await page.getByRole('button', { name: say('shell.settingsAction') }).click();
await shot('modal-settings');
check(await modal.isVisible(), 'the settings opened as a modal window');
await audit(page, 'the settings modal window');
await page.keyboard.press('Escape');
await page.getByRole('button', { name: say('library.addBook') }).click();
await modal.locator('input[type="file"]').setInputFiles({
name: 'gu-zhen-ren.txt',
mimeType: 'text/plain',
buffer: Buffer.from('sample'),
});
await shot('modal-add-book');
check(
await modal.getByText(say('addBook.titleHintAuto'), { exact: false }).isVisible(),
'an empty title field honestly says that the title will be given by parsing the file',
);
await modal.getByRole('textbox').last().fill(book.title);
check(
await modal.getByText(say('addBook.titleHintManual'), { exact: false }).isVisible(),
'a title entered by hand differs from the automatic parse',
);
await page.keyboard.press('Escape');
await page.getByRole('button', { name: say('shell.gotoAction') }).click();
await page.keyboard.type(heading('ch_7'));
await shot('modal-goto');
const results = modal.locator('[role="option"]');
check(
(await results.count()) === 1 &&
(await results.first().innerText()).includes(heading('ch_7')),
'the jump palette finds exactly the chapter that was named',
);
await results.first().click();
// ⚠ Counting preview tabs here is pointless: one of them is open in the showcase always, and the
// check would pass even if picking in the palette did NOTHING. We ask for the NAME.
check(
(await page.locator('[role="tab"][data-preview="true"]').innerText()).includes(
heading('ch_7'),
),
'the chapter picked in the palette is the one that opened — as a preview tab',
);
// Escape closes the palette on the FIRST press: the search primitive clears its line on the
// first press.
await page.getByRole('button', { name: say('shell.gotoAction') }).click();
await page.keyboard.type(heading('ch_7'));
await page.keyboard.press('Escape');
check(!(await modal.isVisible()), 'Escape closes the palette on the first press');
// ⚠ The price of that fix: intercepting Escape stops the field from clearing itself, and the
// palette opened with the OLD query — the next letter was appended to it (found by an
// adversarial review).
await page.getByRole('button', { name: say('shell.gotoAction') }).click();
check(
(await modal.getByRole('searchbox').inputValue()) === '',
'a palette opened anew starts from a clean line',
);
await page.keyboard.press('Escape');
// A request from the palette outweighs the filter by kind: it otherwise leads into the bank and
// shows emptiness there.
const panel = page.locator('aside');
await panel.getByRole('tab', { name: say('bank.tab') }).click();
await panel.getByRole('radio', { name: new RegExp(`^${say('kind.place')}`) }).click();
await page.getByRole('button', { name: say('shell.gotoAction') }).click();
await page.keyboard.type(dstOf(terms, 't_1'));
await modal.locator('[role="option"]').first().click();
await page.waitForTimeout(700);
const table = panel.getByRole('grid', { name: say('bank.tableLabel') });
check(
(await table
.getByRole('row')
.filter({ hasText: dstOf(terms, 't_1') })
.count()) === 1,
'the term picked in the palette is visible in the bank, though a filter of another kind was on',
);
// The same term, asked for a SECOND time, is a request as well.
await panel.getByRole('searchbox', { name: say('bank.search') }).fill('zzz');
await page.getByRole('button', { name: say('shell.gotoAction') }).click();
await page.keyboard.type(dstOf(terms, 't_1'));
await modal.locator('[role="option"]').first().click();
await page.waitForTimeout(700);
check(
(await table
.getByRole('row')
.filter({ hasText: dstOf(terms, 't_1') })
.count()) === 1,
'a repeated request for the same term works the same way',
);
},
};
/**
* How the columns of a table lie in their panel: the sum of their widths, the width of the content
* and of the window, and what is clipped. One measurement for three scenarios — the bank, the hidden
* tab, the zoom.
* @param {Locator} table
*/
function columnSpread(table) {
return table.evaluate((node) => {
const heads = [...node.querySelectorAll('[role="columnheader"]')].sort(
(a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left,
);
const sum = heads.reduce((total, th) => total + th.getBoundingClientRect().width, 0);
// The prose column — the last one — stretches and clips legitimately: no width fits a
// description. The REST are sized off their own content, so clipping there is a defect.
const prose = Math.round(heads.at(-1)?.getBoundingClientRect().left ?? 0);
/** @type {string[]} */
const cut = [];
for (const cell of node.querySelectorAll('[role="gridcell"],[role="rowheader"]')) {
const inner = cell.firstElementChild;
if (!inner || Math.round(cell.getBoundingClientRect().left) === prose) continue;
if (inner.scrollWidth > inner.clientWidth) cut.push(inner.textContent ?? '');
}
return {
sum: Math.round(sum),
grid: Math.round(node.getBoundingClientRect().width),
content: node.scrollWidth,
widths: heads.map((th) => Math.round(th.getBoundingClientRect().width)),
cut,
};
});
}
/**
* Pairs of «content ↔ ground of the selected row» that did not reach the WCAG floor. Computed INSIDE
* the page over the computed colours and not over the tokens: what has to be guarded is what is seen
* on the screen.
*
* The floor is taken by role: text 4.5:1, an icon and a surface (the track of the indicator) 3:1.
* ⚠ One pair is deliberately not in here — «fill ↔ track» inside the indicator itself: above 2.07:1
* it cannot be raised on a blue ground with the owner's palette, and that is named in
* Library.module.css.
* @param {Page} page
*/
function contrastInSelectedRow(page) {
return page.evaluate(() => {
const luminance = (/** @type {string} */ color) => {
const [r = 0, g = 0, b = 0] = (color.match(/[\d.]+/g) ?? []).map(Number);
const lin = (/** @type {number} */ c) =>
c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4;
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
};
const ratio = (/** @type {string} */ a, /** @type {string} */ b) => {
const [high = 0, low = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return Math.round((100 * (high + 0.05)) / (low + 0.05)) / 100;
};
const row = document.querySelector('nav [role="row"][data-selected]');
if (!row) throw new Error('there is no selected row in the tree — nothing to guard');
const ground = getComputedStyle(row).backgroundColor;
const parts = [];
for (const node of row.querySelectorAll('*')) {
const style = getComputedStyle(node);
const label = (node.textContent ?? '').trim().slice(0, 24);
// The label for the reader is cut out of the frame (clip-path) — no contrast is asked of it.
const printed = [...node.childNodes].some(
(child) => child.nodeType === 3 && (child.textContent ?? '').trim() !== '',
);
if (printed && style.clipPath === 'none') {
parts.push({ what: `text «${label}»`, floor: 4.5, ratio: ratio(style.color, ground) });
}
if (node.tagName === 'svg') {
parts.push({ what: 'icon', floor: 3, ratio: ratio(style.color, ground) });
}
// By the ALPHA, and not by a comparison with the string of a transparent colour: the browser
// prints it in different ways, and a literal colour in the code is forbidden by the gate — and
// rightly so, the comparison has to be against the property.
const opaque = Number((style.backgroundColor.match(/[\d.]+/g) ?? [])[3] ?? 1) > 0;
if (opaque && style.clipPath === 'none') {
parts.push({
what: `surface «${label}»`,
floor: 3,
ratio: ratio(style.backgroundColor, ground),
});
}
}
return parts.filter((part) => part.ratio < part.floor);
});
}
/**
* Drags the right separator through a list of offsets from where it started, then lets go.
* @param {Page} page @param {number[]} offsets
*/
async function pull(page, offsets) {
const separator = page.locator('[role="separator"]').nth(1);
const box = await separator.boundingBox();
if (!box) throw new Error('the right separator was not found');
const y = box.y + box.height / 2;
await page.mouse.move(box.x + box.width / 2, y);
await page.mouse.down();
for (const x of offsets) await page.mouse.move(box.x + x, y, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(300);
}
/**
* A drag of the right separator there and back with the frames measured. What is measured is EXACTLY
* what a person complains about: not an abstract render, but dragging the window with the table
* open.
* @param {Page} page
*/
async function dragFrames(page) {
const panel = page.locator('aside');
const widthBefore = (await panel.boundingBox())?.width ?? 0;
await page.evaluate(() => {
/** @type {{frames: number[], last: number, running: boolean}} */
const state = { frames: [], last: performance.now(), running: true };
Object.assign(window, { __frames: state });
const tick = (/** @type {number} */ now) => {
state.frames.push(now - state.last);
state.last = now;
if (state.running) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
const separator = page.locator('[role="separator"]').nth(1);
const box = await separator.boundingBox();
if (!box) throw new Error('the right separator was not found');
const y = box.y + box.height / 2;
await page.mouse.move(box.x + box.width / 2, y);
await page.mouse.down();
let widthPeak = widthBefore;
for (const x of [-60, -140, -220, -300, -220, -140, -60, 0]) {
await page.mouse.move(box.x + x, y, { steps: 8 });
// The width is taken DURING the drag: by the end the mouse comes back where it was, and a
// "before/after" comparison would show zero — that is, the measurement would pass on a panel
// stuck dead as well.
const width = (await panel.boundingBox())?.width ?? 0;
if (width > widthPeak) widthPeak = width;
}
await page.mouse.up();
const stats = await page.evaluate(() => {
const state = /** @type {{frames: number[], running: boolean}} */ (
Reflect.get(window, '__frames')
);
state.running = false;
const frames = [...state.frames].sort((a, b) => a - b);
return {
median: Math.round(frames[Math.floor(frames.length / 2)] ?? 0),
p90: Math.round(frames[Math.floor(frames.length * 0.9)] ?? 0),
worst: Math.round(frames.at(-1) ?? 0),
// A person feels the LONG frames, not the median: it held at 17 ms even where the drag was
// torn to shreds. We count them by the piece.
janky: frames.filter((frame) => frame > 50).length,
frames: frames.length,
};
});
return { ...stats, moved: Math.round(widthPeak - widthBefore) };
}
/**
* Whether the description is covered. The spoiler is `filter: blur()`: a filter is applied AFTER
* the element is drawn and only an ancestor can cut it, whereas `text-shadow` is the element's own
* ink and its own `overflow` cuts it at the glyphs (owner, 10.08: "square on the left").
* @param {Locator} sense
*/
const isCovered = (sense) => sense.evaluate((node) => getComputedStyle(node).filter !== 'none');
/** @param {Locator} separator */
const handleStyle = (separator) =>
separator
.locator('span')
.first()
.evaluate((node) => {
const style = getComputedStyle(node);
return { opacity: style.opacity, background: style.backgroundColor, height: style.height };
});
/** @param {Page} page @param {string} route */
async function open(page, route) {
await page.goto(`${origin}${route}`, { waitUntil: 'load' });
await page.waitForFunction(() => document.documentElement.dataset.screen === 'ready', null, {
timeout: 15_000,
});
await page.evaluate(() => document.fonts.ready);
await settle(page);
}
/**
* Waits for the animations to play out: the frame and the measurement are bound to land on a settled
* state and not in the middle of a transition. Infinite animations we have none — should any appear,
* the wait will honestly run into its timeout instead of taking a blinking frame for a settled one.
* @param {Page} page
*/
const settle = (page) =>
page.waitForFunction(
() => document.getAnimations().every((animation) => animation.playState !== 'running'),
null,
{ timeout: 5000 },
);
const requested = process.argv.slice(2);
const names = requested.length > 0 ? requested : Object.keys(scenes);
const unknown = names.filter((name) => !(name in scenes));
if (unknown.length > 0) {
throw new Error(
`No such scenario: ${unknown.join(', ')}. Known ones: ${Object.keys(scenes).join(', ')}`,
);
}
await mkdir(shotsDir, { recursive: true });
await build({ logLevel: 'warn' });
const server = await preview({ preview: { open: false } });
const origin = server.resolvedUrls?.local[0]?.replace(/\/$/, '');
if (!origin) throw new Error('vite preview did not give back a local address');
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
});
const page = await context.newPage();
for (const name of names) {
console.log(`\n${name}`);
/** @param {string} file */
const shot = (file) => page.screenshot({ path: resolve(shotsDir, `${file}.png`) });
await scenes[name]?.(page, shot);
}
await browser.close();
await server.close();
console.log('\nevery scenario passed');