textmachine/frontend/scripts/scenes.mjs
2026-08-15 17:39:09 +03:00

1778 lines
83 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 {(line: string, variables: Record<string, string | number> | undefined) => string} */
const fill = (await load('../src/i18n/fill.ts')).fill;
/**
* @type {{
* book: Schemas['Book'],
* books: Schemas['Book'][],
* chapters: Schemas['Chapter'][],
* notes: Schemas['Note'][],
* }}
*/
const { book, books, chapters, notes } = await load('../src/mock/book.ts');
/**
* The book of a given status, by the fixture's own hand: a title written out here would go stale in
* silence the moment the fixture is edited.
* @param {Schemas['BookStatus']} status
*/
function bookOf(status) {
const found = books.find((row) => row.status === status);
if (!found) throw new Error(`the fixture has no book in status ${status}`);
return found;
}
const uploadingBook = bookOf('uploading');
const rejectedBook = bookOf('rejected');
const pausedBook = bookOf('paused');
/** @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.
*
* ⚠ The FILLING is the application's own (`src/i18n/fill.ts`), not a copy of it. S3.7 wrote a
* second one right here, with a different rule for a place left without a variable, and its own
* report claimed the module was shared — the acceptance found the claim false. A scene has to
* build the phrase the screen prints, which means by the same code.
* @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 fill(line, variables);
}
/**
* 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)`);
// The NODE, not only the count: a bare number is a thing nobody can act on, and the same
// omission cost the acceptance of S3.6 a hunt by hand.
for (const node of v.nodes)
console.log(` ${node.target.join(' ')}${node.html.slice(0, 120)}`);
}
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');
// The long tail folds by hundreds (Ф-12): 2284 sections stand as 23 folds, and a click on a fold
// is a fold — not a row that answers with nothing. Checked here because this scene already opens
// the book that has the tail. The FIRST fold is the one in the DOM: the tree is virtualized, so
// a fold a thousand rows down is not drawn at all.
const fold = page
.locator('nav [role="row"]')
.filter({ hasText: say('library.chapterRange', { from: 1, to: 100 }) });
check((await fold.count()) === 1, 'the tree of 2284 sections is folded by hundreds');
const opened = await page.locator('nav [role="row"]').count();
await fold.first().click();
await page.waitForTimeout(300);
const closed = await page.locator('nav [role="row"]').count();
check(
closed < opened,
`a click on a fold closes it (${String(opened)}${String(closed)} rows in the DOM)`,
);
await fold.first().click();
await page.waitForTimeout(300);
check(
(await page.locator('nav [role="row"]').count()) === opened,
'and a second click opens it again',
);
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',
);
// S4: a click on a BOOK opens its state (the zone's §2), and the states that have something to
// say beyond a word say it on the card. The fixture holds every one of the eleven; these three
// are the ones with a sentence attached, and each is reached by choosing its book.
await panel.getByRole('tab', { name: say('about.tab') }).click();
for (const { title, phrase, what } of [
{ title: uploadingBook.title, phrase: say('about.uploading'), what: 'a book still arriving' },
{
title: rejectedBook.title,
phrase: say('rejected.sourceUnreadable'),
what: 'a book that could not be parsed',
},
{
title: pausedBook.title,
phrase: say('paused.creditExhausted'),
what: 'a run halted on its ceiling',
},
]) {
await page.locator('nav').getByText(title, { exact: true }).click();
// The wait is the assertion's own timeout, and its verdict is what `check` prints: a bare
// `check(true, …)` after a wait reads as a tautology to whoever comes next.
const said = await page
.waitForFunction(
(want) => document.querySelector('aside')?.textContent?.includes(want) === true,
phrase,
{ timeout: 10_000 },
)
.then(
() => true,
() => false,
);
check(said, `${what}: the card says so in words, not by colour alone («${phrase}»)`);
}
await shot('context-book-state');
await audit(page, 'the card of a book in an intake state');
},
// 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();
// ⚠ The PRECONDITION is asserted and not hoped for (acceptance of S3.7, finding 5): if the bank
// had already arrived by the time the tab was left, this scene would be measuring an ordinary
// table and going green about nothing.
check(
(await panel.getByRole('grid', { name: say('bank.tableLabel') }).count()) === 0,
'the bank had not arrived by the time its tab was left — which is what this scene is about',
);
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') }),
);
// ⚠ Three checks and not one, because the differential alone is a guard a SYMMETRIC regression
// walks straight through (acceptance of S3.7, finding 5): two tables that both fell back to
// equal shares — or both drew nothing at all — measure the same as each other.
check(
hiddenThenShown.widths.length === 4 && hiddenThenShown.sum > 0,
`the table is drawn at all: ${String(hiddenThenShown.widths.length)} columns, ` +
`${String(hiddenThenShown.sum)}px`,
);
check(
new Set(hiddenThenShown.widths.slice(0, 3)).size > 1,
`and its columns are sized by their CONTENT rather than by equal shares ` +
`(${hiddenThenShown.widths.join('·')})`,
);
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.
// ⚠ STRICTLY to the right, not "no further left": equal lefts are exactly what a stack has,
// and the check passed on one (acceptance of S3.7, finding 8).
//
// The stronger form — each panel begins where the previous one ENDS — is deliberately NOT a
// gate: from 125% the centre already runs under the right panel by the number printed below,
// and that is Ф-54, an open question of interface density that belongs to the owner (В-7).
// A gate red by construction teaches people to skip the command, so the overlap is printed
// and the order is asserted.
check(
layout.edges.length === 3 &&
layout.edges.every(({ left, right }) => left >= 0 && right <= width + 1) &&
layout.edges.every(
({ left }, index) => index === 0 || left > (layout.edges[index - 1]?.left ?? 0),
),
`${percent}%: the three panels stand side by side inside the window ` +
`(${layout.edges.map(({ left, right }) => `${left}${right}`).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 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('');
// ⚠ The round starts from a table that has SETTLED, and the number it settled at is measured
// here rather than inherited: the count of rows in the DOM belongs to the virtualizer and
// depends on where the list stands, so the one taken before the scrolling above is not the
// one this round narrows from. With a fixed pause instead, the rows could still be missing
// when the stopwatch started — "fewer rows than before" would be true on arrival and the
// settling would be measured as nothing (acceptance of S3.7, finding 7).
const full = await rowsSettled(page, rowsSelector);
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: full, 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)`);
},
// S4: the path the product begins with — a file becomes a book, the book is parsed, the book is
// translated. Everything here runs against the mock network by the form of the contract; the
// fixture's own rules (the file last, the caps) are the platform's, so a client that broke one
// would fail here rather than in production.
/** @param {Page} page @param {Shot} shot */
async intake(page, shot) {
await open(page, '/intake');
// The precondition is ASSERTED and not assumed: this world starts empty, and every count below
// is a count of what the form put there.
check(
(await page.locator('nav [role="row"]').count()) === 0,
'the intake world starts with an empty library',
);
await page.getByRole('button', { name: say('library.addBook') }).click();
const modal = page.getByRole('dialog');
await shot('intake-form');
await audit(page, 'the add-a-book form');
// The pair of languages is a real choice with a real keyboard: the whole path has to be
// passable without a mouse.
const source = modal.getByLabel(say('addBook.sourceLangLabel'));
const before = (await source.innerText()).trim();
await source.focus();
await page.keyboard.press('Enter');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
const after = (await source.innerText()).trim();
// ⚠ A CHANGE, not "there is a value": the control shows one before a key is ever pressed, so
// the first version of this check was true of a keyboard that did nothing (found by the
// adversarial review).
check(
after !== before && after !== '',
`the language of the original is chosen from the keyboard (${before}${after})`,
);
await modal.locator('input[type="file"]').setInputFiles({
name: 'gu-zhen-ren.txt',
mimeType: 'text/plain',
buffer: Buffer.alloc(300_000, 'a'),
});
await modal.getByRole('textbox', { name: say('addBook.titleLabel') }).fill(book.title);
check(
await modal.getByText(say('addBook.titleHintManual'), { exact: false }).isVisible(),
'a title entered by hand differs from the automatic parse',
);
await modal.getByRole('button', { name: say('addBook.submit') }).click();
// The sending is a STATE and not a flash. ⚠ WHAT IS CHECKED HERE is what the browser world can
// show: under the mock network the request is answered by a service worker, and Chromium reports
// no upload progress for such a request at all (probe: `loadstart` with the right total, then
// nothing). So the screen honestly says the size going out and draws NO share — the determinate
// bar and the "all sent" line belong to the real platform and are proved by src/api/upload.test.ts.
// The line is found by the part of the catalogue string that stands BEFORE the place: the size
// itself is formatted by `Intl` for the interface's locale, and a scene that spelled it out
// would be a second copy of that formatting.
const sending = modal.getByText(say('addBook.sendingUnknown').split('{')[0]?.trim() ?? '', {
exact: false,
});
await sending.waitFor({ state: 'visible', timeout: 5000 });
await shot('intake-sending');
const said = await sending.innerText();
check(/\d/.test(said), `the form says how much is going out («${said.trim()}»)`);
check(
(await modal.getByRole('progressbar').count()) === 0,
'and draws no share while the browser reports none — a bar at zero would be a number that means nothing',
);
// 201 carries `parsing` — the confirmation shows what came back, from the contract's own
// fields and not from what the form typed in.
await modal.getByText(say('addBook.acceptedTitle')).waitFor({ timeout: 10_000 });
await shot('intake-accepted');
const confirmation = await modal.innerText();
check(
confirmation.includes(book.title) && confirmation.includes(say('status.parsing')),
'the confirmation names the book and says it is being parsed',
);
await audit(page, 'the confirmation of the upload');
await modal.getByRole('button', { name: say('action.done') }).click();
// Parsing is a visible step of its own, with no number on it: the contract has no counter for
// it, so the screen says the state and invents no percentage.
const badge = page.locator('nav [role="row"]').first();
check(
(await badge.innerText()).includes(say('status.parsing')),
'the book stands in the library while it is being parsed',
);
check(
!/\d+\s*%/.test(await badge.innerText()),
'and no percentage of the parsing is invented anywhere on that row',
);
await shot('intake-parsing');
// Nothing PUSHES the end of the parse — a book being parsed has no run and therefore no
// stream — so this waits for the poll to bring it.
await page.waitForFunction(
(want) => document.querySelector('nav [role="row"]')?.textContent?.includes(want) === true,
say('status.notStarted'),
{ timeout: 20_000 },
);
// Waited for and not counted on the spot: the tree is read AGAIN when the poll finds the parse
// over, and the answer to that read is a round trip behind the status.
const grown = await page
.waitForFunction(() => document.querySelectorAll('nav [role="row"]').length > 1, null, {
timeout: 15_000,
})
.then(
() => true,
() => false,
);
const chapters = await page.locator('nav [role="row"]').count();
check(grown, `the sections of the parsed book are in the tree (${String(chapters)} rows)`);
await shot('intake-parsed');
// The run: the scale is in CHAPTERS, and there is no money on the screen in any form (§4.8).
const panel = page.locator('aside');
await panel.getByRole('tab', { name: say('about.tab') }).click();
await panel.getByRole('button', { name: say('run.action') }).click();
const scale = modal.getByRole('slider');
await scale.waitFor({ timeout: 5000 });
await shot('intake-run-form');
await audit(page, 'the run form');
// The scale is a native range under the hood (that is what gives it the keyboard and the
// screen reader for free), so its bounds live on the input and not in aria-* attributes.
const boundsOf = () =>
scale.evaluate((node) =>
node instanceof HTMLInputElement
? { now: node.value, min: node.min, max: node.max }
: {
now: node.getAttribute('aria-valuenow'),
min: node.getAttribute('aria-valuemin'),
max: node.getAttribute('aria-valuemax'),
},
);
const bounds = await boundsOf();
check(
bounds.now === '4' && bounds.min === '1' && bounds.max === '8',
`the scale is built from the platform's bounds, preset included (${JSON.stringify(bounds)})`,
);
check(
!/[$₽€]|\d+[.,]\d{2}\b/.test(await modal.innerText()),
'not a sum, not a currency and not a price anywhere on the run form',
);
// The scale is a real control for the keyboard, not a decoration for the mouse.
await scale.focus();
await page.keyboard.press('ArrowRight');
check((await boundsOf()).now === '5', 'the ceiling moves from the keyboard');
// ⚠ THE FIRST PRESS MEETS A 409, and the fixture produces it the way the platform does rather
// than by a marker: the bounds moved between the read and the call (the contract's own case — a
// hold taken for another book lowers what is left). This is the one refusal a correct client
// can meet on a correct form, and §11 of the assignment asks for every branch to be SHOWN.
await modal.getByRole('button', { name: say('run.action') }).click();
const refused = modal.getByText(say('run.conflictAdvice'), { exact: false });
await refused.waitFor({ timeout: 10_000 });
await shot('intake-run-conflict');
check(
await refused.isVisible(),
'a ceiling that no longer fits is refused, and the form says so',
);
// The same shape as the intake refusals: the client's OWN phrase for this class must be absent,
// which is what says the sentence on the screen came from the platform.
check(
!(await modal.innerText()).includes(say('run.conflict')),
"and the phrase is the platform's own, not the client's fallback",
);
// The scale was re-read, so the value now standing on it is the platform's NEW preset — not the
// one the person picked against bounds that no longer exist.
await page.waitForFunction(
() =>
(document.querySelector('[role="dialog"] input[type="range"]')?.getAttribute('max') ??
'') === '2',
null,
{ timeout: 10_000 },
);
check((await boundsOf()).now === '2', 'and the scale comes back with the bounds that are left');
await modal.getByRole('button', { name: say('run.action') }).click();
// The run goes, and it ends where the contract says a ceiling stop ends: `paused`, with a
// machine reason, never `failed`. The phrase is the owner's (В-6) and the client only picks it.
await page.waitForFunction(
(want) => document.querySelector('footer')?.textContent?.includes(want) === true,
say('paused.creditExhausted'),
{ timeout: 20_000 },
);
await shot('intake-paused');
// The halt is a LIVED state and not a toast: the strip carries it, the card carries it, and both
// are still there a minute later — which a message that fades would not be.
check(
(await page.locator('footer').innerText()).includes(say('paused.creditExhausted')),
'the halt on the ceiling is named in the status strip, in the owners own words',
);
check(
(await panel.innerText()).includes(say('paused.creditExhausted')),
'and on the card of the book, where the action for it would be',
);
// ⚠ What the card offers is a NEW run — which carries a new ceiling — and never "continue":
// after a ceiling stop `resume` does not move the run at all (contract §resumeRun).
check(
(await panel.innerText()).includes(say('run.action')),
'the way on from a halt is a new run with a ceiling of its own, and it is offered',
);
},
// The other half of the same path: everything that can go wrong with an intake, each as a state
// of the form rather than an alert over it (§3.8). The class is asked for by a marker in the name
// of the file — the fixture's own switch (src/mock/intake.ts), because a correct client cannot
// produce these answers and the screens for them would otherwise be checked by nothing.
/** @param {Page} page @param {Shot} shot */
async refusals(page, shot) {
await open(page, '/intake');
const modal = page.getByRole('dialog');
/** @param {string} name @param {number} size */
const send = async (name, size) => {
await page.getByRole('button', { name: say('library.addBook') }).click();
await modal.locator('input[type="file"]').setInputFiles({
name,
mimeType: 'text/plain',
buffer: Buffer.alloc(size, 'a'),
});
await modal.getByRole('button', { name: say('addBook.submit') }).click();
// Any of the advices the form can end on: which one it is, is what the checks below ask.
await modal
.getByText(say('upload.retryAdvice'), { exact: false })
.or(modal.getByText(say('upload.badRequestAdvice'), { exact: false }))
.or(modal.getByText(say('upload.tooLargeAdvice'), { exact: false }))
.or(modal.getByText(say('upload.notAcceptedAdvice'), { exact: false }))
.waitFor({ timeout: 10_000 });
return modal.innerText();
};
// The body over the cap — the one refusal the fixture takes from the FILE itself and not from
// a marker, because that rule is real on both sides of the wire.
const tooLarge = await send('gu-zhen-ren.txt', 2_400_000);
await shot('refusal-413');
check(
tooLarge.includes(say('upload.tooLargeAdvice')),
'a file over the cap is refused with the advice to take a smaller one',
);
await audit(page, 'the refusal of an oversized file');
await closeModal(page);
for (const { marker, advice, ours, what } of [
{
marker: 'refuse-400',
advice: say('upload.badRequestAdvice'),
ours: say('upload.badRequest'),
what: 'a form that did not arrive whole',
},
{
marker: 'refuse-404',
advice: say('upload.notAcceptedAdvice'),
ours: say('upload.notAccepted'),
what: 'a deployment that accepts no books',
},
{
marker: 'refuse-408',
advice: say('upload.retryAdvice'),
ours: say('upload.timeout'),
what: 'a body that missed the deadline',
},
]) {
const shown = await send(`${marker}.txt`, 1000);
await shot(`refusal-${marker}`);
check(shown.includes(advice), `${what}: the form says what to do next`);
// ⚠ The CLIENT'S OWN phrase for this very class must be absent: it is the fallback used when
// the platform sends no problem body, so it is reachable — and its absence therefore says the
// phrase on the screen came from the server. The first version of this check looked for a
// phrase no path can print at all and so could never fail (found by the adversarial review).
check(
!shown.includes(ours),
`${what}: the phrase shown is the platform's, not the client's own («${ours}»)`,
);
await closeModal(page);
}
// And the other end of the intake: a book that was accepted and could not be turned into
// chapters. Three reasons, three different next actions.
for (const { marker, reason, advice } of [
{
marker: 'reject-source',
reason: say('rejected.sourceUnreadable'),
advice: say('rejected.sourceUnreadableAdvice'),
},
{
marker: 'reject-config',
reason: say('rejected.notConfigured'),
advice: say('rejected.notConfiguredAdvice'),
},
{
marker: 'reject-parser',
reason: say('rejected.parserUnavailable'),
advice: say('rejected.parserUnavailableAdvice'),
},
]) {
await page.getByRole('button', { name: say('library.addBook') }).click();
await modal.locator('input[type="file"]').setInputFiles({
name: `${marker}.txt`,
mimeType: 'text/plain',
buffer: Buffer.alloc(1000, 'a'),
});
await modal.getByRole('button', { name: say('addBook.submit') }).click();
await modal.getByText(say('addBook.acceptedTitle')).waitFor({ timeout: 10_000 });
await modal.getByRole('button', { name: say('action.done') }).click();
// The book just uploaded is CHOSEN in the tree — the library holds the ones before it, and a
// card that went on showing the first book would make every reason below read the same.
// (The title is the name of the file: the form left it to the parse.)
await page.locator('nav').getByText(marker, { exact: true }).click();
const panel = page.locator('aside');
await panel.getByRole('tab', { name: say('about.tab') }).click();
await page.waitForFunction(
(want) => document.querySelector('aside')?.textContent?.includes(want) === true,
reason,
{ timeout: 20_000 },
);
await shot(`refusal-${marker}`);
// The row of a book can be SELECTED since S4, so the pairs on the blue ground are checked
// here as well: axe does not see the contrast of a badge it considers decorative, and the
// rule that recolours it had been removed in S3.7 as dead code.
const faint = await contrastInSelectedRow(page);
check(
faint.length === 0,
`${marker}: everything on the selected row of the book is readable ${faint
.map((part) => `${part.what} ${part.ratio}:1 < ${part.floor}`)
.join(' · ')}`,
);
check(
(await panel.innerText()).includes(advice),
`${marker}: the card names the reason and what to do about it`,
);
}
await audit(page, 'a rejected book on the card');
},
// 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',
);
// By NAME and not by position: the form has gained the pair of languages and the genre since
// this scene was written, and `.last()` had quietly become the genre field.
await modal.getByRole('textbox', { name: say('addBook.titleLabel') }).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);
};
// ⚠ NOT rounded before the comparison: two places would accept 2.995 as three, which is the
// hole the token gate closed on its own copy of this arithmetic (adversarial review). The
// rounding that remains is for the MESSAGE and happens after the verdict.
const ratio = (/** @type {string} */ a, /** @type {string} */ b) => {
const [high = 0, low = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (high + 0.05) / (low + 0.05);
};
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)
.map((part) => ({ ...part, ratio: Math.round(part.ratio * 100) / 100 }));
});
}
/**
* The number of rows the table has come to rest at. The repaint of the collection is deferred on
* purpose (`useDeferredValue`), so "how many rows are there" has no answer until it stops changing —
* and a fixed pause in its place is a number that is either too short to be true or too long to be
* a measurement.
* @param {Page} page @param {string} selector
*/
async function rowsSettled(page, selector) {
// THREE readings in a row, not two: the deferred repaint has plateaus of its own, and two equal
// samples 50 ms apart can both land on one (found by the adversarial review).
let previous = -1;
let steady = 0;
for (let attempt = 0; attempt < 60; attempt += 1) {
const now = await page.locator(selector).count();
steady = now === previous ? steady + 1 : 0;
if (steady >= 2 && now > 0) return now;
previous = now;
await page.waitForTimeout(50);
}
throw new Error('the table never settled on a number of rows');
}
/**
* 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 };
});
/**
* Closes the modal that is open and WAITS for it to be gone.
*
* Escape alone is a race: the window plays a transition, and the next click lands on the overlay
* that is still there — which is how this scene began failing once in every few runs, with the
* pointer intercepted by `_overlay_`. Waiting for the dialog to detach is the only honest signal.
* @param {Page} page
*/
async function closeModal(page) {
const modal = page.getByRole('dialog');
if ((await modal.count()) === 0) return;
await page.keyboard.press('Escape');
await modal.waitFor({ state: 'detached', timeout: 5000 });
}
/** @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');