// 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} */ const ru = (await load('../src/i18n/ru.ts')).ru; /** @type {(line: string, variables: Record | 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} 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} [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 Promise>} */ 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 40–45. 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 owner’s 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');