@fasl-work/caos-app-shell 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/gate/run.mjs CHANGED
@@ -1,780 +1,1036 @@
1
- // caos-shell-gate runner: measures a built CAOS product in a real browser and fails on what a reader would meet.
2
- // Every check embeds a trap recorded in the 2026-10-04 failure history (failure class 10: gates that measured the
3
- // wrong thing); the self-test in this repository plants one defect per check and requires each to fail (G15).
4
- //
5
- // G1 subject identity the brand must match --expect-brand; a check that finds no subject fails
6
- // G2 mode identity theme and language set with the shell's own storage keys, then read back
7
- // G3 errors console.error, page errors, any GET answered 400 or above
8
- // G4 deep links every route loaded directly (and with a trailing slash), 200 and rendered; artifacts
9
- // are JSON, not the HTML fallback; a missing asset answers 404; doc text clears a floor
10
- // G5 scroll and reach boxes inside the viewport, the rail and their scrollers; tall views scroll; nothing is
11
- // cut; truncated text carries its full text; every control reachable by the pointer
12
- // G6 painted area drawing surfaces have painted pixels, fill their stage, and cover at least half the
13
- // viewport on the App route, measured on the screenshot, never on a host box
14
- // G7 walk everything every route, tab, sub-tab and case, at five sizes, both themes, both languages,
15
- // waiting for declared state (data-state, data-stale, data-drawn), never networkidle
16
- // G8 idle at rest no sustained animation frames or DOM mutations after load and after a tab switch
17
- // G9 reactivity each registered control changes the selection key and no view keeps an old one; the
18
- // rendered case is the case asked for; a disabled control is not moved
19
- // G10 text in drawings every label of a chart, an instrument SVG or a document figure inside the drawing and
20
- // clear of the others, also when every page is rendered in a wider fallback font (the
21
- // wide-font pass: Verdana, else DejaVu Sans, the Linux runner's font)
22
- // G11 Spanish numbers a page in Spanish writes no decimal point (canvas text excepted)
23
- // G12 sticky lists a vertical sub-tab list is still in view at the end of a long section
24
- // G13 contrast every visible text at WCAG AA against the colour behind it (primary size, both themes; first language, and every language on a documentation route)
25
- // G14 captures index gate-output/index.html shows every capture by route and mode, the failures first
26
- // plus the ADR-0071 and ADR-0017 measures (viewport, one-row tabs, rail without scroll, centred prose, captions,
27
- // references).
28
-
29
- import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
30
- import { join } from 'node:path';
31
- import { decodePng, paintedBox, unionArea } from './png.mjs';
32
- import { servePages } from './serve.mjs';
33
- import { installLib } from './inpage.mjs';
34
- import { DOC_ROUTES, docFloorFailures } from './areas.mjs';
35
-
36
- export const DEFAULTS = {
37
- sizes: '390x844,768x1024,1280x800,1600x900,2560x1440',
38
- themes: 'light,dark',
39
- langs: 'en,es',
40
- workbench: '/app',
41
- idleMs: 3000,
42
- settleMs: 15000,
43
- textFloor: 400,
44
- instrumentMin: 0.5,
45
- stageFillMin: 0.3,
46
- stageFloor: 140,
47
- viewportMinWidth: 1280,
48
- out: 'gate-output',
49
- maxShots: 40,
50
- wideFont: true,
51
- wideFontSizes: '390x844,1280x800',
52
- };
53
-
54
- /** The wide fallback font of the G10 pass: wider than the Windows and macOS sans fonts; DejaVu Sans is what a Linux
55
- * reader (and the CI runner) gets. Applied before the first frame, so a drawing that measures its text measures it. */
56
- const WIDE_FONT = ':root, :root[data-theme] { --font-sans: Verdana, "DejaVu Sans", sans-serif !important; }';
57
-
58
- const RATE_LIMIT = 5; // frames or mutations per second at rest above which a loop is reported (G8)
59
-
60
- async function loadKeys() {
61
- try {
62
- return await import(new URL('../dist/keys.js', import.meta.url).href);
63
- } catch {
64
- throw new Error('caos-shell-gate: dist/keys.js is missing; the shell package is not built (npm run build)');
65
- }
66
- }
67
-
68
- /**
69
- * @param {object} o options (see bin/caos-shell-gate.mjs)
70
- * @returns {Promise<object>} the report; `report.ok` is false on any failure
71
- */
72
- export async function runGate(o) {
73
- const opt = { ...DEFAULTS, ...o };
74
- if (!opt.expectBrand) throw new Error('caos-shell-gate: --expect-brand is required (G1: the gate never measures an unnamed subject)');
75
- if (!opt.url && !opt.serve) throw new Error('caos-shell-gate: --url or --serve is required (there is no default target)');
76
- let chromium;
77
- try {
78
- ({ chromium } = await import('playwright'));
79
- } catch {
80
- throw new Error('caos-shell-gate: install playwright in this project (npm i -D playwright) to run the gate');
81
- }
82
- const { THEME_STORAGE_KEY, LANG_STORAGE_KEY } = await loadKeys();
83
-
84
- const sizes = String(opt.sizes).split(',').map((s) => s.trim().split('x').map(Number));
85
- const themes = String(opt.themes).split(',').map((s) => s.trim());
86
- const langs = String(opt.langs).split(',').map((s) => s.trim());
87
- const workbenchRoute = String(opt.workbench).split(',')[0].trim() || '/app';
88
- const workbenchRoutes = new Set(String(opt.workbench).split(',').map((s) => s.trim()).filter(Boolean));
89
- const primarySize = sizes.find(([w]) => w >= opt.viewportMinWidth) ?? sizes[0];
90
- const shotsDir = join(opt.out, 'shots');
91
- // Every run starts from an empty capture folder: a capture left by an earlier run is stale evidence.
92
- rmSync(shotsDir, { recursive: true, force: true });
93
- mkdirSync(shotsDir, { recursive: true });
94
-
95
- let server = null;
96
- let base = opt.url;
97
- if (opt.serve) {
98
- server = await servePages(opt.serve, opt.basePath ?? '/');
99
- base = server.url;
100
- }
101
- base = base.endsWith('/') ? base : `${base}/`;
102
- const basePath = new URL(base).pathname;
103
- const urlFor = (route) => new URL(route.replace(/^\//, ''), base).href;
104
- const routeOf = (href) => {
105
- const p = new URL(href, base).pathname;
106
- return p.startsWith(basePath) ? `/${p.slice(basePath.length)}`.replace(/\/$/, '') || '/' : null;
107
- };
108
-
109
- const failures = [];
110
- const waivers = []; // ADR-0017 2.1: every waiver a documentation page declares, with its verdict
111
- // The smallest measured values against each floor, so a pass shows its margin (and a CI font change that eats it
112
- // is visible before it fails).
113
- const minima = { drawnShare: null, stageFill: null };
114
- const low = (k, value, where) => {
115
- if (minima[k] === null || value < minima[k].value) minima[k] = { value: Number(value.toFixed(4)), route: where.route, mode: where.mode, trail: where.trail };
116
- };
117
- let shots = 0;
118
- let states = 0;
119
- const fail = (check, where, message) => failures.push({ check, route: where.route ?? '', mode: where.mode ?? '', trail: where.trail ?? '', message });
120
- const artifacts = new Map();
121
-
122
- const browser = await chromium.launch();
123
- const openContext = async ([w, h], theme, lang, wide = false) => {
124
- const ctx = await browser.newContext({ viewport: { width: w, height: h }, deviceScaleFactor: 1 });
125
- if (wide) {
126
- await ctx.addInitScript((css) => {
127
- const add = () => {
128
- const st = document.createElement('style');
129
- st.setAttribute('data-caos-gate-font', '');
130
- st.textContent = css;
131
- document.documentElement.appendChild(st);
132
- };
133
- if (document.documentElement) add();
134
- else
135
- new MutationObserver((_, o) => {
136
- if (document.documentElement) {
137
- o.disconnect();
138
- add();
139
- }
140
- }).observe(document, { childList: true });
141
- }, WIDE_FONT);
142
- }
143
- await ctx.addInitScript(
144
- ([tk, lk, t, l]) => {
145
- try {
146
- localStorage.setItem(tk, t);
147
- localStorage.setItem(lk, l);
148
- } catch {
149
- /* storage unavailable: G2 reports the mode that came back */
150
- }
151
- },
152
- [THEME_STORAGE_KEY, LANG_STORAGE_KEY, theme, lang],
153
- );
154
- await ctx.addInitScript(installLib);
155
- return ctx;
156
- };
157
-
158
- const newPage = async (ctx) => {
159
- const page = await ctx.newPage();
160
- const sink = [];
161
- page.on('console', (m) => {
162
- if (m.type() === 'error') sink.push(`console.error: ${m.text().slice(0, 300)}`);
163
- });
164
- page.on('pageerror', (e) => sink.push(`page error: ${String(e).slice(0, 300)}`));
165
- page.on('response', (r) => {
166
- const req = r.request();
167
- if (req.method() !== 'GET') return;
168
- if (r.status() >= 400) sink.push(`HTTP ${r.status()} for ${r.url()}`);
169
- const type = req.resourceType();
170
- let pathname = '';
171
- try {
172
- pathname = new URL(r.url()).pathname;
173
- } catch {
174
- /* not a URL */
175
- }
176
- if ((type === 'fetch' || type === 'xhr') && pathname.endsWith('.json') && r.status() < 400 && !artifacts.has(pathname)) {
177
- artifacts.set(
178
- pathname,
179
- r.text().then(
180
- (t) => t.trimStart().slice(0, 1),
181
- () => '',
182
- ),
183
- );
184
- }
185
- });
186
- return { page, sink };
187
- };
188
-
189
- const settle = async (page) => {
190
- await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))))).catch(() => undefined);
191
- const t0 = Date.now();
192
- let last = 'timeout';
193
- let streak = 0;
194
- while (Date.now() - t0 < opt.settleMs) {
195
- const why = await page.evaluate(() => (window.__caosGate && window.__caosGate.ready ? window.__caosGate.ready() : 'the gate library is not installed')).catch((e) => `evaluate failed: ${e.message}`);
196
- if (why === '') {
197
- streak += 1;
198
- if (streak >= 2) return '';
199
- } else {
200
- streak = 0;
201
- last = why;
202
- }
203
- await page.waitForTimeout(60);
204
- }
205
- return last;
206
- };
207
-
208
- const drain = (sink, where) => {
209
- for (const e of sink.splice(0)) fail('G3', where, e);
210
- };
211
-
212
- // Every route in every mode keeps its capture, for the person who reads them (the gate measures, it does not judge
213
- // content); only failure captures are capped, so a run with many failures does not fill the disk.
214
- const saveShot = async (page, name, failure = false) => {
215
- if (failure) {
216
- if (shots >= opt.maxShots) return;
217
- shots += 1;
218
- }
219
- await page.screenshot({ path: join(shotsDir, name) }).catch(() => undefined);
220
- };
221
-
222
- try {
223
- // ---- G4: a missing asset answers 404, not the HTML fallback.
224
- {
225
- const ctx = await browser.newContext();
226
- const res = await ctx.request.get(urlFor('/__caos_gate_missing__.json'), { maxRedirects: 0 }).catch((e) => ({ status: () => `error ${e.message}` }));
227
- if (res.status() !== 404) fail('G4', { route: '/__caos_gate_missing__.json' }, `a missing asset answered ${res.status()}, not 404 (an SPA fallback hides missing artifacts and broken deep links)`);
228
- await ctx.close();
229
- }
230
-
231
- // ---- G1: the subject, and the route list (from the header nav unless given).
232
- let routes = opt.routes ? String(opt.routes).split(',').map((r) => r.trim()).filter(Boolean) : null;
233
- {
234
- const ctx = await openContext(primarySize, themes[0], langs[0]);
235
- const { page, sink } = await newPage(ctx);
236
- await page.goto(urlFor('/'), { waitUntil: 'load', timeout: 30000 });
237
- const why = await settle(page);
238
- const id = await page.evaluate(() => window.__caosGate.identity());
239
- if (id.brand !== opt.expectBrand) {
240
- fail('G1', { route: '/' }, `the page at ${base} is "${id.brand ?? id.title}", not "${opt.expectBrand}"; nothing else is measured (a gate pointed at another product passes on it)`);
241
- await ctx.close();
242
- return finish();
243
- }
244
- if (why) fail('G7', { route: '/' }, `the landing route never settled: ${why}`);
245
- if (!routes) {
246
- const hrefs = await page.$$eval('header nav a[href]', (as) => as.map((a) => a.href));
247
- routes = [...new Set(hrefs.map(routeOf).filter(Boolean))];
248
- }
249
- sink.splice(0);
250
- await ctx.close();
251
- }
252
-
253
- // ---- The landing (shell 0.13.0): the root opens the Introduction on its executive view; an old App link with a query string reaches the App.
254
- {
255
- const ctx = await openContext(primarySize, themes[0], langs[0]);
256
- const { page, sink } = await newPage(ctx);
257
- await page.goto(urlFor('/'), { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
258
- await settle(page);
259
- const at = { route: '/', mode: 'landing' };
260
- if (routeOf(page.url()) !== '/introduction') {
261
- fail('G4', at, `the root opened ${routeOf(page.url()) ?? page.url()}, not the Introduction; every product lands on the Introduction (Landing of the shell on the root path)`);
262
- } else {
263
- const landed = await page.evaluate(() => window.__caosGate.docPage());
264
- if (!landed || landed.view !== 'executive' || !landed.executive || landed.executive.count !== 1) fail('G4', at, `the root opened the Introduction on the "${landed?.view ?? 'none'}" view; the landing opens its executive view`);
265
- }
266
- const old = await page.goto(`${urlFor('/')}?gate=landing#frag`, { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
267
- await settle(page);
268
- const u = new URL(page.url());
269
- if (routeOf(page.url()) !== workbenchRoute || u.searchParams.get('gate') !== 'landing' || u.hash !== '#frag') {
270
- fail('G4', { route: '/', mode: 'landing' }, `an old App link /?gate=landing#frag reached ${routeOf(page.url())}${u.search}${u.hash}; it must reach ${workbenchRoute} with its query string and hash kept (Landing keeps the query string and the hash)`);
271
- }
272
- void old;
273
- sink.splice(0);
274
- await ctx.close();
275
- }
276
-
277
- // ---- The walk: every mode, every route, every tab, every case (G2 to G9 and the ADR measures).
278
- for (const size of sizes) {
279
- for (const theme of themes) {
280
- for (const lang of langs) {
281
- const [w, h] = size;
282
- const mode = `${w}x${h} ${theme} ${lang}`;
283
- const primary = size === primarySize && theme === themes[0] && lang === langs[0];
284
- const idleHere = theme === themes[0] && lang === langs[0];
285
- const ctx = await openContext(size, theme, lang);
286
- const { page, sink } = await newPage(ctx);
287
- for (const route of routes) {
288
- const isWorkbench = workbenchRoutes.has(route);
289
- const at = { route, mode };
290
- // G13 runs in the first language; the documentation routes (the prose a reader reads) in every language
291
- const contrast = size === primarySize && (lang === langs[0] || DOC_ROUTES.includes(route));
292
- const res = await page.goto(urlFor(route), { waitUntil: 'load', timeout: 30000 }).catch((e) => e);
293
- const status = res && typeof res.status === 'function' ? res.status() : `error ${res?.message ?? res}`;
294
- if (status !== 200) {
295
- fail('G4', at, `loaded directly, ${urlFor(route)} answered ${status}`);
296
- drain(sink, at);
297
- continue;
298
- }
299
- const why = await settle(page);
300
- if (why) fail('G7', at, `never settled: ${why}`);
301
- const id = await page.evaluate(() => window.__caosGate.identity());
302
- if (id.brand !== opt.expectBrand) {
303
- fail('G1', at, `the route renders "${id.brand ?? id.title}", not "${opt.expectBrand}"`);
304
- drain(sink, at);
305
- continue;
306
- }
307
- if (id.theme !== theme) fail('G2', at, `asked for the ${theme} theme through ${THEME_STORAGE_KEY}; the document has data-theme=${id.theme}`);
308
- if (!String(id.lang ?? '').startsWith(lang)) fail('G2', at, `asked for ${lang} through ${LANG_STORAGE_KEY}; <html lang> is ${id.lang}`);
309
- if (id.activeHref && routeOf(id.activeHref) !== route) fail('G4', at, `loaded directly, the page shows route ${routeOf(id.activeHref)}`);
310
-
311
- // ADR-0017 2.1: the documentation floors are measured once per language, at the primary size and first theme
312
- const docFloors = size === primarySize && theme === themes[0] && DOC_ROUTES.includes(route);
313
- const walkState = { tested: new Set(), keyedViews: 0, controls: 0, tablists: 0, doc: docFloors ? [] : null };
314
- await processState(page, sink, { ...at, trail: '' }, { route, isWorkbench, primary, idle: idleHere, first: true, size, w, h, theme, lang, walkState, contrast });
315
-
316
- if (primary) {
317
- // G4: the trailing-slash form a static host serves for a materialised route.
318
- const slash = `${urlFor(route)}/`;
319
- const r2 = await page.goto(slash, { waitUntil: 'load', timeout: 30000 }).catch((e) => e);
320
- const s2 = r2 && typeof r2.status === 'function' ? r2.status() : `error ${r2?.message ?? r2}`;
321
- if (s2 !== 200) fail('G4', at, `${slash} answered ${s2}`);
322
- else {
323
- await settle(page);
324
- const id2 = await page.evaluate(() => window.__caosGate.identity());
325
- if (id2.brand !== opt.expectBrand) fail('G4', at, `${slash} renders "${id2.brand ?? id2.title}"`);
326
- else if (id2.activeHref && routeOf(id2.activeHref) !== route) fail('G4', at, `${slash} shows route ${routeOf(id2.activeHref)}`);
327
- }
328
- drain(sink, { ...at, trail: 'trailing slash' });
329
- await page.goto(urlFor(route), { waitUntil: 'load', timeout: 30000 });
330
- await settle(page);
331
- sink.splice(0);
332
- }
333
-
334
- const leaves = await walkTabs(page, sink, { ...at }, [], new Set(), { route, isWorkbench, primary, idle: primary, size, w, h, theme, lang, walkState, contrast });
335
- if (walkState.doc) docResult(at, lang, walkState.doc, Math.max(1, leaves));
336
-
337
- if (isWorkbench) await walkCases(page, sink, at, { route, isWorkbench, primary, size, w, h, theme, lang, walkState, contrast });
338
-
339
- if (primary && isWorkbench) {
340
- const f = await page.evaluate(() => window.__caosGate.facts());
341
- if (!f.workbench) fail('G9', at, 'no [data-case-workbench] on a workbench route');
342
- else if (!f.workbench.replayOnly) {
343
- if (walkState.controls === 0) fail('G9', at, 'the workbench has no registered control ([data-control]) and does not declare itself replay-only');
344
- if (walkState.keyedViews === 0) fail('G9', at, 'no view declares the selection key it shows (PlotCard or Readout dataKey); reactivity cannot be verified');
345
- }
346
- if (walkState.tablists === 0) fail('G7', at, 'the workbench shows no tab list to walk');
347
- }
348
- }
349
-
350
- if (primary) {
351
- // ADR-0071 rule 9 / G5: every header route reached by a real pointer click from the App route.
352
- for (const route of routes.filter((r) => r !== workbenchRoute)) {
353
- await page.goto(urlFor(workbenchRoute), { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
354
- await settle(page);
355
- const link = page.locator('header nav a[href]');
356
- const hrefs = await link.evaluateAll((as) => as.map((a) => a.href));
357
- const idx = hrefs.findIndex((href) => routeOf(href) === route);
358
- let ok = false;
359
- if (idx >= 0) {
360
- // A reader scrolls a narrow nav row to the link first; so does the gate, then clicks with the pointer.
361
- // A link that does not answer in time is a failure of this route, never an exception that ends the
362
- // whole run (a locator waited its default 30 s under load and aborted the gate, 0.9.0 self-test).
363
- await link.nth(idx).evaluate((el) => window.__caosGate.bringEl(el), undefined, { timeout: 10000 }).catch(() => undefined);
364
- const box = await link.nth(idx).boundingBox({ timeout: 10000 }).catch(() => null);
365
- if (box) {
366
- await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
367
- await page.waitForURL((u) => routeOf(u.href) === route, { timeout: 3000 }).catch(() => undefined);
368
- ok = routeOf(page.url()) === route;
369
- }
370
- }
371
- if (!ok) fail('G5', { route, mode }, 'not reachable by a pointer click on the header link from the App route');
372
- sink.splice(0);
373
- }
374
- }
375
- await ctx.close();
376
- }
377
- }
378
- }
379
-
380
- // ---- G10: the wide-font pass. Every route, tab and sampled case again, in Spanish (the longer labels) when it is
381
- // measured, rendered in the wide fallback font from the first frame: what a reader with wider fonts sees.
382
- if (opt.wideFont !== false && opt.wideFontSizes) {
383
- const wideSizes = String(opt.wideFontSizes).split(',').map((x) => x.trim()).filter(Boolean).map((x) => x.split('x').map(Number));
384
- const wlang = langs.includes('es') ? 'es' : langs[0];
385
- const theme = themes[0];
386
- for (const size of wideSizes) {
387
- const [w, h] = size;
388
- const mode = `${w}x${h} ${theme} ${wlang} wide-font`;
389
- const ctx = await openContext(size, theme, wlang, true);
390
- const { page, sink } = await newPage(ctx);
391
- for (const route of routes) {
392
- const isWorkbench = workbenchRoutes.has(route);
393
- const at = { route, mode };
394
- const res = await page.goto(urlFor(route), { waitUntil: 'load', timeout: 30000 }).catch((e) => e);
395
- const status = res && typeof res.status === 'function' ? res.status() : `error ${res?.message ?? res}`;
396
- if (status !== 200) {
397
- fail('G4', at, `loaded directly, ${urlFor(route)} answered ${status}`);
398
- drain(sink, at);
399
- continue;
400
- }
401
- const why = await settle(page);
402
- if (why) fail('G7', at, `never settled: ${why}`);
403
- const walkState = { tested: new Set(), keyedViews: 0, controls: 0, tablists: 0 };
404
- const s = { route, isWorkbench, primary: false, idle: false, size, w, h, theme, lang: wlang, walkState, wide: true };
405
- await processState(page, sink, { ...at, trail: '' }, { ...s, first: true });
406
- await walkTabs(page, sink, { ...at }, [], new Set(), s);
407
- if (isWorkbench) await walkCases(page, sink, at, s);
408
- }
409
- await ctx.close();
410
- }
411
- }
412
-
413
- // ---- G4: artifacts answered as JSON.
414
- for (const [pathname, first] of artifacts) {
415
- const c = await first;
416
- if (c !== '{' && c !== '[') fail('G4', { route: pathname }, `the artifact ${pathname} did not answer JSON (starts with "${c}"; the HTML fallback?)`);
417
- }
418
- } finally {
419
- await browser.close();
420
- if (server) await server.close();
421
- }
422
- return finish();
423
-
424
- // ------------------------------------------------------------------------------------------------------------
425
- async function processState(page, sink, where, s) {
426
- states += 1;
427
- // On a narrow screen the instrument stacks under the rail; a reader scrolls to the open view, and so does the gate.
428
- if (s.isWorkbench && s.w < opt.viewportMinWidth) {
429
- await page.evaluate(() => window.__caosGate.bring('[data-case-workbench] [role="tabpanel"]')).catch(() => undefined);
430
- }
431
- const f = await page.evaluate(() => window.__caosGate.facts());
432
- const big = s.w >= opt.viewportMinWidth;
433
- if (f.docW > f.vw + 1) fail('G5', where, `horizontal overflow: the document is ${f.docW}px wide in a ${f.vw}px viewport`);
434
- if (f.beyondCount) fail('G5', where, `${f.beyondCount} element(s) outside the viewport: ${f.beyond.join('; ')}`);
435
- if (big && f.docH > f.vh + 1) fail('ADR-0071.1', where, `the document scrolls: ${f.docH}px in a ${f.vh}px viewport (the page is the viewport)`);
436
- for (const r of f.rows) if (r.rows > 1) fail('ADR-0071.4', where, `tab row "${r.name}" wraps onto ${r.rows} rows`);
437
- for (const r of f.rows) if (r.cut > 1) fail('G5', where, `tab row "${r.name}" cuts its tabs by ${r.cut}px (a row shrunk under its panel)`);
438
- if (f.rail) {
439
- if (big && f.rail.scroll > f.rail.client + 1) fail('ADR-0071.6', where, `the rail scrolls: ${f.rail.scroll}px of content in ${f.rail.client}px`);
440
- if (f.rail.outsideCount) fail('G5', where, `${f.rail.outsideCount} rail element(s) outside the rail: ${f.rail.outside.join('; ')}`);
441
- }
442
- if (f.truncatedCount) fail('G5', where, `${f.truncatedCount} truncated text(s) without the full text in a title: ${f.truncated.join('; ')}`);
443
- for (const c of f.clipped) fail('G5', where, c);
444
- if (f.offCentre !== null && f.offCentre > 2) fail('ADR-0017.1', where, `the prose page is off-centre by ${f.offCentre.toFixed(1)}px`);
445
- if (f.referenceLists) fail('ADR-0017.4', where, `${f.referenceLists} bibliography outside the end-of-page references block (a .reference-list); give each DocSection its refs and let the page list them once`);
446
- if (f.referencesNotLast) fail('ADR-0017.4', where, `${f.referencesNotLast} references block(s) with content after them; references must be the last block of the page or tab`);
447
- if (f.refsDeclaredNoBlock) fail('ADR-0017.4', where, `${f.refsDeclaredNoBlock} section(s) cite references the page never lists (no references block in their page or tab)`);
448
- if (f.uncaptioned) fail('ADR-0017.2', where, `${f.uncaptioned} equation(s) without a caption`);
449
- if (s.walkState?.doc) {
450
- const d = await page.evaluate(() => window.__caosGate.docPage());
451
- if (d) s.walkState.doc.push(d);
452
- }
453
- for (const r of f.repeatedTicks ?? []) {
454
- fail('G6', where, `${r.label}: ${r.n} axis tick label(s) repeat the label before them (give the axis fixed decimals)`);
455
- }
456
- // a filling card whose note leaves its drawing under the floor (CAOS_APP_SHELL#95); below 1280 px the document scrolls
457
- if (big) for (const c of f.fillStages ?? []) if (c.h < opt.stageFloor) fail('G6', where, `the drawing of ${c.label} keeps ${Math.round(c.h)} px under its note (floor ${opt.stageFloor} px)`);
458
- for (const m of await page.evaluate(() => window.__caosGate.drawingText())) fail('G10', where, m);
459
- if (s.lang === 'es') for (const m of await page.evaluate(() => window.__caosGate.decimals())) fail('G11', where, m);
460
- if (s.w > 760) for (const m of await page.evaluate(() => window.__caosGate.sticky())) fail('G12', where, m);
461
- if (s.contrast) for (const m of await page.evaluate(() => window.__caosGate.contrast())) fail('G13', where, m);
462
- if (f.sectionsWithoutRefs) fail('ADR-0017.4', where, `${f.sectionsWithoutRefs} section(s) end without references or a stated reason`);
463
- if (s.first && !s.isWorkbench && f.pageText < opt.textFloor) fail('G4', where, `the page body holds ${f.pageText} characters of text (floor ${opt.textFloor}); the route rendered without its content`);
464
-
465
- if (s.isWorkbench) {
466
- if (!f.instrument) fail('G6', where, 'no [data-instrument] on a workbench route');
467
- else await paintedChecks(page, f, where, big);
468
- if (f.workbench && s.walkState) {
469
- const keyed = (await page.evaluate(() => window.__caosGate.views())).filter((v) => v.key);
470
- s.walkState.keyedViews += keyed.length;
471
- }
472
- }
473
-
474
- if (s.first) await saveShot(page, `${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}${s.wide ? '_wide' : ''}.png`);
475
-
476
- if (s.idle) {
477
- const idle = await page.evaluate((ms) => window.__caosGate.idle(ms), opt.idleMs);
478
- const secs = idle.ms / 1000;
479
- if (idle.raf / secs > RATE_LIMIT) fail('G8', where, `${idle.raf} animation frames in ${secs}s at rest (a loop runs with nothing to show)`);
480
- if (idle.mutations / secs > RATE_LIMIT) fail('G8', where, `${idle.mutations} DOM mutations in ${secs}s at rest: ${idle.sample.join('; ')}`);
481
- }
482
-
483
- for (const m of await page.evaluate(() => window.__caosGate.scrolls())) fail('G5', where, m);
484
- const reach = await page.evaluate(() => window.__caosGate.reach());
485
- if (reach.unreachableCount) fail('G5', where, `${reach.unreachableCount} control(s) not reachable by the pointer: ${reach.unreachable.join('; ')}`);
486
-
487
- if (s.primary && s.isWorkbench && s.walkState) await reactivity(page, sink, where, s);
488
- drain(sink, where);
489
- const failedHere = failures.some((x) => x.route === where.route && x.mode === where.mode && x.trail === where.trail && x.check !== 'G3');
490
- if (failedHere) await saveShot(page, `fail_${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}${s.wide ? '_wide' : ''}_${shots + 1}.png`, true);
491
- }
492
-
493
- async function paintedChecks(page, f, where, big) {
494
- if (f.prose) return; // the context write-up is prose, measured as a document, not as a drawing
495
- if (f.surfaces.length === 0 && f.unavailable) {
496
- // a declared absence a reader can see and read (ADR-0080 decision 3): nothing to draw, every other check still ran
497
- if (f.drawn > 0) fail('G6', where, `a drawing is present but not visible beside a data-state="unavailable" note (${f.drawn} drawing element(s) rendered in the instrument, collapsed, hidden or too small to see): draw it, or remove it`);
498
- else if (f.unavailable === 'unexplained') fail('G6', where, 'the instrument declares data-state="unavailable" and draws nothing, but gives no reason (the text a reader sees needs at least 12 letters or digits in two words or more, saying why the data is absent)');
499
- else if (f.unavailable === 'unseen') fail('G6', where, 'the instrument declares data-state="unavailable" and draws nothing, but no reader can see the note (it needs a visible box of at least 40 x 12 px inside the instrument, not transparent, not clipped, not off-screen)');
500
- return;
501
- }
502
- if (f.surfaces.length === 0) {
503
- fail('G6', where, 'nothing is drawn in the instrument (no canvas, svg drawing, image or table)');
504
- return;
505
- }
506
- const clip = { x: Math.floor(f.instrument.x), y: Math.floor(f.instrument.y), width: Math.max(1, Math.floor(f.instrument.w)), height: Math.max(1, Math.floor(f.instrument.h)) };
507
- let img;
508
- try {
509
- img = decodePng(await page.screenshot({ clip }));
510
- } catch (e) {
511
- fail('G6', where, `the instrument could not be captured: ${e.message}`);
512
- return;
513
- }
514
- // Per stage: the painted extent of its drawings (union of their painted boxes), judged against the stage.
515
- const stages = new Map();
516
- for (const s of f.surfaces) {
517
- if (s.right > f.vw + 1) fail('G6', where, `${s.label} extends to ${Math.round(s.right)}px, past the ${f.vw}px page`);
518
- const st = stages.get(s.stageKey) ?? { label: s.label, kinds: new Set(), stage: s.stage, view: s.view, painted: [] };
519
- st.kinds.add(s.kind);
520
- const box = paintedBox(img, { x: s.x - clip.x, y: s.y - clip.y, w: s.w, h: s.h });
521
- if (box) st.painted.push({ x: box.x + clip.x, y: box.y + clip.y, w: box.w, h: box.h });
522
- stages.set(s.stageKey, st);
523
- }
524
- const counted = [];
525
- for (const st of stages.values()) {
526
- if (st.painted.length === 0) {
527
- fail('G6', where, `${st.label} (${[...st.kinds].join(', ')}, ${Math.round(st.stage.w)}x${Math.round(st.stage.h)}) is blank: nothing is painted on it`);
528
- continue;
529
- }
530
- const x0 = Math.min(...st.painted.map((b) => b.x));
531
- const y0 = Math.min(...st.painted.map((b) => b.y));
532
- const x1 = Math.max(...st.painted.map((b) => b.x + b.w));
533
- const y1 = Math.max(...st.painted.map((b) => b.y + b.h));
534
- const fill = ((x1 - x0) * (y1 - y0)) / Math.max(1, st.stage.w * st.stage.h);
535
- if (big) low('stageFill', fill, where);
536
- if (fill < opt.stageFillMin) {
537
- if (big) fail('G6', where, `${st.label} draws on ${(100 * fill).toFixed(0)}% of its stage (floor ${(100 * opt.stageFillMin).toFixed(0)}%)`);
538
- continue;
539
- }
540
- counted.push(st.view);
541
- }
542
- if (big) {
543
- // The instrument share counts only the views whose drawing passed: empty instrument space, and the cards of
544
- // blank or underfilled drawings, never count (ADR-0071 rule 8, measured on what is drawn).
545
- const share = unionArea(counted, f.vw, f.vh) / (f.vw * f.vh);
546
- low('drawnShare', share, where);
547
- if (share < opt.instrumentMin) fail('G6', where, `the drawn views cover ${(100 * share).toFixed(1)}% of the viewport (floor ${(100 * opt.instrumentMin).toFixed(0)}% on the App route, ADR-0071 rule 8)`);
548
- }
549
- }
550
-
551
- async function reactivity(page, sink, where, s) {
552
- const controls = await page.evaluate(() => window.__caosGate.controls());
553
- s.walkState.controls += controls.length;
554
- for (const c of controls) {
555
- if (s.walkState.tested.has(c.id)) continue;
556
- s.walkState.tested.add(c.id);
557
- const at = { ...where, trail: `${where.trail ? `${where.trail} > ` : ''}control ${c.id}` };
558
- const before = await workbenchKey(page);
559
- const changed = await changeControl(page, c).catch((e) => `error: ${e.message.split('\n')[0]}`);
560
- if (changed !== true) {
561
- fail('G9', at, `control "${c.id}" (${c.kind}) could not be changed: ${changed || 'no alternative value'}`);
562
- continue;
563
- }
564
- const why = await settle(page);
565
- if (why) {
566
- fail('G9', at, `after changing "${c.id}" the views did not settle: ${why}`);
567
- continue;
568
- }
569
- const after = await workbenchKey(page);
570
- if (after === before) fail('G9', at, `control "${c.id}" did not change the selection key (data-state-key stayed ${before})`);
571
- const old = (await page.evaluate(() => window.__caosGate.views())).filter((v) => v.key && v.key !== after);
572
- if (old.length) fail('G9', at, `${old.length} view(s) keep an earlier selection key after "${c.id}" changed: ${old.slice(0, 3).map((v) => v.label).join('; ')}`);
573
- drain(sink, at);
574
- }
575
- }
576
-
577
- /** Walks every tab of every list owned by `trail`; returns the number of leaf topics found (a tab with no list inside it is one; a list inside a documentation section adds none). */
578
- async function walkTabs(page, sink, where, trail, handled, s) {
579
- let leaves = 0;
580
- const lists = (await page.evaluate(() => window.__caosGate.tablists())).filter((l) => !handled.has(l.key) && !l.source);
581
- const parentKey = trail.length ? trail[trail.length - 1].key : null;
582
- const mine = lists.filter((l) => (parentKey ? l.owner === parentKey : l.owner === null));
583
- for (const tl of mine) {
584
- handled.add(tl.key);
585
- s.walkState.tablists += 1;
586
- for (let i = 0; i < tl.tabs.length; i += 1) {
587
- const label = tl.tabs[i];
588
- const here = [...trail, { key: tl.key, label }];
589
- const at = { ...where, trail: [...(where.case ? [`case ${where.case}`] : []), ...here.map((t) => t.label)].join(' > ') };
590
- if (i !== tl.active) {
591
- const tab = page.locator(tl.key).locator('[role="tab"]').filter({ visible: true }).nth(i);
592
- try {
593
- // a reader scrolls the tab clear of the sticky header first; so does the gate, then clicks it
594
- await tab.evaluate((el) => window.__caosGate.bringEl(el)).catch(() => undefined);
595
- await tab.click({ timeout: 5000 });
596
- } catch (e) {
597
- fail('G7', at, `the tab could not be clicked: ${e.message.split('\n')[0]}`);
598
- if (!tl.inSection) leaves += 1;
599
- continue;
600
- }
601
- const why = await settle(page);
602
- if (why) fail('G7', at, `never settled: ${why}`);
603
- await processState(page, sink, at, { ...s, first: false, idle: s.idle });
604
- }
605
- const inner = await walkTabs(page, sink, where, here, new Set(handled), s);
606
- // the tabs of a list inside a documentation section are visited, but they are parts of that section, not topics
607
- if (!tl.inSection) leaves += inner === 0 ? 1 : inner;
608
- }
609
- if (tl.active >= 0 && tl.tabs.length > 1) {
610
- await page.locator(tl.key).locator('[role="tab"]').filter({ visible: true }).nth(tl.active).click({ timeout: 5000 }).catch(() => undefined);
611
- await settle(page);
612
- sink.splice(0);
613
- }
614
- }
615
- return leaves;
616
- }
617
-
618
- /** ADR-0017 2.1: applies the area table (gate/areas.mjs) to what a documentation page showed in one language. */
619
- function docResult(at, lang, states, expectedTopics) {
620
- const r = docFloorFailures({ route: at.route, lang, states, expectedTopics });
621
- for (const f of r.failures) fail('ADR-0017.2', { ...at, trail: f.trail }, f.message);
622
- for (const w of r.waivers) waivers.push({ route: at.route, lang, ...w });
623
- }
624
-
625
- async function walkCases(page, sink, at, s) {
626
- const sources = (await page.evaluate(() => window.__caosGate.tablists())).find((l) => l.source);
627
- const sourceTabs = sources ? sources.tabs.map((_, i) => i) : [null];
628
- let total = 0;
629
- const sampleSet = opt.caseSample ? new Set(String(opt.caseSample).split(',').map((x) => x.trim())) : null;
630
- for (const si of sourceTabs) {
631
- if (si !== null && si !== sources.active) {
632
- await page.locator(sources.key).locator('[role="tab"]').nth(si).click({ timeout: 5000 }).catch(() => undefined);
633
- await settle(page);
634
- }
635
- const { kind, ids } = await page.evaluate(() => window.__caosGate.cases());
636
- total += ids.length;
637
- const pick = s.primary ? ids : sampleSet ? ids.filter((x) => sampleSet.has(x)) : [...new Set([ids[0], ids[ids.length - 1]])].filter(Boolean);
638
- for (const caseId of pick) {
639
- const where = { ...at, case: caseId, trail: `case ${caseId}` };
640
- try {
641
- if (kind === 'select') await page.locator('select[data-control="case"]').filter({ visible: true }).first().selectOption(caseId, { timeout: 5000 });
642
- else await page.locator(`[data-control="case"] [data-case="${caseId}"]`).filter({ visible: true }).first().click({ timeout: 5000 });
643
- } catch (e) {
644
- fail('G7', where, `the case could not be selected: ${e.message.split('\n')[0]}`);
645
- continue;
646
- }
647
- const why = await settle(page);
648
- if (why) fail('G7', where, `never settled: ${why}`);
649
- const shownCase = await page.evaluate(() => document.querySelector('[data-case-workbench]')?.getAttribute('data-case') ?? null);
650
- if (shownCase !== caseId) fail('G9', where, `asked for case ${caseId}; the workbench shows ${shownCase}`);
651
- await processState(page, sink, where, { ...s, first: false, idle: false });
652
- if (s.primary) await walkTabs(page, sink, where, [], new Set(), { ...s, idle: false });
653
- }
654
- if (s.primary && pick.length) {
655
- // A deep link selects the case it names (G9).
656
- const deep = pick[pick.length - 1];
657
- await page.goto(`${urlFor(s.route)}?case=${encodeURIComponent(deep)}`, { waitUntil: 'load', timeout: 30000 });
658
- await settle(page);
659
- const shownCase = await page.evaluate(() => document.querySelector('[data-case-workbench]')?.getAttribute('data-case') ?? null);
660
- if (shownCase !== deep) fail('G9', { ...at, trail: `?case=${deep}` }, `the deep link ?case=${deep} shows case ${shownCase}`);
661
- drain(sink, { ...at, trail: `?case=${deep}` });
662
- await page.goto(urlFor(s.route), { waitUntil: 'load', timeout: 30000 });
663
- await settle(page);
664
- sink.splice(0);
665
- }
666
- }
667
- if (total === 0 && !opt.singleCase) fail('G9', at, 'the workbench offers no case ([data-control="case"]); declare --single-case if it has one');
668
- }
669
-
670
- async function workbenchKey(page) {
671
- return page.evaluate(() => document.querySelector('[data-case-workbench]')?.getAttribute('data-state-key') ?? null);
672
- }
673
-
674
- async function changeControl(page, c) {
675
- const host = page.locator(c.path);
676
- if (c.kind === 'range') {
677
- const input = (await host.evaluate((e) => e.matches('input'))) ? host : host.locator('input[type="range"]').first();
678
- const v = await input.evaluate((e) => ({ value: Number(e.value), max: e.max === '' ? 100 : Number(e.max) }));
679
- await input.focus();
680
- await page.keyboard.press(v.value >= v.max ? 'ArrowLeft' : 'ArrowRight');
681
- return true;
682
- }
683
- if (c.kind === 'select') {
684
- const sel = (await host.evaluate((e) => e.tagName === 'SELECT')) ? host : host.locator('select').first();
685
- const idx = await sel.evaluate((e) => [...e.options].findIndex((o, i) => i !== e.selectedIndex && !o.disabled));
686
- if (idx < 0) return 'no other option';
687
- await sel.selectOption({ index: idx });
688
- return true;
689
- }
690
- if (c.kind === 'check') {
691
- await host.locator('input[type="checkbox"], input[type="radio"]:not(:checked)').first().click({ timeout: 5000 });
692
- return true;
693
- }
694
- if (c.kind === 'number') {
695
- const input = host.locator('input[type="number"]').first();
696
- const v = await input.evaluate((e) => ({ value: Number(e.value), step: Number(e.step) || 1 }));
697
- await input.fill(String(v.value + v.step));
698
- await input.press('Enter');
699
- return true;
700
- }
701
- if (c.kind === 'buttons') {
702
- const buttons = host.locator('button');
703
- const n = await buttons.count();
704
- for (let i = 0; i < n; i += 1) {
705
- const b = buttons.nth(i);
706
- const on = await b.evaluate(
707
- (e) => e.disabled || e.classList.contains('on') || e.classList.contains('active') || ['aria-pressed', 'aria-checked', 'aria-selected'].some((a) => e.getAttribute(a) === 'true'),
708
- );
709
- if (!on) {
710
- await b.click({ timeout: 5000 });
711
- return true;
712
- }
713
- }
714
- return 'every option is already selected';
715
- }
716
- return `unknown control kind (${c.kind})`;
717
- }
718
-
719
- function finish() {
720
- const checks = {};
721
- for (const f of failures) checks[f.check] = (checks[f.check] ?? 0) + 1;
722
- mkdirSync(opt.out, { recursive: true });
723
- const captures = existsSync(shotsDir) ? readdirSync(shotsDir).filter((n) => n.endsWith('.png')).sort() : [];
724
- const report = {
725
- ok: failures.length === 0,
726
- base,
727
- expectBrand: opt.expectBrand,
728
- sizes: opt.sizes,
729
- themes: opt.themes,
730
- langs: opt.langs,
731
- wideFontSizes: opt.wideFont === false ? '' : opt.wideFontSizes,
732
- states,
733
- minima,
734
- waivers,
735
- checks,
736
- captures: captures.length,
737
- failures,
738
- };
739
- writeFileSync(join(opt.out, 'gate-report.json'), JSON.stringify(report, null, 2));
740
- // G14: the captures are for a person to read (the gate measures, it does not judge content); one page shows them.
741
- writeFileSync(join(opt.out, 'index.html'), captureIndex(report, captures));
742
- return report;
743
- }
744
- }
745
-
746
- const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
747
-
748
- /** G14: one page with every capture, grouped by route, the failure captures and the failure list first. */
749
- export function captureIndex(report, captures) {
750
- const groups = new Map();
751
- for (const name of captures) {
752
- const key = name.startsWith('fail_') ? 'failures' : name.split('_')[0];
753
- if (!groups.has(key)) groups.set(key, []);
754
- groups.get(key).push(name);
755
- }
756
- const order = ['failures', ...[...groups.keys()].filter((k) => k !== 'failures')];
757
- const rows = report.failures
758
- .slice(0, 400)
759
- .map((f) => `<tr><td>${esc(f.check)}</td><td>${esc(f.route)}</td><td>${esc(f.mode)}</td><td>${esc(f.trail)}</td><td>${esc(f.message)}</td></tr>`)
760
- .join('');
761
- const figure = (n) => `<figure><a href="shots/${esc(n)}"><img loading="lazy" src="shots/${esc(n)}" alt="${esc(n)}"></a><figcaption>${esc(n.replace(/\.png$/, ''))}</figcaption></figure>`;
762
- const sections = order
763
- .filter((k) => groups.has(k))
764
- .map((k) => `<h2>${esc(k)} (${groups.get(k).length})</h2><div class="grid">${groups.get(k).map(figure).join('')}</div>`)
765
- .join('');
766
- const style =
767
- 'body{font:14px system-ui,sans-serif;margin:16px;background:#f6f8fa;color:#1f2328}h1{font-size:18px}h2{font-size:15px;margin-top:24px}' +
768
- 'table{border-collapse:collapse;font-size:12px}td{border-bottom:1px solid #d0d7de;padding:3px 6px;vertical-align:top}' +
769
- '.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px}figure{margin:0;background:#fff;border:1px solid #d0d7de;padding:6px}' +
770
- 'img{width:100%;height:auto;display:block}figcaption{font-size:11px;color:#57606a;margin-top:4px}';
771
- const head = `caos-shell-gate: ${esc(report.expectBrand)} at ${esc(report.base)}: ${report.ok ? 'OK' : `${report.failures.length} failure(s)`} in ${report.states} states, ${captures.length} captures`;
772
- const wrows = (report.waivers ?? []).map((w) => `<tr><td>${esc(w.route)}</td><td>${esc(w.lang)}</td><td>${esc(w.floor)}</td><td>${esc(w.reason)}</td><td>${w.valid ? 'accepted' : 'refused'}</td></tr>`).join('');
773
- const waiverTable = wrows ? `<h2>Waivers (${report.waivers.length})</h2><table><tr><td>route</td><td>language</td><td>floor</td><td>reason</td><td>verdict</td></tr>${wrows}</table>` : '';
774
- const table = rows ? `<table><tr><td>check</td><td>route</td><td>mode</td><td>trail</td><td>message</td></tr>${rows}</table>` : '';
775
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${head}</title><style>${style}</style></head><body><h1>${head}</h1>${table}${waiverTable}${sections}</body></html>\n`;
776
- }
777
-
778
- function slug(route) {
779
- return route === '/' ? 'landing' : route.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '');
780
- }
1
+ // caos-shell-gate runner: measures a built CAOS product in a real browser and fails on what a reader would meet.
2
+ // Every check embeds a trap recorded in the 2026-10-04 failure history (failure class 10: gates that measured the
3
+ // wrong thing); the self-test in this repository plants one defect per check and requires each to fail (G15).
4
+ //
5
+ // G1 subject identity the brand must match --expect-brand; a check that finds no subject fails
6
+ // G2 mode identity theme and language set with the shell's own storage keys, then read back
7
+ // G3 errors console.error, page errors, any GET answered 400 or above
8
+ // G4 deep links every route loaded directly (and with a trailing slash), 200 and rendered; artifacts
9
+ // are JSON, not the HTML fallback; a missing asset answers 404; doc text clears a floor
10
+ // G5 scroll and reach boxes inside the viewport, the rail and their scrollers; tall views scroll; nothing is
11
+ // cut; truncated text carries its full text; every control reachable by the pointer
12
+ // G6 painted area drawing surfaces have painted pixels, fill their stage, and cover at least half the
13
+ // viewport on the App route, measured on the screenshot, never on a host box
14
+ // G7 walk everything every route, tab, sub-tab and case, at five sizes, both themes, both languages,
15
+ // waiting for declared state (data-state, data-stale, data-drawn), never networkidle
16
+ // G8 idle at rest no sustained animation frames or DOM mutations after load and after a tab switch
17
+ // G9 reactivity each registered control changes the selection key and no view keeps an old one; the
18
+ // rendered case is the case asked for; a disabled control is not moved
19
+ // G10 text in drawings every label of a chart, an instrument SVG or a document figure inside the drawing and
20
+ // clear of the others, also when every page is rendered in a wider fallback font (the
21
+ // wide-font pass: Verdana, else DejaVu Sans, the Linux runner's font)
22
+ // G11 Spanish numbers a page in Spanish writes no decimal point (canvas text excepted)
23
+ // G12 sticky lists a vertical sub-tab list is still in view at the end of a long section
24
+ // G13 contrast every visible text at WCAG AA against the colour behind it (primary size, both themes; first language, and every language on a documentation route)
25
+ // G14 captures index gate-output/index.html shows every capture by route and mode, the failures first
26
+ // G16 flip cards, decks a flip card is a small card (title and back within the limits), a real button that turns with Enter and
27
+ // Space, its hidden face out of the accessibility tree; a carousel reaches every card, never scrolls sideways
28
+ // G17 animated drawings an animated SVG has its reduced-motion block, plays for 10 s at most and is still under reduced motion
29
+ // G18 Focus mode the App's Focus control opens the case's open group in the full viewport and returns to the same case and
30
+ // group (by the exit control and by Escape); no page scroll, the rail without scroll, one tab row
31
+ // plus the ADR-0071 and ADR-0017 measures (viewport, one-row tabs, rail without scroll, centred prose, captions,
32
+ // references).
33
+
34
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
35
+ import { join } from 'node:path';
36
+ import { decodePng, paintedBox, unionArea } from './png.mjs';
37
+ import { servePages } from './serve.mjs';
38
+ import { installLib } from './inpage.mjs';
39
+ import { DOC_ROUTES, FLIP_LIMITS, analyseSvgAnimation, animationFailures, docFloorFailures } from './areas.mjs';
40
+
41
+ export const DEFAULTS = {
42
+ sizes: '390x844,768x1024,1280x800,1600x900,2560x1440',
43
+ themes: 'light,dark',
44
+ langs: 'en,es',
45
+ workbench: '/app',
46
+ idleMs: 3000,
47
+ settleMs: 15000,
48
+ textFloor: 400,
49
+ instrumentMin: 0.5,
50
+ stageFillMin: 0.3,
51
+ stageFloor: 140,
52
+ viewportMinWidth: 1280,
53
+ out: 'gate-output',
54
+ maxShots: 40,
55
+ wideFont: true,
56
+ wideFontSizes: '390x844,1280x800',
57
+ requireFocus: false,
58
+ };
59
+
60
+ /** The wide fallback font of the G10 pass: wider than the Windows and macOS sans fonts; DejaVu Sans is what a Linux
61
+ * reader (and the CI runner) gets. Applied before the first frame, so a drawing that measures its text measures it. */
62
+ const WIDE_FONT = ':root, :root[data-theme] { --font-sans: Verdana, "DejaVu Sans", sans-serif !important; }';
63
+
64
+ const RATE_LIMIT = 5; // frames or mutations per second at rest above which a loop is reported (G8)
65
+
66
+ async function loadKeys() {
67
+ try {
68
+ return await import(new URL('../dist/keys.js', import.meta.url).href);
69
+ } catch {
70
+ throw new Error('caos-shell-gate: dist/keys.js is missing; the shell package is not built (npm run build)');
71
+ }
72
+ }
73
+
74
+ /**
75
+ * @param {object} o options (see bin/caos-shell-gate.mjs)
76
+ * @returns {Promise<object>} the report; `report.ok` is false on any failure
77
+ */
78
+ export async function runGate(o) {
79
+ const opt = { ...DEFAULTS, ...o };
80
+ if (!opt.expectBrand) throw new Error('caos-shell-gate: --expect-brand is required (G1: the gate never measures an unnamed subject)');
81
+ if (!opt.url && !opt.serve) throw new Error('caos-shell-gate: --url or --serve is required (there is no default target)');
82
+ let chromium;
83
+ try {
84
+ ({ chromium } = await import('playwright'));
85
+ } catch {
86
+ throw new Error('caos-shell-gate: install playwright in this project (npm i -D playwright) to run the gate');
87
+ }
88
+ const { THEME_STORAGE_KEY, LANG_STORAGE_KEY } = await loadKeys();
89
+
90
+ const sizes = String(opt.sizes).split(',').map((s) => s.trim().split('x').map(Number));
91
+ const themes = String(opt.themes).split(',').map((s) => s.trim());
92
+ const langs = String(opt.langs).split(',').map((s) => s.trim());
93
+ const workbenchRoute = String(opt.workbench).split(',')[0].trim() || '/app';
94
+ const workbenchRoutes = new Set(String(opt.workbench).split(',').map((s) => s.trim()).filter(Boolean));
95
+ const primarySize = sizes.find(([w]) => w >= opt.viewportMinWidth) ?? sizes[0];
96
+ const shotsDir = join(opt.out, 'shots');
97
+ // Every run starts from an empty capture folder: a capture left by an earlier run is stale evidence.
98
+ rmSync(shotsDir, { recursive: true, force: true });
99
+ mkdirSync(shotsDir, { recursive: true });
100
+
101
+ let server = null;
102
+ let base = opt.url;
103
+ if (opt.serve) {
104
+ server = await servePages(opt.serve, opt.basePath ?? '/');
105
+ base = server.url;
106
+ }
107
+ base = base.endsWith('/') ? base : `${base}/`;
108
+ const basePath = new URL(base).pathname;
109
+ const urlFor = (route) => new URL(route.replace(/^\//, ''), base).href;
110
+ const routeOf = (href) => {
111
+ const p = new URL(href, base).pathname;
112
+ return p.startsWith(basePath) ? `/${p.slice(basePath.length)}`.replace(/\/$/, '') || '/' : null;
113
+ };
114
+
115
+ const failures = [];
116
+ const focusReported = new Set(); // G18: an App without a Focus control is reported once per route and mode
117
+ const waivers = []; // ADR-0017 2.1: every waiver a documentation page declares, with its verdict
118
+ // The smallest measured values against each floor, so a pass shows its margin (and a CI font change that eats it
119
+ // is visible before it fails).
120
+ const minima = { drawnShare: null, stageFill: null };
121
+ const low = (k, value, where) => {
122
+ if (minima[k] === null || value < minima[k].value) minima[k] = { value: Number(value.toFixed(4)), route: where.route, mode: where.mode, trail: where.trail };
123
+ };
124
+ let shots = 0;
125
+ let states = 0;
126
+ const fail = (check, where, message) => failures.push({ check, route: where.route ?? '', mode: where.mode ?? '', trail: where.trail ?? '', message });
127
+ const artifacts = new Map();
128
+
129
+ const browser = await chromium.launch();
130
+ const openContext = async ([w, h], theme, lang, wide = false) => {
131
+ const ctx = await browser.newContext({ viewport: { width: w, height: h }, deviceScaleFactor: 1 });
132
+ if (wide) {
133
+ await ctx.addInitScript((css) => {
134
+ const add = () => {
135
+ const st = document.createElement('style');
136
+ st.setAttribute('data-caos-gate-font', '');
137
+ st.textContent = css;
138
+ document.documentElement.appendChild(st);
139
+ };
140
+ if (document.documentElement) add();
141
+ else
142
+ new MutationObserver((_, o) => {
143
+ if (document.documentElement) {
144
+ o.disconnect();
145
+ add();
146
+ }
147
+ }).observe(document, { childList: true });
148
+ }, WIDE_FONT);
149
+ }
150
+ await ctx.addInitScript(
151
+ ([tk, lk, t, l]) => {
152
+ try {
153
+ localStorage.setItem(tk, t);
154
+ localStorage.setItem(lk, l);
155
+ } catch {
156
+ /* storage unavailable: G2 reports the mode that came back */
157
+ }
158
+ },
159
+ [THEME_STORAGE_KEY, LANG_STORAGE_KEY, theme, lang],
160
+ );
161
+ await ctx.addInitScript(installLib);
162
+ return ctx;
163
+ };
164
+
165
+ const newPage = async (ctx) => {
166
+ const page = await ctx.newPage();
167
+ const sink = [];
168
+ page.on('console', (m) => {
169
+ if (m.type() === 'error') sink.push(`console.error: ${m.text().slice(0, 300)}`);
170
+ });
171
+ page.on('pageerror', (e) => sink.push(`page error: ${String(e).slice(0, 300)}`));
172
+ page.on('response', (r) => {
173
+ const req = r.request();
174
+ if (req.method() !== 'GET') return;
175
+ if (r.status() >= 400) sink.push(`HTTP ${r.status()} for ${r.url()}`);
176
+ const type = req.resourceType();
177
+ let pathname = '';
178
+ try {
179
+ pathname = new URL(r.url()).pathname;
180
+ } catch {
181
+ /* not a URL */
182
+ }
183
+ if ((type === 'fetch' || type === 'xhr') && pathname.endsWith('.json') && r.status() < 400 && !artifacts.has(pathname)) {
184
+ artifacts.set(
185
+ pathname,
186
+ r.text().then(
187
+ (t) => t.trimStart().slice(0, 1),
188
+ () => '',
189
+ ),
190
+ );
191
+ }
192
+ });
193
+ return { page, sink };
194
+ };
195
+
196
+ const settle = async (page) => {
197
+ await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))))).catch(() => undefined);
198
+ const t0 = Date.now();
199
+ let last = 'timeout';
200
+ let streak = 0;
201
+ while (Date.now() - t0 < opt.settleMs) {
202
+ const why = await page.evaluate(() => (window.__caosGate && window.__caosGate.ready ? window.__caosGate.ready() : 'the gate library is not installed')).catch((e) => `evaluate failed: ${e.message}`);
203
+ if (why === '') {
204
+ streak += 1;
205
+ if (streak >= 2) return '';
206
+ } else {
207
+ streak = 0;
208
+ last = why;
209
+ }
210
+ await page.waitForTimeout(60);
211
+ }
212
+ return last;
213
+ };
214
+
215
+ const drain = (sink, where) => {
216
+ for (const e of sink.splice(0)) fail('G3', where, e);
217
+ };
218
+
219
+ // Every route in every mode keeps its capture, for the person who reads them (the gate measures, it does not judge
220
+ // content); only failure captures are capped, so a run with many failures does not fill the disk.
221
+ const saveShot = async (page, name, failure = false) => {
222
+ if (failure) {
223
+ if (shots >= opt.maxShots) return;
224
+ shots += 1;
225
+ }
226
+ await page.screenshot({ path: join(shotsDir, name) }).catch(() => undefined);
227
+ };
228
+
229
+ try {
230
+ // ---- G4: a missing asset answers 404, not the HTML fallback.
231
+ {
232
+ const ctx = await browser.newContext();
233
+ const res = await ctx.request.get(urlFor('/__caos_gate_missing__.json'), { maxRedirects: 0 }).catch((e) => ({ status: () => `error ${e.message}` }));
234
+ if (res.status() !== 404) fail('G4', { route: '/__caos_gate_missing__.json' }, `a missing asset answered ${res.status()}, not 404 (an SPA fallback hides missing artifacts and broken deep links)`);
235
+ await ctx.close();
236
+ }
237
+
238
+ // ---- G1: the subject, and the route list (from the header nav unless given).
239
+ let routes = opt.routes ? String(opt.routes).split(',').map((r) => r.trim()).filter(Boolean) : null;
240
+ {
241
+ const ctx = await openContext(primarySize, themes[0], langs[0]);
242
+ const { page, sink } = await newPage(ctx);
243
+ await page.goto(urlFor('/'), { waitUntil: 'load', timeout: 30000 });
244
+ const why = await settle(page);
245
+ const id = await page.evaluate(() => window.__caosGate.identity());
246
+ if (id.brand !== opt.expectBrand) {
247
+ fail('G1', { route: '/' }, `the page at ${base} is "${id.brand ?? id.title}", not "${opt.expectBrand}"; nothing else is measured (a gate pointed at another product passes on it)`);
248
+ await ctx.close();
249
+ return finish();
250
+ }
251
+ if (why) fail('G7', { route: '/' }, `the landing route never settled: ${why}`);
252
+ if (!routes) {
253
+ const hrefs = await page.$$eval('header nav a[href]', (as) => as.map((a) => a.href));
254
+ routes = [...new Set(hrefs.map(routeOf).filter(Boolean))];
255
+ }
256
+ sink.splice(0);
257
+ await ctx.close();
258
+ }
259
+
260
+ // ---- The landing (shell 0.13.0): the root opens the Introduction on its executive view; an old App link with a query string reaches the App.
261
+ {
262
+ const ctx = await openContext(primarySize, themes[0], langs[0]);
263
+ const { page, sink } = await newPage(ctx);
264
+ await page.goto(urlFor('/'), { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
265
+ await settle(page);
266
+ const at = { route: '/', mode: 'landing' };
267
+ if (routeOf(page.url()) !== '/introduction') {
268
+ fail('G4', at, `the root opened ${routeOf(page.url()) ?? page.url()}, not the Introduction; every product lands on the Introduction (Landing of the shell on the root path)`);
269
+ } else {
270
+ const landed = await page.evaluate(() => window.__caosGate.docPage());
271
+ if (!landed || landed.view !== 'executive' || !landed.executive || landed.executive.count !== 1) fail('G4', at, `the root opened the Introduction on the "${landed?.view ?? 'none'}" view; the landing opens its executive view`);
272
+ }
273
+ const old = await page.goto(`${urlFor('/')}?gate=landing#frag`, { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
274
+ await settle(page);
275
+ const u = new URL(page.url());
276
+ if (routeOf(page.url()) !== workbenchRoute || u.searchParams.get('gate') !== 'landing' || u.hash !== '#frag') {
277
+ fail('G4', { route: '/', mode: 'landing' }, `an old App link /?gate=landing#frag reached ${routeOf(page.url())}${u.search}${u.hash}; it must reach ${workbenchRoute} with its query string and hash kept (Landing keeps the query string and the hash)`);
278
+ }
279
+ void old;
280
+ sink.splice(0);
281
+ await ctx.close();
282
+ }
283
+
284
+ // ---- The walk: every mode, every route, every tab, every case (G2 to G9 and the ADR measures).
285
+ for (const size of sizes) {
286
+ for (const theme of themes) {
287
+ for (const lang of langs) {
288
+ const [w, h] = size;
289
+ const mode = `${w}x${h} ${theme} ${lang}`;
290
+ const primary = size === primarySize && theme === themes[0] && lang === langs[0];
291
+ const idleHere = theme === themes[0] && lang === langs[0];
292
+ const ctx = await openContext(size, theme, lang);
293
+ const { page, sink } = await newPage(ctx);
294
+ for (const route of routes) {
295
+ const isWorkbench = workbenchRoutes.has(route);
296
+ const at = { route, mode };
297
+ // G13 runs in the first language; the documentation routes (the prose a reader reads) in every language
298
+ const contrast = size === primarySize && (lang === langs[0] || DOC_ROUTES.includes(route));
299
+ const res = await page.goto(urlFor(route), { waitUntil: 'load', timeout: 30000 }).catch((e) => e);
300
+ const status = res && typeof res.status === 'function' ? res.status() : `error ${res?.message ?? res}`;
301
+ if (status !== 200) {
302
+ fail('G4', at, `loaded directly, ${urlFor(route)} answered ${status}`);
303
+ drain(sink, at);
304
+ continue;
305
+ }
306
+ const why = await settle(page);
307
+ if (why) fail('G7', at, `never settled: ${why}`);
308
+ const id = await page.evaluate(() => window.__caosGate.identity());
309
+ if (id.brand !== opt.expectBrand) {
310
+ fail('G1', at, `the route renders "${id.brand ?? id.title}", not "${opt.expectBrand}"`);
311
+ drain(sink, at);
312
+ continue;
313
+ }
314
+ if (id.theme !== theme) fail('G2', at, `asked for the ${theme} theme through ${THEME_STORAGE_KEY}; the document has data-theme=${id.theme}`);
315
+ if (!String(id.lang ?? '').startsWith(lang)) fail('G2', at, `asked for ${lang} through ${LANG_STORAGE_KEY}; <html lang> is ${id.lang}`);
316
+ if (id.activeHref && routeOf(id.activeHref) !== route) fail('G4', at, `loaded directly, the page shows route ${routeOf(id.activeHref)}`);
317
+
318
+ // ADR-0017 2.1: the documentation floors are measured once per language, at the primary size and first theme
319
+ const docFloors = size === primarySize && theme === themes[0] && DOC_ROUTES.includes(route);
320
+ const walkState = { tested: new Set(), keyedViews: 0, controls: 0, tablists: 0, doc: docFloors ? [] : null };
321
+ await processState(page, sink, { ...at, trail: '' }, { route, isWorkbench, primary, idle: idleHere, interact: idleHere, first: true, size, w, h, theme, lang, walkState, contrast });
322
+
323
+ if (primary) {
324
+ // G4: the trailing-slash form a static host serves for a materialised route.
325
+ const slash = `${urlFor(route)}/`;
326
+ const r2 = await page.goto(slash, { waitUntil: 'load', timeout: 30000 }).catch((e) => e);
327
+ const s2 = r2 && typeof r2.status === 'function' ? r2.status() : `error ${r2?.message ?? r2}`;
328
+ if (s2 !== 200) fail('G4', at, `${slash} answered ${s2}`);
329
+ else {
330
+ await settle(page);
331
+ const id2 = await page.evaluate(() => window.__caosGate.identity());
332
+ if (id2.brand !== opt.expectBrand) fail('G4', at, `${slash} renders "${id2.brand ?? id2.title}"`);
333
+ else if (id2.activeHref && routeOf(id2.activeHref) !== route) fail('G4', at, `${slash} shows route ${routeOf(id2.activeHref)}`);
334
+ }
335
+ drain(sink, { ...at, trail: 'trailing slash' });
336
+ await page.goto(urlFor(route), { waitUntil: 'load', timeout: 30000 });
337
+ await settle(page);
338
+ sink.splice(0);
339
+ }
340
+
341
+ const leaves = await walkTabs(page, sink, { ...at }, [], new Set(), { route, isWorkbench, primary, idle: primary, interact: idleHere, size, w, h, theme, lang, walkState, contrast });
342
+ if (walkState.doc) docResult(at, lang, walkState.doc, Math.max(1, leaves));
343
+
344
+ if (isWorkbench) await walkCases(page, sink, at, { route, isWorkbench, primary, interact: idleHere, size, w, h, theme, lang, walkState, contrast });
345
+
346
+ if (primary && isWorkbench) {
347
+ const f = await page.evaluate(() => window.__caosGate.facts());
348
+ if (!f.workbench) fail('G9', at, 'no [data-case-workbench] on a workbench route');
349
+ else if (!f.workbench.replayOnly) {
350
+ if (walkState.controls === 0) fail('G9', at, 'the workbench has no registered control ([data-control]) and does not declare itself replay-only');
351
+ if (walkState.keyedViews === 0) fail('G9', at, 'no view declares the selection key it shows (PlotCard or Readout dataKey); reactivity cannot be verified');
352
+ }
353
+ if (walkState.tablists === 0) fail('G7', at, 'the workbench shows no tab list to walk');
354
+ }
355
+ }
356
+
357
+ if (primary) {
358
+ // ADR-0071 rule 9 / G5: every header route reached by a real pointer click from the App route.
359
+ for (const route of routes.filter((r) => r !== workbenchRoute)) {
360
+ await page.goto(urlFor(workbenchRoute), { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
361
+ await settle(page);
362
+ const link = page.locator('header nav a[href]');
363
+ const hrefs = await link.evaluateAll((as) => as.map((a) => a.href));
364
+ const idx = hrefs.findIndex((href) => routeOf(href) === route);
365
+ let ok = false;
366
+ if (idx >= 0) {
367
+ // A reader scrolls a narrow nav row to the link first; so does the gate, then clicks with the pointer.
368
+ // A link that does not answer in time is a failure of this route, never an exception that ends the
369
+ // whole run (a locator waited its default 30 s under load and aborted the gate, 0.9.0 self-test).
370
+ await link.nth(idx).evaluate((el) => window.__caosGate.bringEl(el), undefined, { timeout: 10000 }).catch(() => undefined);
371
+ const box = await link.nth(idx).boundingBox({ timeout: 10000 }).catch(() => null);
372
+ if (box) {
373
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
374
+ await page.waitForURL((u) => routeOf(u.href) === route, { timeout: 3000 }).catch(() => undefined);
375
+ ok = routeOf(page.url()) === route;
376
+ }
377
+ }
378
+ if (!ok) fail('G5', { route, mode }, 'not reachable by a pointer click on the header link from the App route');
379
+ sink.splice(0);
380
+ }
381
+ }
382
+ await ctx.close();
383
+ }
384
+ }
385
+ }
386
+
387
+ // ---- G10: the wide-font pass. Every route, tab and sampled case again, in Spanish (the longer labels) when it is
388
+ // measured, rendered in the wide fallback font from the first frame: what a reader with wider fonts sees.
389
+ if (opt.wideFont !== false && opt.wideFontSizes) {
390
+ const wideSizes = String(opt.wideFontSizes).split(',').map((x) => x.trim()).filter(Boolean).map((x) => x.split('x').map(Number));
391
+ const wlang = langs.includes('es') ? 'es' : langs[0];
392
+ const theme = themes[0];
393
+ for (const size of wideSizes) {
394
+ const [w, h] = size;
395
+ const mode = `${w}x${h} ${theme} ${wlang} wide-font`;
396
+ const ctx = await openContext(size, theme, wlang, true);
397
+ const { page, sink } = await newPage(ctx);
398
+ for (const route of routes) {
399
+ const isWorkbench = workbenchRoutes.has(route);
400
+ const at = { route, mode };
401
+ const res = await page.goto(urlFor(route), { waitUntil: 'load', timeout: 30000 }).catch((e) => e);
402
+ const status = res && typeof res.status === 'function' ? res.status() : `error ${res?.message ?? res}`;
403
+ if (status !== 200) {
404
+ fail('G4', at, `loaded directly, ${urlFor(route)} answered ${status}`);
405
+ drain(sink, at);
406
+ continue;
407
+ }
408
+ const why = await settle(page);
409
+ if (why) fail('G7', at, `never settled: ${why}`);
410
+ const walkState = { tested: new Set(), keyedViews: 0, controls: 0, tablists: 0 };
411
+ const s = { route, isWorkbench, primary: false, idle: false, size, w, h, theme, lang: wlang, walkState, wide: true };
412
+ await processState(page, sink, { ...at, trail: '' }, { ...s, first: true });
413
+ await walkTabs(page, sink, { ...at }, [], new Set(), s);
414
+ if (isWorkbench) await walkCases(page, sink, at, s);
415
+ }
416
+ await ctx.close();
417
+ }
418
+ }
419
+
420
+ // ---- G4: artifacts answered as JSON.
421
+ for (const [pathname, first] of artifacts) {
422
+ const c = await first;
423
+ if (c !== '{' && c !== '[') fail('G4', { route: pathname }, `the artifact ${pathname} did not answer JSON (starts with "${c}"; the HTML fallback?)`);
424
+ }
425
+ } finally {
426
+ await browser.close();
427
+ if (server) await server.close();
428
+ }
429
+ return finish();
430
+
431
+ // ------------------------------------------------------------------------------------------------------------
432
+ async function processState(page, sink, where, s) {
433
+ states += 1;
434
+ // On a narrow screen the instrument stacks under the rail; a reader scrolls to the open view, and so does the gate.
435
+ if (s.isWorkbench && s.w < opt.viewportMinWidth) {
436
+ await page.evaluate(() => window.__caosGate.bring('[data-case-workbench] [role="tabpanel"]')).catch(() => undefined);
437
+ }
438
+ const f = await page.evaluate(() => window.__caosGate.facts());
439
+ const big = s.w >= opt.viewportMinWidth;
440
+ if (f.docW > f.vw + 1) fail('G5', where, `horizontal overflow: the document is ${f.docW}px wide in a ${f.vw}px viewport`);
441
+ if (f.beyondCount) fail('G5', where, `${f.beyondCount} element(s) outside the viewport: ${f.beyond.join('; ')}`);
442
+ if (big && f.docH > f.vh + 1) fail('ADR-0071.1', where, `the document scrolls: ${f.docH}px in a ${f.vh}px viewport (the page is the viewport)`);
443
+ for (const r of f.rows) if (r.rows > 1) fail('ADR-0071.4', where, `tab row "${r.name}" wraps onto ${r.rows} rows`);
444
+ for (const r of f.rows) if (r.cut > 1) fail('G5', where, `tab row "${r.name}" cuts its tabs by ${r.cut}px (a row shrunk under its panel)`);
445
+ if (f.rail) {
446
+ if (big && f.rail.scroll > f.rail.client + 1) fail('ADR-0071.6', where, `the rail scrolls: ${f.rail.scroll}px of content in ${f.rail.client}px`);
447
+ if (f.rail.outsideCount) fail('G5', where, `${f.rail.outsideCount} rail element(s) outside the rail: ${f.rail.outside.join('; ')}`);
448
+ }
449
+ if (f.truncatedCount) fail('G5', where, `${f.truncatedCount} truncated text(s) without the full text in a title: ${f.truncated.join('; ')}`);
450
+ for (const c of f.clipped) fail('G5', where, c);
451
+ if (f.offCentre !== null && f.offCentre > 2) fail('ADR-0017.1', where, `the prose page is off-centre by ${f.offCentre.toFixed(1)}px`);
452
+ if (f.referenceLists) fail('ADR-0017.4', where, `${f.referenceLists} bibliography outside the end-of-page references block (a .reference-list); give each DocSection its refs and let the page list them once`);
453
+ if (f.referencesNotLast) fail('ADR-0017.4', where, `${f.referencesNotLast} references block(s) with content after them; references must be the last block of the page or tab`);
454
+ if (f.refsDeclaredNoBlock) fail('ADR-0017.4', where, `${f.refsDeclaredNoBlock} section(s) cite references the page never lists (no references block in their page or tab)`);
455
+ if (f.uncaptioned) fail('ADR-0017.2', where, `${f.uncaptioned} equation(s) without a caption`);
456
+ if (s.walkState?.doc) {
457
+ const d = await page.evaluate(() => window.__caosGate.docPage());
458
+ if (d) s.walkState.doc.push(d);
459
+ }
460
+ for (const r of f.repeatedTicks ?? []) {
461
+ fail('G6', where, `${r.label}: ${r.n} axis tick label(s) repeat the label before them (give the axis fixed decimals)`);
462
+ }
463
+ // a filling card whose note leaves its drawing under the floor (CAOS_APP_SHELL#95); below 1280 px the document scrolls
464
+ if (big) for (const c of f.fillStages ?? []) if (c.h < opt.stageFloor) fail('G6', where, `the drawing of ${c.label} keeps ${Math.round(c.h)} px under its note (floor ${opt.stageFloor} px)`);
465
+ for (const m of await page.evaluate(() => window.__caosGate.drawingText())) fail('G10', where, m);
466
+ if (s.lang === 'es') for (const m of await page.evaluate(() => window.__caosGate.decimals())) fail('G11', where, m);
467
+ if (s.w > 760) for (const m of await page.evaluate(() => window.__caosGate.sticky())) fail('G12', where, m);
468
+ if (s.contrast) for (const m of await page.evaluate(() => window.__caosGate.contrast())) fail('G13', where, m);
469
+ if (f.sectionsWithoutRefs) fail('ADR-0017.4', where, `${f.sectionsWithoutRefs} section(s) end without references or a stated reason`);
470
+ if (s.first && !s.isWorkbench && f.pageText < opt.textFloor) fail('G4', where, `the page body holds ${f.pageText} characters of text (floor ${opt.textFloor}); the route rendered without its content`);
471
+
472
+ if (s.isWorkbench) {
473
+ if (!f.instrument) fail('G6', where, 'no [data-instrument] on a workbench route');
474
+ else await paintedChecks(page, f, where, big);
475
+ if (f.workbench && s.walkState) {
476
+ const keyed = (await page.evaluate(() => window.__caosGate.views())).filter((v) => v.key);
477
+ s.walkState.keyedViews += keyed.length;
478
+ }
479
+ }
480
+
481
+ if (s.first) await saveShot(page, `${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}${s.wide ? '_wide' : ''}.png`);
482
+
483
+ if (s.idle) {
484
+ const idle = await page.evaluate((ms) => window.__caosGate.idle(ms), opt.idleMs);
485
+ const secs = idle.ms / 1000;
486
+ if (idle.raf / secs > RATE_LIMIT) fail('G8', where, `${idle.raf} animation frames in ${secs}s at rest (a loop runs with nothing to show)`);
487
+ if (idle.mutations / secs > RATE_LIMIT) fail('G8', where, `${idle.mutations} DOM mutations in ${secs}s at rest: ${idle.sample.join('; ')}`);
488
+ }
489
+
490
+ for (const m of await page.evaluate(() => window.__caosGate.scrolls())) fail('G5', where, m);
491
+ const reach = await page.evaluate(() => window.__caosGate.reach());
492
+ if (reach.unreachableCount) fail('G5', where, `${reach.unreachableCount} control(s) not reachable by the pointer: ${reach.unreachable.join('; ')}`);
493
+
494
+ await cardChecks(page, where, s);
495
+ if (s.primary) await animationChecks(page, where);
496
+
497
+ if (s.primary && s.isWorkbench && s.walkState) await reactivity(page, sink, where, s);
498
+ drain(sink, where);
499
+ const failedHere = failures.some((x) => x.route === where.route && x.mode === where.mode && x.trail === where.trail && x.check !== 'G3');
500
+ if (failedHere) await saveShot(page, `fail_${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}${s.wide ? '_wide' : ''}_${shots + 1}.png`, true);
501
+ }
502
+
503
+ async function paintedChecks(page, f, where, big) {
504
+ if (f.prose) return; // the context write-up is prose, measured as a document, not as a drawing
505
+ if (f.surfaces.length === 0 && f.unavailable) {
506
+ // a declared absence a reader can see and read (ADR-0080 decision 3): nothing to draw, every other check still ran
507
+ if (f.drawn > 0) fail('G6', where, `a drawing is present but not visible beside a data-state="unavailable" note (${f.drawn} drawing element(s) rendered in the instrument, collapsed, hidden or too small to see): draw it, or remove it`);
508
+ else if (f.unavailable === 'unexplained') fail('G6', where, 'the instrument declares data-state="unavailable" and draws nothing, but gives no reason (the text a reader sees needs at least 12 letters or digits in two words or more, saying why the data is absent)');
509
+ else if (f.unavailable === 'unseen') fail('G6', where, 'the instrument declares data-state="unavailable" and draws nothing, but no reader can see the note (it needs a visible box of at least 40 x 12 px inside the instrument, not transparent, not clipped, not off-screen)');
510
+ return;
511
+ }
512
+ if (f.surfaces.length === 0) {
513
+ fail('G6', where, 'nothing is drawn in the instrument (no canvas, svg drawing, image or table)');
514
+ return;
515
+ }
516
+ const clip = { x: Math.floor(f.instrument.x), y: Math.floor(f.instrument.y), width: Math.max(1, Math.floor(f.instrument.w)), height: Math.max(1, Math.floor(f.instrument.h)) };
517
+ let img;
518
+ try {
519
+ img = decodePng(await page.screenshot({ clip }));
520
+ } catch (e) {
521
+ fail('G6', where, `the instrument could not be captured: ${e.message}`);
522
+ return;
523
+ }
524
+ // Per stage: the painted extent of its drawings (union of their painted boxes), judged against the stage.
525
+ const stages = new Map();
526
+ for (const s of f.surfaces) {
527
+ if (s.right > f.vw + 1) fail('G6', where, `${s.label} extends to ${Math.round(s.right)}px, past the ${f.vw}px page`);
528
+ const st = stages.get(s.stageKey) ?? { label: s.label, kinds: new Set(), stage: s.stage, view: s.view, painted: [] };
529
+ st.kinds.add(s.kind);
530
+ const box = paintedBox(img, { x: s.x - clip.x, y: s.y - clip.y, w: s.w, h: s.h });
531
+ if (box) st.painted.push({ x: box.x + clip.x, y: box.y + clip.y, w: box.w, h: box.h });
532
+ stages.set(s.stageKey, st);
533
+ }
534
+ const counted = [];
535
+ for (const st of stages.values()) {
536
+ if (st.painted.length === 0) {
537
+ fail('G6', where, `${st.label} (${[...st.kinds].join(', ')}, ${Math.round(st.stage.w)}x${Math.round(st.stage.h)}) is blank: nothing is painted on it`);
538
+ continue;
539
+ }
540
+ const x0 = Math.min(...st.painted.map((b) => b.x));
541
+ const y0 = Math.min(...st.painted.map((b) => b.y));
542
+ const x1 = Math.max(...st.painted.map((b) => b.x + b.w));
543
+ const y1 = Math.max(...st.painted.map((b) => b.y + b.h));
544
+ const fill = ((x1 - x0) * (y1 - y0)) / Math.max(1, st.stage.w * st.stage.h);
545
+ if (big) low('stageFill', fill, where);
546
+ if (fill < opt.stageFillMin) {
547
+ if (big) fail('G6', where, `${st.label} draws on ${(100 * fill).toFixed(0)}% of its stage (floor ${(100 * opt.stageFillMin).toFixed(0)}%)`);
548
+ continue;
549
+ }
550
+ counted.push(st.view);
551
+ }
552
+ if (big) {
553
+ // The instrument share counts only the views whose drawing passed: empty instrument space, and the cards of
554
+ // blank or underfilled drawings, never count (ADR-0071 rule 8, measured on what is drawn).
555
+ const share = unionArea(counted, f.vw, f.vh) / (f.vw * f.vh);
556
+ low('drawnShare', share, where);
557
+ if (share < opt.instrumentMin) fail('G6', where, `the drawn views cover ${(100 * share).toFixed(1)}% of the viewport (floor ${(100 * opt.instrumentMin).toFixed(0)}% on the App route, ADR-0071 rule 8)`);
558
+ }
559
+ }
560
+
561
+ async function reactivity(page, sink, where, s) {
562
+ const controls = await page.evaluate(() => window.__caosGate.controls());
563
+ s.walkState.controls += controls.length;
564
+ for (const c of controls) {
565
+ if (s.walkState.tested.has(c.id)) continue;
566
+ s.walkState.tested.add(c.id);
567
+ const at = { ...where, trail: `${where.trail ? `${where.trail} > ` : ''}control ${c.id}` };
568
+ const before = await workbenchKey(page);
569
+ const changed = await changeControl(page, c).catch((e) => `error: ${e.message.split('\n')[0]}`);
570
+ if (changed !== true) {
571
+ fail('G9', at, `control "${c.id}" (${c.kind}) could not be changed: ${changed || 'no alternative value'}`);
572
+ continue;
573
+ }
574
+ const why = await settle(page);
575
+ if (why) {
576
+ fail('G9', at, `after changing "${c.id}" the views did not settle: ${why}`);
577
+ continue;
578
+ }
579
+ const after = await workbenchKey(page);
580
+ if (after === before) fail('G9', at, `control "${c.id}" did not change the selection key (data-state-key stayed ${before})`);
581
+ const old = (await page.evaluate(() => window.__caosGate.views())).filter((v) => v.key && v.key !== after);
582
+ if (old.length) fail('G9', at, `${old.length} view(s) keep an earlier selection key after "${c.id}" changed: ${old.slice(0, 3).map((v) => v.label).join('; ')}`);
583
+ drain(sink, at);
584
+ }
585
+ }
586
+
587
+ /** Walks every tab of every list owned by `trail`; returns the number of leaf topics found (a tab with no list inside it is one; a list inside a documentation section adds none). */
588
+ async function walkTabs(page, sink, where, trail, handled, s) {
589
+ let leaves = 0;
590
+ const lists = (await page.evaluate(() => window.__caosGate.tablists())).filter((l) => !handled.has(l.key) && !l.source);
591
+ const parentKey = trail.length ? trail[trail.length - 1].key : null;
592
+ const mine = lists.filter((l) => (parentKey ? l.owner === parentKey : l.owner === null));
593
+ for (const tl of mine) {
594
+ handled.add(tl.key);
595
+ s.walkState.tablists += 1;
596
+ for (let i = 0; i < tl.tabs.length; i += 1) {
597
+ const label = tl.tabs[i];
598
+ const here = [...trail, { key: tl.key, label }];
599
+ const at = { ...where, trail: [...(where.case ? [`case ${where.case}`] : []), ...here.map((t) => t.label)].join(' > ') };
600
+ if (i !== tl.active) {
601
+ const tab = page.locator(tl.key).locator('[role="tab"]').filter({ visible: true }).nth(i);
602
+ try {
603
+ // a reader scrolls the tab clear of the sticky header first; so does the gate, then clicks it
604
+ await tab.evaluate((el) => window.__caosGate.bringEl(el)).catch(() => undefined);
605
+ await tab.click({ timeout: 5000 });
606
+ } catch (e) {
607
+ fail('G7', at, `the tab could not be clicked: ${e.message.split('\n')[0]}`);
608
+ if (!tl.inSection) leaves += 1;
609
+ continue;
610
+ }
611
+ const why = await settle(page);
612
+ if (why) fail('G7', at, `never settled: ${why}`);
613
+ await processState(page, sink, at, { ...s, first: false, idle: s.idle });
614
+ }
615
+ const inner = await walkTabs(page, sink, where, here, new Set(handled), s);
616
+ // the tabs of a list inside a documentation section are visited, but they are parts of that section, not topics
617
+ if (!tl.inSection) leaves += inner === 0 ? 1 : inner;
618
+ }
619
+ if (tl.active >= 0 && tl.tabs.length > 1) {
620
+ await page.locator(tl.key).locator('[role="tab"]').filter({ visible: true }).nth(tl.active).click({ timeout: 5000 }).catch(() => undefined);
621
+ await settle(page);
622
+ sink.splice(0);
623
+ }
624
+ }
625
+ return leaves;
626
+ }
627
+
628
+ /** ADR-0017 2.1: applies the area table (gate/areas.mjs) to what a documentation page showed in one language. */
629
+ function docResult(at, lang, states, expectedTopics) {
630
+ const r = docFloorFailures({ route: at.route, lang, states, expectedTopics });
631
+ for (const f of r.failures) fail('ADR-0017.2', { ...at, trail: f.trail }, f.message);
632
+ for (const w of r.waivers) waivers.push({ route: at.route, lang, ...w });
633
+ }
634
+
635
+ async function walkCases(page, sink, at, s) {
636
+ const sources = (await page.evaluate(() => window.__caosGate.tablists())).find((l) => l.source);
637
+ const sourceTabs = sources ? sources.tabs.map((_, i) => i) : [null];
638
+ let total = 0;
639
+ const sampleSet = opt.caseSample ? new Set(String(opt.caseSample).split(',').map((x) => x.trim())) : null;
640
+ for (const si of sourceTabs) {
641
+ if (si !== null && si !== sources.active) {
642
+ await page.locator(sources.key).locator('[role="tab"]').nth(si).click({ timeout: 5000 }).catch(() => undefined);
643
+ await settle(page);
644
+ }
645
+ const { kind, ids } = await page.evaluate(() => window.__caosGate.cases());
646
+ total += ids.length;
647
+ const pick = s.primary ? ids : sampleSet ? ids.filter((x) => sampleSet.has(x)) : [...new Set([ids[0], ids[ids.length - 1]])].filter(Boolean);
648
+ for (const caseId of pick) {
649
+ const where = { ...at, case: caseId, trail: `case ${caseId}` };
650
+ try {
651
+ if (kind === 'select') await page.locator('select[data-control="case"]').filter({ visible: true }).first().selectOption(caseId, { timeout: 5000 });
652
+ else await page.locator(`[data-control="case"] [data-case="${caseId}"]`).filter({ visible: true }).first().click({ timeout: 5000 });
653
+ } catch (e) {
654
+ fail('G7', where, `the case could not be selected: ${e.message.split('\n')[0]}`);
655
+ continue;
656
+ }
657
+ const why = await settle(page);
658
+ if (why) fail('G7', where, `never settled: ${why}`);
659
+ const shownCase = await page.evaluate(() => document.querySelector('[data-case-workbench]')?.getAttribute('data-case') ?? null);
660
+ if (shownCase !== caseId) fail('G9', where, `asked for case ${caseId}; the workbench shows ${shownCase}`);
661
+ await processState(page, sink, where, { ...s, first: false, idle: false });
662
+ if (s.primary) await walkTabs(page, sink, where, [], new Set(), { ...s, idle: false });
663
+ await focusVisit(page, sink, where, s, caseId, pickedIndex(pick, caseId), total);
664
+ }
665
+ if (s.primary && pick.length) {
666
+ // A deep link selects the case it names (G9).
667
+ const deep = pick[pick.length - 1];
668
+ await page.goto(`${urlFor(s.route)}?case=${encodeURIComponent(deep)}`, { waitUntil: 'load', timeout: 30000 });
669
+ await settle(page);
670
+ const shownCase = await page.evaluate(() => document.querySelector('[data-case-workbench]')?.getAttribute('data-case') ?? null);
671
+ if (shownCase !== deep) fail('G9', { ...at, trail: `?case=${deep}` }, `the deep link ?case=${deep} shows case ${shownCase}`);
672
+ drain(sink, { ...at, trail: `?case=${deep}` });
673
+ await page.goto(urlFor(s.route), { waitUntil: 'load', timeout: 30000 });
674
+ await settle(page);
675
+ sink.splice(0);
676
+ }
677
+ }
678
+ if (total === 0 && !opt.singleCase) fail('G9', at, 'the workbench offers no case ([data-control="case"]); declare --single-case if it has one');
679
+ }
680
+
681
+ // ---------------------------------------------------------------------------------------------------- G16, G17, G18
682
+ function pickedIndex(list, id) {
683
+ return list.indexOf(id);
684
+ }
685
+ function idleAnimations(page) {
686
+ return page.waitForFunction('document.getAnimations().filter((a) => a.transitionProperty === "transform" || a.transitionProperty === "visibility" || a.animationName === "caos-deck-in").length === 0', undefined, { timeout: 3000 }).catch(() => undefined);
687
+ }
688
+
689
+ /** G16: the flip cards and the card decks of the state (facts), and, in the first theme and language, their interactions. */
690
+ async function cardChecks(page, where, s) {
691
+ const f = await page.evaluate(() => window.__caosGate.cards());
692
+ if (f.cards.length === 0 && f.decks.length === 0) return;
693
+ for (const c of f.cards) {
694
+ const tl = FLIP_LIMITS.title[f.lang];
695
+ const bl = FLIP_LIMITS.back[f.lang];
696
+ if (c.titleWords > tl) fail('G16', where, `flip card "${c.label}": a title of ${c.titleWords} words; the limit is ${tl} (${f.lang}); a flip card is a small card, a long one stays plain`);
697
+ if (c.backWords > bl) fail('G16', where, `flip card "${c.label}": a back of ${c.backWords} words; the limit is ${bl} (${f.lang}); a flip card is a small card, a long one stays plain`);
698
+ if (!c.buttons || !c.wired) fail('G16', where, `flip card "${c.label}": its faces are not real buttons with aria-expanded and aria-controls naming the back`);
699
+ if (c.hiddenInTree) fail('G16', where, `flip card "${c.label}": the hidden ${c.open ? 'face' : 'back'} is in the accessibility tree (aria-hidden, inert or visibility must take it out)`);
700
+ else if (c.hiddenFocusable) fail('G16', where, `flip card "${c.label}": the hidden ${c.open ? 'face' : 'back'} holds a control the keyboard reaches`);
701
+ }
702
+ for (const d of f.decks) {
703
+ if (d.sideways) fail('G16', where, `card deck "${d.label}": the carousel scrolls sideways (${d.outside} card(s) past its box, the deck or its track wider than the page); it must page, never scroll`);
704
+ if (d.docW > d.vw + 1) fail('G16', where, `card deck "${d.label}": the page is ${d.docW}px wide in a ${d.vw}px viewport`);
705
+ if (d.mode === 'carousel' && d.visible.length === 0) fail('G16', where, `card deck "${d.label}": the carousel shows no card`);
706
+ if (d.mode === 'carousel' && !d.controls) {
707
+ for (let k = 0; k < d.total; k += 1) if (!d.visible.includes(k)) fail('G16', where, `card deck "${d.label}": card ${k + 1} of ${d.total} cannot be reached: the carousel hides it and shows no previous and next controls`);
708
+ }
709
+ }
710
+ if (!s.interact) return;
711
+ try {
712
+ // a carousel pages through every card with its next button
713
+ for (const d of f.decks) {
714
+ if (d.mode !== 'carousel' || !d.controls) continue;
715
+ const deck = page.locator('[data-card-deck]').nth(d.index);
716
+ const reached = new Set(d.visible);
717
+ let st = d;
718
+ let guard = 0;
719
+ while (!st.atStart && guard < d.total + 2) {
720
+ await deck.locator('[data-deck-prev]').click({ timeout: 4000 });
721
+ st = (await page.evaluate(() => window.__caosGate.cards())).decks[d.index];
722
+ guard += 1;
723
+ }
724
+ for (const i of st.visible) reached.add(i);
725
+ guard = 0;
726
+ while (!st.atEnd && guard < d.total + 1) {
727
+ await deck.locator('[data-deck-next]').click({ timeout: 4000 });
728
+ await idleAnimations(page);
729
+ st = (await page.evaluate(() => window.__caosGate.cards())).decks[d.index];
730
+ for (const i of st.visible) reached.add(i);
731
+ if (st.sideways) fail('G16', where, `card deck "${d.label}": the carousel scrolls sideways after the next button (${st.outside} card(s) past its box)`);
732
+ guard += 1;
733
+ }
734
+ for (let k = 0; k < d.total; k += 1) if (!reached.has(k)) fail('G16', where, `card deck "${d.label}": card ${k + 1} of ${d.total} cannot be reached with the next button`);
735
+ guard = 0;
736
+ while (!st.atStart && guard < d.total + 2) {
737
+ await deck.locator('[data-deck-prev]').click({ timeout: 4000 }).catch(() => undefined);
738
+ st = (await page.evaluate(() => window.__caosGate.cards())).decks[d.index];
739
+ guard += 1;
740
+ }
741
+ }
742
+ // one flip card of each deck (and the loose ones): the face takes focus, Enter turns it, focus follows, Space turns it back
743
+ const f2 = await page.evaluate(() => window.__caosGate.cards());
744
+ const done = new Set();
745
+ for (const c of f2.cards) {
746
+ if (!c.shown || c.open || done.has(c.deck)) continue;
747
+ done.add(c.deck);
748
+ const card = page.locator('[data-flip-card]').nth(c.index);
749
+ const toggle = card.locator('[data-flip-toggle="front"]');
750
+ const focused = await toggle.evaluate((el) => { el.focus(); return document.activeElement === el; }).catch(() => false);
751
+ if (!focused) {
752
+ fail('G16', where, `flip card "${c.label}" does not turn with the keyboard: its face cannot take focus (it is not a button)`);
753
+ continue;
754
+ }
755
+ await page.keyboard.press('Enter');
756
+ await idleAnimations(page);
757
+ const after = (await page.evaluate(() => window.__caosGate.cards())).cards[c.index];
758
+ if (!after || !after.open) {
759
+ fail('G16', where, `flip card "${c.label}" does not turn with the keyboard: Enter on its face left it closed`);
760
+ continue;
761
+ }
762
+ const active = await page.evaluate(() => { const a = document.activeElement; return { visible: Boolean(a) && a.checkVisibility({ checkVisibilityCSS: true }), inCard: Boolean(a) && Boolean(a.closest('[data-flip-card]')) }; });
763
+ if (!active.visible || !active.inCard) fail('G16', where, `flip card "${c.label}": after Enter, keyboard focus is not on a control of the face that shows`);
764
+ if (after.hiddenInTree) fail('G16', where, `flip card "${c.label}": the hidden face is in the accessibility tree while the back shows`);
765
+ if (!after.contentShown) fail('G16', where, `flip card "${c.label}": the back shows no content after it turns (its content has no visible box)`);
766
+ if (s.contrast) for (const m of await page.evaluate(() => window.__caosGate.contrast())) fail('G13', where, `${m} (a flip card turned)`);
767
+ if (!done.has('shot')) {
768
+ done.add('shot');
769
+ await saveShot(page, `flipcard_open_${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}.png`);
770
+ }
771
+ await page.keyboard.press('Space');
772
+ await idleAnimations(page);
773
+ const closed = (await page.evaluate(() => window.__caosGate.cards())).cards[c.index];
774
+ if (!closed || closed.open) fail('G16', where, `flip card "${c.label}" does not turn back with the keyboard: Space on its back left it open`);
775
+ }
776
+ } catch (e) {
777
+ fail('G16', where, `a flip card or a card deck could not be used: ${e.message.split('\n')[0]}`);
778
+ }
779
+ }
780
+
781
+ /** G17: every animated SVG of the state has its reduced-motion block, plays for ten seconds at most and stands still under reduced motion. */
782
+ async function animationChecks(page, where) {
783
+ const a = await page.evaluate(() => window.__caosGate.animations());
784
+ const animated = [];
785
+ for (const it of a.items) {
786
+ const analysis = analyseSvgAnimation(it.svg);
787
+ for (const m of animationFailures(analysis, it.name)) fail('G17', where, m);
788
+ if (analysis.animated) animated.push(it.name);
789
+ }
790
+ // a figure drawn by an img of an SVG file: the file is analysed (an img takes no page style, so its own block is what counts)
791
+ const imgs = await page.evaluate(() => window.__caosGate.figureImages());
792
+ for (const im of imgs) {
793
+ const text = await page.evaluate(async (u) => { try { return await (await fetch(u)).text(); } catch (e) { return ''; } }, im.src);
794
+ for (const m of animationFailures(analyseSvgAnimation(text), im.name)) fail('G17', where, m);
795
+ }
796
+ if (animated.length === 0) return;
797
+ await page.emulateMedia({ reducedMotion: 'reduce' });
798
+ try {
799
+ await page.waitForTimeout(250);
800
+ const b = await page.evaluate(() => window.__caosGate.animations());
801
+ for (const it of b.items) {
802
+ if (!animated.includes(it.name)) continue;
803
+ if (it.runningCss > 0 || it.runningSmil) fail('G17', where, `illustration "${it.name}" keeps moving under reduced motion (${it.runningCss} CSS animation(s) running${it.smil ? `, SMIL ${it.runningSmil ? 'playing' : 'paused'}` : ''})`);
804
+ }
805
+ } finally {
806
+ await page.emulateMedia({ reducedMotion: null });
807
+ }
808
+ }
809
+
810
+ /**
811
+ * G18: Focus mode of the cases, by clicking. The App is put on its last group, and on the last sub-view of a group that has
812
+ * sub-views (the first group is where the walk leaves it, so a Focus that fell back to the first would go unseen); Focus must
813
+ * open on that case, group and sub-view, keep the keyboard inside its frame, hold one landmark and return to the same place.
814
+ */
815
+ async function focusVisit(page, sink, where, s, caseId, index, total) {
816
+ const enter = page.locator('[data-focus-enter]').filter({ visible: true }).first();
817
+ if ((await enter.count()) === 0) {
818
+ if (opt.requireFocus && !focusReported.has(`${s.route} ${where.mode}`)) {
819
+ focusReported.add(`${s.route} ${where.mode}`);
820
+ fail('G18', { ...where, trail: `case ${caseId} > Focus` }, 'the App offers no Focus control ([data-focus-enter]): ADR-0070 requires a visible control in the App that opens the selected case in focus mode');
821
+ }
822
+ return;
823
+ }
824
+ const tabs = page.locator('[data-case-workbench] [role="tablist"]').first().locator('[role="tab"]');
825
+ const n = await tabs.count();
826
+ const targets = n > 1 ? [n - 1] : [0];
827
+ if (s.primary && n > 2) {
828
+ // and the first group that holds sub-views
829
+ for (let i = 0; i < n - 1; i += 1) {
830
+ await tabs.nth(i).click({ timeout: 4000 }).catch(() => undefined);
831
+ await settle(page);
832
+ if ((await page.locator('[data-case-workbench] .subtablist').count()) > 0) {
833
+ targets.push(i);
834
+ break;
835
+ }
836
+ }
837
+ }
838
+ for (const [k, t] of targets.entries()) {
839
+ await tabs.nth(t).click({ timeout: 4000 }).catch(() => undefined);
840
+ await settle(page);
841
+ const subs = page.locator('[data-case-workbench] .subtablist [role="tab"]');
842
+ const sn = await subs.count();
843
+ if (sn > 1) {
844
+ await subs.nth(sn - 1).click({ timeout: 4000 }).catch(() => undefined);
845
+ await settle(page);
846
+ }
847
+ await focusVisitOne(page, sink, where, s, caseId, index + k, total, enter);
848
+ }
849
+ await tabs.nth(0).click({ timeout: 4000 }).catch(() => undefined);
850
+ await settle(page);
851
+ }
852
+
853
+ async function focusVisitOne(page, sink, where, s, caseId, index, total, enter) {
854
+ const at = { ...where, trail: `case ${caseId} > Focus` };
855
+ const facts = () => page.evaluate(() => window.__caosGate.focusFacts());
856
+ const label = `Focus mode of case ${caseId}`;
857
+ const before = await facts();
858
+ try {
859
+ await enter.evaluate((el) => window.__caosGate.bringEl(el)).catch(() => undefined);
860
+ await enter.click({ timeout: 5000 });
861
+ } catch (e) {
862
+ fail('G18', at, `${label}: the Focus control could not be clicked: ${e.message.split('\n')[0]}`);
863
+ return;
864
+ }
865
+ const why = await settle(page);
866
+ if (why) fail('G18', at, `${label}: never settled: ${why}`);
867
+ const f = await facts();
868
+ if (!f.focus) {
869
+ fail('G18', at, `${label}: the Focus control did not open Focus mode`);
870
+ return;
871
+ }
872
+ if (f.caseId !== caseId) fail('G18', at, `${label} shows case ${f.caseId}`);
873
+ if (f.group !== before.group) fail('G18', at, `${label} shows group ${f.group}, the App had ${before.group}`);
874
+ if (f.subview !== before.subview) fail('G18', at, `${label} shows sub-view ${f.subview}, the App had ${before.subview}`);
875
+ const q = new URLSearchParams(f.search);
876
+ if (q.get('focus') !== '1' || (total > 1 && q.get('case') !== caseId)) fail('G18', at, `${label}: the URL (${f.search || 'no query'}) does not hold ?focus=1 and the case`);
877
+ if (f.docW > f.vw + 1 || f.docH > f.vh + 1) fail('G18', at, `${label}: the page scrolls (the document is ${f.docW}x${f.docH}px in a ${f.vw}x${f.vh}px viewport); Focus mode is the viewport`);
878
+ if (f.nestedMain) fail('G18', at, `${label}: the page holds a main inside a main (the Focus frame must not be a second landmark inside the App's main)`);
879
+ // the keyboard stays in the Focus frame: the App behind it is out of the tab order
880
+ const strayed = [];
881
+ for (let i = 0; i < 14; i += 1) {
882
+ await page.keyboard.press('Tab');
883
+ const where2 = await page.evaluate(() => { const a = document.activeElement; return !a || a === document.body || a.closest('[data-focus-shell]') ? '' : (a.getAttribute('aria-label') || a.textContent || a.tagName).trim().slice(0, 30); });
884
+ if (where2) strayed.push(where2);
885
+ }
886
+ if (strayed.length) fail('G18', at, `${label}: Tab leaves the Focus frame (focus reached ${[...new Set(strayed)].slice(0, 4).map((x) => `"${x}"`).join(', ')} of the App behind it; make the rest of the page inert)`);
887
+ const big = s.w >= opt.viewportMinWidth;
888
+ if (big && f.rail && f.rail.scroll > f.rail.client + 1) fail('G18', at, `${label}: the rail scrolls (${f.rail.scroll}px of content in ${f.rail.client}px)`);
889
+ if (f.tabRows.some((r) => r > 1)) fail('G18', at, `${label}: a tab row wraps onto ${Math.max(...f.tabRows)} rows`);
890
+ if (big && f.share < opt.instrumentMin) fail('G18', at, `${label}: the stage covers ${(100 * f.share).toFixed(1)}% of the viewport (floor ${(100 * opt.instrumentMin).toFixed(0)}%)`);
891
+ if (f.exitInside === false) fail('G18', at, `${label}: the exit control is outside the viewport`);
892
+ for (const m of await page.evaluate(() => window.__caosGate.drawingText())) fail('G10', at, m);
893
+ if (s.contrast) for (const m of await page.evaluate(() => window.__caosGate.contrast())) fail('G13', at, m);
894
+ if (index === 0) await saveShot(page, `focus_${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}.png`);
895
+ // leave: the exit control for one case, Escape for the next, so both ways are walked
896
+ const byButton = index % 2 === 0;
897
+ let left = true;
898
+ if (byButton) {
899
+ try {
900
+ await page.locator('[data-focus-exit]').first().click({ timeout: 3000 });
901
+ } catch (e) {
902
+ fail('G18', at, `${label}: the exit control could not be used: ${e.message.split('\n')[0]}`);
903
+ left = false;
904
+ }
905
+ } else {
906
+ await page.keyboard.press('Escape');
907
+ }
908
+ if (!left) await page.keyboard.press('Escape');
909
+ const why2 = await settle(page);
910
+ if (why2) fail('G18', at, `${label}: never settled after leaving: ${why2}`);
911
+ const g = await facts();
912
+ if (g.focus) {
913
+ fail('G18', at, `${label}: ${byButton && left ? 'the exit control' : 'Escape'} does not return to the App`);
914
+ await page.goto(`${urlFor(s.route)}?case=${encodeURIComponent(caseId)}`, { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
915
+ await settle(page);
916
+ } else {
917
+ if (!g.app) fail('G18', at, `${label}: back from Focus mode there is no App on the page`);
918
+ if (g.caseId !== caseId) fail('G18', at, `${label}: back in the App on case ${g.caseId}, it was left on case ${caseId}`);
919
+ if (g.group !== before.group) fail('G18', at, `${label}: back in the App on group ${g.group}, it was left on group ${before.group}`);
920
+ if (g.subview !== before.subview) fail('G18', at, `${label}: back in the App on sub-view ${g.subview}, it was left on sub-view ${before.subview}`);
921
+ if (new URLSearchParams(g.search).has('focus')) fail('G18', at, `${label}: the URL still holds ?focus after the return`);
922
+ }
923
+ drain(sink, at);
924
+ }
925
+
926
+ async function workbenchKey(page) {
927
+ return page.evaluate(() => document.querySelector('[data-case-workbench]')?.getAttribute('data-state-key') ?? null);
928
+ }
929
+
930
+ async function changeControl(page, c) {
931
+ const host = page.locator(c.path);
932
+ if (c.kind === 'range') {
933
+ const input = (await host.evaluate((e) => e.matches('input'))) ? host : host.locator('input[type="range"]').first();
934
+ const v = await input.evaluate((e) => ({ value: Number(e.value), max: e.max === '' ? 100 : Number(e.max) }));
935
+ await input.focus();
936
+ await page.keyboard.press(v.value >= v.max ? 'ArrowLeft' : 'ArrowRight');
937
+ return true;
938
+ }
939
+ if (c.kind === 'select') {
940
+ const sel = (await host.evaluate((e) => e.tagName === 'SELECT')) ? host : host.locator('select').first();
941
+ const idx = await sel.evaluate((e) => [...e.options].findIndex((o, i) => i !== e.selectedIndex && !o.disabled));
942
+ if (idx < 0) return 'no other option';
943
+ await sel.selectOption({ index: idx });
944
+ return true;
945
+ }
946
+ if (c.kind === 'check') {
947
+ await host.locator('input[type="checkbox"], input[type="radio"]:not(:checked)').first().click({ timeout: 5000 });
948
+ return true;
949
+ }
950
+ if (c.kind === 'number') {
951
+ const input = host.locator('input[type="number"]').first();
952
+ const v = await input.evaluate((e) => ({ value: Number(e.value), step: Number(e.step) || 1 }));
953
+ await input.fill(String(v.value + v.step));
954
+ await input.press('Enter');
955
+ return true;
956
+ }
957
+ if (c.kind === 'buttons') {
958
+ const buttons = host.locator('button');
959
+ const n = await buttons.count();
960
+ for (let i = 0; i < n; i += 1) {
961
+ const b = buttons.nth(i);
962
+ const on = await b.evaluate(
963
+ (e) => e.disabled || e.classList.contains('on') || e.classList.contains('active') || ['aria-pressed', 'aria-checked', 'aria-selected'].some((a) => e.getAttribute(a) === 'true'),
964
+ );
965
+ if (!on) {
966
+ await b.click({ timeout: 5000 });
967
+ return true;
968
+ }
969
+ }
970
+ return 'every option is already selected';
971
+ }
972
+ return `unknown control kind (${c.kind})`;
973
+ }
974
+
975
+ function finish() {
976
+ const checks = {};
977
+ for (const f of failures) checks[f.check] = (checks[f.check] ?? 0) + 1;
978
+ mkdirSync(opt.out, { recursive: true });
979
+ const captures = existsSync(shotsDir) ? readdirSync(shotsDir).filter((n) => n.endsWith('.png')).sort() : [];
980
+ const report = {
981
+ ok: failures.length === 0,
982
+ base,
983
+ expectBrand: opt.expectBrand,
984
+ sizes: opt.sizes,
985
+ themes: opt.themes,
986
+ langs: opt.langs,
987
+ wideFontSizes: opt.wideFont === false ? '' : opt.wideFontSizes,
988
+ states,
989
+ minima,
990
+ waivers,
991
+ checks,
992
+ captures: captures.length,
993
+ failures,
994
+ };
995
+ writeFileSync(join(opt.out, 'gate-report.json'), JSON.stringify(report, null, 2));
996
+ // G14: the captures are for a person to read (the gate measures, it does not judge content); one page shows them.
997
+ writeFileSync(join(opt.out, 'index.html'), captureIndex(report, captures));
998
+ return report;
999
+ }
1000
+ }
1001
+
1002
+ const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
1003
+
1004
+ /** G14: one page with every capture, grouped by route, the failure captures and the failure list first. */
1005
+ export function captureIndex(report, captures) {
1006
+ const groups = new Map();
1007
+ for (const name of captures) {
1008
+ const key = name.startsWith('fail_') ? 'failures' : name.split('_')[0];
1009
+ if (!groups.has(key)) groups.set(key, []);
1010
+ groups.get(key).push(name);
1011
+ }
1012
+ const order = ['failures', ...[...groups.keys()].filter((k) => k !== 'failures')];
1013
+ const rows = report.failures
1014
+ .slice(0, 400)
1015
+ .map((f) => `<tr><td>${esc(f.check)}</td><td>${esc(f.route)}</td><td>${esc(f.mode)}</td><td>${esc(f.trail)}</td><td>${esc(f.message)}</td></tr>`)
1016
+ .join('');
1017
+ const figure = (n) => `<figure><a href="shots/${esc(n)}"><img loading="lazy" src="shots/${esc(n)}" alt="${esc(n)}"></a><figcaption>${esc(n.replace(/\.png$/, ''))}</figcaption></figure>`;
1018
+ const sections = order
1019
+ .filter((k) => groups.has(k))
1020
+ .map((k) => `<h2>${esc(k)} (${groups.get(k).length})</h2><div class="grid">${groups.get(k).map(figure).join('')}</div>`)
1021
+ .join('');
1022
+ const style =
1023
+ 'body{font:14px system-ui,sans-serif;margin:16px;background:#f6f8fa;color:#1f2328}h1{font-size:18px}h2{font-size:15px;margin-top:24px}' +
1024
+ 'table{border-collapse:collapse;font-size:12px}td{border-bottom:1px solid #d0d7de;padding:3px 6px;vertical-align:top}' +
1025
+ '.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px}figure{margin:0;background:#fff;border:1px solid #d0d7de;padding:6px}' +
1026
+ 'img{width:100%;height:auto;display:block}figcaption{font-size:11px;color:#57606a;margin-top:4px}';
1027
+ const head = `caos-shell-gate: ${esc(report.expectBrand)} at ${esc(report.base)}: ${report.ok ? 'OK' : `${report.failures.length} failure(s)`} in ${report.states} states, ${captures.length} captures`;
1028
+ const wrows = (report.waivers ?? []).map((w) => `<tr><td>${esc(w.route)}</td><td>${esc(w.lang)}</td><td>${esc(w.floor)}</td><td>${esc(w.reason)}</td><td>${w.valid ? 'accepted' : 'refused'}</td></tr>`).join('');
1029
+ const waiverTable = wrows ? `<h2>Waivers (${report.waivers.length})</h2><table><tr><td>route</td><td>language</td><td>floor</td><td>reason</td><td>verdict</td></tr>${wrows}</table>` : '';
1030
+ const table = rows ? `<table><tr><td>check</td><td>route</td><td>mode</td><td>trail</td><td>message</td></tr>${rows}</table>` : '';
1031
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${head}</title><style>${style}</style></head><body><h1>${head}</h1>${table}${waiverTable}${sections}</body></html>\n`;
1032
+ }
1033
+
1034
+ function slug(route) {
1035
+ return route === '/' ? 'landing' : route.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '');
1036
+ }