@fasl-work/caos-app-shell 0.6.13 → 0.7.1

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 ADDED
@@ -0,0 +1,595 @@
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
19
+ // plus the ADR-0071 and ADR-0017 measures (viewport, one-row tabs, rail without scroll, centred prose, captions,
20
+ // references).
21
+
22
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { decodePng, paintedBox, unionArea } from './png.mjs';
25
+ import { servePages } from './serve.mjs';
26
+ import { installLib } from './inpage.mjs';
27
+
28
+ export const DEFAULTS = {
29
+ sizes: '390x844,768x1024,1280x800,1600x900,2560x1440',
30
+ themes: 'light,dark',
31
+ langs: 'en,es',
32
+ workbench: '/',
33
+ idleMs: 3000,
34
+ settleMs: 15000,
35
+ textFloor: 400,
36
+ instrumentMin: 0.5,
37
+ stageFillMin: 0.3,
38
+ viewportMinWidth: 1280,
39
+ out: 'gate-output',
40
+ maxShots: 40,
41
+ };
42
+
43
+ const RATE_LIMIT = 5; // frames or mutations per second at rest above which a loop is reported (G8)
44
+
45
+ async function loadKeys() {
46
+ try {
47
+ return await import(new URL('../dist/keys.js', import.meta.url).href);
48
+ } catch {
49
+ throw new Error('caos-shell-gate: dist/keys.js is missing; the shell package is not built (npm run build)');
50
+ }
51
+ }
52
+
53
+ /**
54
+ * @param {object} o options (see bin/caos-shell-gate.mjs)
55
+ * @returns {Promise<object>} the report; `report.ok` is false on any failure
56
+ */
57
+ export async function runGate(o) {
58
+ const opt = { ...DEFAULTS, ...o };
59
+ if (!opt.expectBrand) throw new Error('caos-shell-gate: --expect-brand is required (G1: the gate never measures an unnamed subject)');
60
+ if (!opt.url && !opt.serve) throw new Error('caos-shell-gate: --url or --serve is required (there is no default target)');
61
+ let chromium;
62
+ try {
63
+ ({ chromium } = await import('playwright'));
64
+ } catch {
65
+ throw new Error('caos-shell-gate: install playwright in this project (npm i -D playwright) to run the gate');
66
+ }
67
+ const { THEME_STORAGE_KEY, LANG_STORAGE_KEY } = await loadKeys();
68
+
69
+ const sizes = String(opt.sizes).split(',').map((s) => s.trim().split('x').map(Number));
70
+ const themes = String(opt.themes).split(',').map((s) => s.trim());
71
+ const langs = String(opt.langs).split(',').map((s) => s.trim());
72
+ const workbenchRoutes = new Set(String(opt.workbench).split(',').map((s) => s.trim()).filter(Boolean));
73
+ const primarySize = sizes.find(([w]) => w >= opt.viewportMinWidth) ?? sizes[0];
74
+ const shotsDir = join(opt.out, 'shots');
75
+ // Every run starts from an empty capture folder: a capture left by an earlier run is stale evidence.
76
+ rmSync(shotsDir, { recursive: true, force: true });
77
+ mkdirSync(shotsDir, { recursive: true });
78
+
79
+ let server = null;
80
+ let base = opt.url;
81
+ if (opt.serve) {
82
+ server = await servePages(opt.serve, opt.basePath ?? '/');
83
+ base = server.url;
84
+ }
85
+ base = base.endsWith('/') ? base : `${base}/`;
86
+ const basePath = new URL(base).pathname;
87
+ const urlFor = (route) => new URL(route.replace(/^\//, ''), base).href;
88
+ const routeOf = (href) => {
89
+ const p = new URL(href, base).pathname;
90
+ return p.startsWith(basePath) ? `/${p.slice(basePath.length)}`.replace(/\/$/, '') || '/' : null;
91
+ };
92
+
93
+ const failures = [];
94
+ // The smallest measured values against each floor, so a pass shows its margin (and a CI font change that eats it
95
+ // is visible before it fails).
96
+ const minima = { drawnShare: null, stageFill: null };
97
+ const low = (k, value, where) => {
98
+ if (minima[k] === null || value < minima[k].value) minima[k] = { value: Number(value.toFixed(4)), route: where.route, mode: where.mode, trail: where.trail };
99
+ };
100
+ let shots = 0;
101
+ let states = 0;
102
+ const fail = (check, where, message) => failures.push({ check, route: where.route ?? '', mode: where.mode ?? '', trail: where.trail ?? '', message });
103
+ const artifacts = new Map();
104
+
105
+ const browser = await chromium.launch();
106
+ const openContext = async ([w, h], theme, lang) => {
107
+ const ctx = await browser.newContext({ viewport: { width: w, height: h }, deviceScaleFactor: 1 });
108
+ await ctx.addInitScript(
109
+ ([tk, lk, t, l]) => {
110
+ try {
111
+ localStorage.setItem(tk, t);
112
+ localStorage.setItem(lk, l);
113
+ } catch {
114
+ /* storage unavailable: G2 reports the mode that came back */
115
+ }
116
+ },
117
+ [THEME_STORAGE_KEY, LANG_STORAGE_KEY, theme, lang],
118
+ );
119
+ await ctx.addInitScript(installLib);
120
+ return ctx;
121
+ };
122
+
123
+ const newPage = async (ctx) => {
124
+ const page = await ctx.newPage();
125
+ const sink = [];
126
+ page.on('console', (m) => {
127
+ if (m.type() === 'error') sink.push(`console.error: ${m.text().slice(0, 300)}`);
128
+ });
129
+ page.on('pageerror', (e) => sink.push(`page error: ${String(e).slice(0, 300)}`));
130
+ page.on('response', (r) => {
131
+ const req = r.request();
132
+ if (req.method() !== 'GET') return;
133
+ if (r.status() >= 400) sink.push(`HTTP ${r.status()} for ${r.url()}`);
134
+ const type = req.resourceType();
135
+ let pathname = '';
136
+ try {
137
+ pathname = new URL(r.url()).pathname;
138
+ } catch {
139
+ /* not a URL */
140
+ }
141
+ if ((type === 'fetch' || type === 'xhr') && pathname.endsWith('.json') && r.status() < 400 && !artifacts.has(pathname)) {
142
+ artifacts.set(
143
+ pathname,
144
+ r.text().then(
145
+ (t) => t.trimStart().slice(0, 1),
146
+ () => '',
147
+ ),
148
+ );
149
+ }
150
+ });
151
+ return { page, sink };
152
+ };
153
+
154
+ const settle = async (page) => {
155
+ await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))))).catch(() => undefined);
156
+ const t0 = Date.now();
157
+ let last = 'timeout';
158
+ let streak = 0;
159
+ while (Date.now() - t0 < opt.settleMs) {
160
+ 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}`);
161
+ if (why === '') {
162
+ streak += 1;
163
+ if (streak >= 2) return '';
164
+ } else {
165
+ streak = 0;
166
+ last = why;
167
+ }
168
+ await page.waitForTimeout(60);
169
+ }
170
+ return last;
171
+ };
172
+
173
+ const drain = (sink, where) => {
174
+ for (const e of sink.splice(0)) fail('G3', where, e);
175
+ };
176
+
177
+ // Every route in every mode keeps its capture, for the person who reads them (the gate measures, it does not judge
178
+ // content); only failure captures are capped, so a run with many failures does not fill the disk.
179
+ const saveShot = async (page, name, failure = false) => {
180
+ if (failure) {
181
+ if (shots >= opt.maxShots) return;
182
+ shots += 1;
183
+ }
184
+ await page.screenshot({ path: join(shotsDir, name) }).catch(() => undefined);
185
+ };
186
+
187
+ try {
188
+ // ---- G4: a missing asset answers 404, not the HTML fallback.
189
+ {
190
+ const ctx = await browser.newContext();
191
+ const res = await ctx.request.get(urlFor('/__caos_gate_missing__.json'), { maxRedirects: 0 }).catch((e) => ({ status: () => `error ${e.message}` }));
192
+ 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)`);
193
+ await ctx.close();
194
+ }
195
+
196
+ // ---- G1: the subject, and the route list (from the header nav unless given).
197
+ let routes = opt.routes ? String(opt.routes).split(',').map((r) => r.trim()).filter(Boolean) : null;
198
+ {
199
+ const ctx = await openContext(primarySize, themes[0], langs[0]);
200
+ const { page, sink } = await newPage(ctx);
201
+ await page.goto(urlFor('/'), { waitUntil: 'load', timeout: 30000 });
202
+ const why = await settle(page);
203
+ const id = await page.evaluate(() => window.__caosGate.identity());
204
+ if (id.brand !== opt.expectBrand) {
205
+ 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)`);
206
+ await ctx.close();
207
+ return finish();
208
+ }
209
+ if (why) fail('G7', { route: '/' }, `the App route never settled: ${why}`);
210
+ if (!routes) {
211
+ const hrefs = await page.$$eval('header nav a[href]', (as) => as.map((a) => a.href));
212
+ routes = [...new Set(['/', ...hrefs.map(routeOf).filter(Boolean)])];
213
+ }
214
+ sink.splice(0);
215
+ await ctx.close();
216
+ }
217
+
218
+ // ---- The walk: every mode, every route, every tab, every case (G2 to G9 and the ADR measures).
219
+ for (const size of sizes) {
220
+ for (const theme of themes) {
221
+ for (const lang of langs) {
222
+ const [w, h] = size;
223
+ const mode = `${w}x${h} ${theme} ${lang}`;
224
+ const primary = size === primarySize && theme === themes[0] && lang === langs[0];
225
+ const idleHere = theme === themes[0] && lang === langs[0];
226
+ const ctx = await openContext(size, theme, lang);
227
+ const { page, sink } = await newPage(ctx);
228
+ for (const route of routes) {
229
+ const isWorkbench = workbenchRoutes.has(route);
230
+ const at = { route, mode };
231
+ const res = await page.goto(urlFor(route), { waitUntil: 'load', timeout: 30000 }).catch((e) => e);
232
+ const status = res && typeof res.status === 'function' ? res.status() : `error ${res?.message ?? res}`;
233
+ if (status !== 200) {
234
+ fail('G4', at, `loaded directly, ${urlFor(route)} answered ${status}`);
235
+ drain(sink, at);
236
+ continue;
237
+ }
238
+ const why = await settle(page);
239
+ if (why) fail('G7', at, `never settled: ${why}`);
240
+ const id = await page.evaluate(() => window.__caosGate.identity());
241
+ if (id.brand !== opt.expectBrand) {
242
+ fail('G1', at, `the route renders "${id.brand ?? id.title}", not "${opt.expectBrand}"`);
243
+ drain(sink, at);
244
+ continue;
245
+ }
246
+ if (id.theme !== theme) fail('G2', at, `asked for the ${theme} theme through ${THEME_STORAGE_KEY}; the document has data-theme=${id.theme}`);
247
+ if (!String(id.lang ?? '').startsWith(lang)) fail('G2', at, `asked for ${lang} through ${LANG_STORAGE_KEY}; <html lang> is ${id.lang}`);
248
+ if (route !== '/' && id.activeHref && routeOf(id.activeHref) !== route) fail('G4', at, `loaded directly, the page shows route ${routeOf(id.activeHref)}`);
249
+
250
+ const walkState = { tested: new Set(), keyedViews: 0, controls: 0, tablists: 0 };
251
+ await processState(page, sink, { ...at, trail: '' }, { route, isWorkbench, primary, idle: idleHere, first: true, size, w, h, theme, lang, walkState });
252
+
253
+ if (primary && route !== '/') {
254
+ // G4: the trailing-slash form a static host serves for a materialised route.
255
+ const slash = `${urlFor(route)}/`;
256
+ const r2 = await page.goto(slash, { waitUntil: 'load', timeout: 30000 }).catch((e) => e);
257
+ const s2 = r2 && typeof r2.status === 'function' ? r2.status() : `error ${r2?.message ?? r2}`;
258
+ if (s2 !== 200) fail('G4', at, `${slash} answered ${s2}`);
259
+ else {
260
+ await settle(page);
261
+ const id2 = await page.evaluate(() => window.__caosGate.identity());
262
+ if (id2.brand !== opt.expectBrand) fail('G4', at, `${slash} renders "${id2.brand ?? id2.title}"`);
263
+ else if (id2.activeHref && routeOf(id2.activeHref) !== route) fail('G4', at, `${slash} shows route ${routeOf(id2.activeHref)}`);
264
+ }
265
+ drain(sink, { ...at, trail: 'trailing slash' });
266
+ await page.goto(urlFor(route), { waitUntil: 'load', timeout: 30000 });
267
+ await settle(page);
268
+ sink.splice(0);
269
+ }
270
+
271
+ await walkTabs(page, sink, { ...at }, [], new Set(), { route, isWorkbench, primary, idle: primary, size, w, h, theme, lang, walkState });
272
+
273
+ if (isWorkbench) await walkCases(page, sink, at, { route, isWorkbench, primary, size, w, h, theme, lang, walkState });
274
+
275
+ if (primary && isWorkbench) {
276
+ const f = await page.evaluate(() => window.__caosGate.facts());
277
+ if (!f.workbench) fail('G9', at, 'no [data-case-workbench] on a workbench route');
278
+ else if (!f.workbench.replayOnly) {
279
+ if (walkState.controls === 0) fail('G9', at, 'the workbench has no registered control ([data-control]) and does not declare itself replay-only');
280
+ if (walkState.keyedViews === 0) fail('G9', at, 'no view declares the selection key it shows (PlotCard or Readout dataKey); reactivity cannot be verified');
281
+ }
282
+ if (walkState.tablists === 0) fail('G7', at, 'the workbench shows no tab list to walk');
283
+ }
284
+ }
285
+
286
+ if (primary) {
287
+ // ADR-0071 rule 9 / G5: every header route reached by a real pointer click from the App route.
288
+ for (const route of routes.filter((r) => r !== '/')) {
289
+ await page.goto(urlFor('/'), { waitUntil: 'load', timeout: 30000 });
290
+ await settle(page);
291
+ const link = page.locator('header nav a[href]');
292
+ const hrefs = await link.evaluateAll((as) => as.map((a) => a.href));
293
+ const idx = hrefs.findIndex((href) => routeOf(href) === route);
294
+ let ok = false;
295
+ if (idx >= 0) {
296
+ // A reader scrolls a narrow nav row to the link first; so does the gate, then clicks with the pointer.
297
+ await link.nth(idx).evaluate((el) => window.__caosGate.bringEl(el));
298
+ const box = await link.nth(idx).boundingBox();
299
+ if (box) {
300
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
301
+ await page.waitForURL((u) => routeOf(u.href) === route, { timeout: 3000 }).catch(() => undefined);
302
+ ok = routeOf(page.url()) === route;
303
+ }
304
+ }
305
+ if (!ok) fail('G5', { route, mode }, 'not reachable by a pointer click on the header link from the App route');
306
+ sink.splice(0);
307
+ }
308
+ }
309
+ await ctx.close();
310
+ }
311
+ }
312
+ }
313
+
314
+ // ---- G4: artifacts answered as JSON.
315
+ for (const [pathname, first] of artifacts) {
316
+ const c = await first;
317
+ if (c !== '{' && c !== '[') fail('G4', { route: pathname }, `the artifact ${pathname} did not answer JSON (starts with "${c}"; the HTML fallback?)`);
318
+ }
319
+ } finally {
320
+ await browser.close();
321
+ if (server) await server.close();
322
+ }
323
+ return finish();
324
+
325
+ // ------------------------------------------------------------------------------------------------------------
326
+ async function processState(page, sink, where, s) {
327
+ states += 1;
328
+ // On a narrow screen the instrument stacks under the rail; a reader scrolls to the open view, and so does the gate.
329
+ if (s.isWorkbench && s.w < opt.viewportMinWidth) {
330
+ await page.evaluate(() => window.__caosGate.bring('[data-case-workbench] [role="tabpanel"]')).catch(() => undefined);
331
+ }
332
+ const f = await page.evaluate(() => window.__caosGate.facts());
333
+ const big = s.w >= opt.viewportMinWidth;
334
+ if (f.docW > f.vw + 1) fail('G5', where, `horizontal overflow: the document is ${f.docW}px wide in a ${f.vw}px viewport`);
335
+ if (f.beyondCount) fail('G5', where, `${f.beyondCount} element(s) outside the viewport: ${f.beyond.join('; ')}`);
336
+ 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)`);
337
+ for (const r of f.rows) if (r.rows > 1) fail('ADR-0071.4', where, `tab row "${r.name}" wraps onto ${r.rows} rows`);
338
+ 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)`);
339
+ if (f.rail) {
340
+ 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`);
341
+ if (f.rail.outsideCount) fail('G5', where, `${f.rail.outsideCount} rail element(s) outside the rail: ${f.rail.outside.join('; ')}`);
342
+ }
343
+ if (f.truncatedCount) fail('G5', where, `${f.truncatedCount} truncated text(s) without the full text in a title: ${f.truncated.join('; ')}`);
344
+ for (const c of f.clipped) fail('G5', where, c);
345
+ if (f.offCentre !== null && f.offCentre > 2) fail('ADR-0017.1', where, `the prose page is off-centre by ${f.offCentre.toFixed(1)}px`);
346
+ if (f.referenceLists) fail('ADR-0017.4', where, `${f.referenceLists} bibliography dump(s); references belong at the end of each section`);
347
+ if (f.uncaptioned) fail('ADR-0017.2', where, `${f.uncaptioned} equation(s) without a caption`);
348
+ if (f.sectionsWithoutRefs) fail('ADR-0017.4', where, `${f.sectionsWithoutRefs} section(s) end without references or a stated reason`);
349
+ 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`);
350
+
351
+ if (s.isWorkbench) {
352
+ if (!f.instrument) fail('G6', where, 'no [data-instrument] on a workbench route');
353
+ else await paintedChecks(page, f, where, big);
354
+ if (f.workbench && s.walkState) {
355
+ const keyed = (await page.evaluate(() => window.__caosGate.views())).filter((v) => v.key);
356
+ s.walkState.keyedViews += keyed.length;
357
+ }
358
+ }
359
+
360
+ if (s.first) await saveShot(page, `${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}.png`);
361
+
362
+ if (s.idle) {
363
+ const idle = await page.evaluate((ms) => window.__caosGate.idle(ms), opt.idleMs);
364
+ const secs = idle.ms / 1000;
365
+ 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)`);
366
+ if (idle.mutations / secs > RATE_LIMIT) fail('G8', where, `${idle.mutations} DOM mutations in ${secs}s at rest: ${idle.sample.join('; ')}`);
367
+ }
368
+
369
+ for (const m of await page.evaluate(() => window.__caosGate.scrolls())) fail('G5', where, m);
370
+ const reach = await page.evaluate(() => window.__caosGate.reach());
371
+ if (reach.unreachableCount) fail('G5', where, `${reach.unreachableCount} control(s) not reachable by the pointer: ${reach.unreachable.join('; ')}`);
372
+
373
+ if (s.primary && s.isWorkbench && s.walkState) await reactivity(page, sink, where, s);
374
+ drain(sink, where);
375
+ const failedHere = failures.some((x) => x.route === where.route && x.mode === where.mode && x.trail === where.trail && x.check !== 'G3');
376
+ if (failedHere) await saveShot(page, `fail_${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}_${shots + 1}.png`, true);
377
+ }
378
+
379
+ async function paintedChecks(page, f, where, big) {
380
+ if (f.prose) return; // the context write-up is prose, measured as a document, not as a drawing
381
+ if (f.surfaces.length === 0) {
382
+ fail('G6', where, 'nothing is drawn in the instrument (no canvas, svg drawing, image or table)');
383
+ return;
384
+ }
385
+ 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)) };
386
+ let img;
387
+ try {
388
+ img = decodePng(await page.screenshot({ clip }));
389
+ } catch (e) {
390
+ fail('G6', where, `the instrument could not be captured: ${e.message}`);
391
+ return;
392
+ }
393
+ // Per stage: the painted extent of its drawings (union of their painted boxes), judged against the stage.
394
+ const stages = new Map();
395
+ for (const s of f.surfaces) {
396
+ if (s.right > f.vw + 1) fail('G6', where, `${s.label} extends to ${Math.round(s.right)}px, past the ${f.vw}px page`);
397
+ const st = stages.get(s.stageKey) ?? { label: s.label, kinds: new Set(), stage: s.stage, view: s.view, painted: [] };
398
+ st.kinds.add(s.kind);
399
+ const box = paintedBox(img, { x: s.x - clip.x, y: s.y - clip.y, w: s.w, h: s.h });
400
+ if (box) st.painted.push({ x: box.x + clip.x, y: box.y + clip.y, w: box.w, h: box.h });
401
+ stages.set(s.stageKey, st);
402
+ }
403
+ const counted = [];
404
+ for (const st of stages.values()) {
405
+ if (st.painted.length === 0) {
406
+ 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`);
407
+ continue;
408
+ }
409
+ const x0 = Math.min(...st.painted.map((b) => b.x));
410
+ const y0 = Math.min(...st.painted.map((b) => b.y));
411
+ const x1 = Math.max(...st.painted.map((b) => b.x + b.w));
412
+ const y1 = Math.max(...st.painted.map((b) => b.y + b.h));
413
+ const fill = ((x1 - x0) * (y1 - y0)) / Math.max(1, st.stage.w * st.stage.h);
414
+ if (big) low('stageFill', fill, where);
415
+ if (fill < opt.stageFillMin) {
416
+ if (big) fail('G6', where, `${st.label} draws on ${(100 * fill).toFixed(0)}% of its stage (floor ${(100 * opt.stageFillMin).toFixed(0)}%)`);
417
+ continue;
418
+ }
419
+ counted.push(st.view);
420
+ }
421
+ if (big) {
422
+ // The instrument share counts only the views whose drawing passed: empty instrument space, and the cards of
423
+ // blank or underfilled drawings, never count (ADR-0071 rule 8, measured on what is drawn).
424
+ const share = unionArea(counted, f.vw, f.vh) / (f.vw * f.vh);
425
+ low('drawnShare', share, where);
426
+ 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)`);
427
+ }
428
+ }
429
+
430
+ async function reactivity(page, sink, where, s) {
431
+ const controls = await page.evaluate(() => window.__caosGate.controls());
432
+ s.walkState.controls += controls.length;
433
+ for (const c of controls) {
434
+ if (s.walkState.tested.has(c.id)) continue;
435
+ s.walkState.tested.add(c.id);
436
+ const at = { ...where, trail: `${where.trail ? `${where.trail} > ` : ''}control ${c.id}` };
437
+ const before = await workbenchKey(page);
438
+ const changed = await changeControl(page, c).catch((e) => `error: ${e.message.split('\n')[0]}`);
439
+ if (changed !== true) {
440
+ fail('G9', at, `control "${c.id}" (${c.kind}) could not be changed: ${changed || 'no alternative value'}`);
441
+ continue;
442
+ }
443
+ const why = await settle(page);
444
+ if (why) {
445
+ fail('G9', at, `after changing "${c.id}" the views did not settle: ${why}`);
446
+ continue;
447
+ }
448
+ const after = await workbenchKey(page);
449
+ if (after === before) fail('G9', at, `control "${c.id}" did not change the selection key (data-state-key stayed ${before})`);
450
+ const old = (await page.evaluate(() => window.__caosGate.views())).filter((v) => v.key && v.key !== after);
451
+ 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('; ')}`);
452
+ drain(sink, at);
453
+ }
454
+ }
455
+
456
+ async function walkTabs(page, sink, where, trail, handled, s) {
457
+ const lists = (await page.evaluate(() => window.__caosGate.tablists())).filter((l) => !handled.has(l.key) && !l.source);
458
+ const parentKey = trail.length ? trail[trail.length - 1].key : null;
459
+ const mine = lists.filter((l) => (parentKey ? l.owner === parentKey : l.owner === null));
460
+ for (const tl of mine) {
461
+ handled.add(tl.key);
462
+ s.walkState.tablists += 1;
463
+ for (let i = 0; i < tl.tabs.length; i += 1) {
464
+ const label = tl.tabs[i];
465
+ const here = [...trail, { key: tl.key, label }];
466
+ const at = { ...where, trail: [...(where.case ? [`case ${where.case}`] : []), ...here.map((t) => t.label)].join(' > ') };
467
+ if (i !== tl.active) {
468
+ const tab = page.locator(tl.key).locator('[role="tab"]').filter({ visible: true }).nth(i);
469
+ try {
470
+ await tab.click({ timeout: 5000 });
471
+ } catch (e) {
472
+ fail('G7', at, `the tab could not be clicked: ${e.message.split('\n')[0]}`);
473
+ continue;
474
+ }
475
+ const why = await settle(page);
476
+ if (why) fail('G7', at, `never settled: ${why}`);
477
+ await processState(page, sink, at, { ...s, first: false, idle: s.idle });
478
+ }
479
+ await walkTabs(page, sink, where, here, new Set(handled), s);
480
+ }
481
+ if (tl.active >= 0 && tl.tabs.length > 1) {
482
+ await page.locator(tl.key).locator('[role="tab"]').filter({ visible: true }).nth(tl.active).click({ timeout: 5000 }).catch(() => undefined);
483
+ await settle(page);
484
+ sink.splice(0);
485
+ }
486
+ }
487
+ }
488
+
489
+ async function walkCases(page, sink, at, s) {
490
+ const sources = (await page.evaluate(() => window.__caosGate.tablists())).find((l) => l.source);
491
+ const sourceTabs = sources ? sources.tabs.map((_, i) => i) : [null];
492
+ let total = 0;
493
+ const sampleSet = opt.caseSample ? new Set(String(opt.caseSample).split(',').map((x) => x.trim())) : null;
494
+ for (const si of sourceTabs) {
495
+ if (si !== null && si !== sources.active) {
496
+ await page.locator(sources.key).locator('[role="tab"]').nth(si).click({ timeout: 5000 }).catch(() => undefined);
497
+ await settle(page);
498
+ }
499
+ const { kind, ids } = await page.evaluate(() => window.__caosGate.cases());
500
+ total += ids.length;
501
+ const pick = s.primary ? ids : sampleSet ? ids.filter((x) => sampleSet.has(x)) : [...new Set([ids[0], ids[ids.length - 1]])].filter(Boolean);
502
+ for (const caseId of pick) {
503
+ const where = { ...at, case: caseId, trail: `case ${caseId}` };
504
+ try {
505
+ if (kind === 'select') await page.locator('select[data-control="case"]').filter({ visible: true }).first().selectOption(caseId, { timeout: 5000 });
506
+ else await page.locator(`[data-control="case"] [data-case="${caseId}"]`).filter({ visible: true }).first().click({ timeout: 5000 });
507
+ } catch (e) {
508
+ fail('G7', where, `the case could not be selected: ${e.message.split('\n')[0]}`);
509
+ continue;
510
+ }
511
+ const why = await settle(page);
512
+ if (why) fail('G7', where, `never settled: ${why}`);
513
+ const shownCase = await page.evaluate(() => document.querySelector('[data-case-workbench]')?.getAttribute('data-case') ?? null);
514
+ if (shownCase !== caseId) fail('G9', where, `asked for case ${caseId}; the workbench shows ${shownCase}`);
515
+ await processState(page, sink, where, { ...s, first: false, idle: false });
516
+ if (s.primary) await walkTabs(page, sink, where, [], new Set(), { ...s, idle: false });
517
+ }
518
+ if (s.primary && pick.length) {
519
+ // A deep link selects the case it names (G9).
520
+ const deep = pick[pick.length - 1];
521
+ await page.goto(`${urlFor(s.route)}?case=${encodeURIComponent(deep)}`, { waitUntil: 'load', timeout: 30000 });
522
+ await settle(page);
523
+ const shownCase = await page.evaluate(() => document.querySelector('[data-case-workbench]')?.getAttribute('data-case') ?? null);
524
+ if (shownCase !== deep) fail('G9', { ...at, trail: `?case=${deep}` }, `the deep link ?case=${deep} shows case ${shownCase}`);
525
+ drain(sink, { ...at, trail: `?case=${deep}` });
526
+ await page.goto(urlFor(s.route), { waitUntil: 'load', timeout: 30000 });
527
+ await settle(page);
528
+ sink.splice(0);
529
+ }
530
+ }
531
+ if (total === 0 && !opt.singleCase) fail('G9', at, 'the workbench offers no case ([data-control="case"]); declare --single-case if it has one');
532
+ }
533
+
534
+ async function workbenchKey(page) {
535
+ return page.evaluate(() => document.querySelector('[data-case-workbench]')?.getAttribute('data-state-key') ?? null);
536
+ }
537
+
538
+ async function changeControl(page, c) {
539
+ const host = page.locator(c.path);
540
+ if (c.kind === 'range') {
541
+ const input = (await host.evaluate((e) => e.matches('input'))) ? host : host.locator('input[type="range"]').first();
542
+ const v = await input.evaluate((e) => ({ value: Number(e.value), max: e.max === '' ? 100 : Number(e.max) }));
543
+ await input.focus();
544
+ await page.keyboard.press(v.value >= v.max ? 'ArrowLeft' : 'ArrowRight');
545
+ return true;
546
+ }
547
+ if (c.kind === 'select') {
548
+ const sel = (await host.evaluate((e) => e.tagName === 'SELECT')) ? host : host.locator('select').first();
549
+ const idx = await sel.evaluate((e) => [...e.options].findIndex((o, i) => i !== e.selectedIndex && !o.disabled));
550
+ if (idx < 0) return 'no other option';
551
+ await sel.selectOption({ index: idx });
552
+ return true;
553
+ }
554
+ if (c.kind === 'check') {
555
+ await host.locator('input[type="checkbox"], input[type="radio"]:not(:checked)').first().click({ timeout: 5000 });
556
+ return true;
557
+ }
558
+ if (c.kind === 'number') {
559
+ const input = host.locator('input[type="number"]').first();
560
+ const v = await input.evaluate((e) => ({ value: Number(e.value), step: Number(e.step) || 1 }));
561
+ await input.fill(String(v.value + v.step));
562
+ await input.press('Enter');
563
+ return true;
564
+ }
565
+ if (c.kind === 'buttons') {
566
+ const buttons = host.locator('button');
567
+ const n = await buttons.count();
568
+ for (let i = 0; i < n; i += 1) {
569
+ const b = buttons.nth(i);
570
+ const on = await b.evaluate(
571
+ (e) => e.disabled || e.classList.contains('on') || e.classList.contains('active') || ['aria-pressed', 'aria-checked', 'aria-selected'].some((a) => e.getAttribute(a) === 'true'),
572
+ );
573
+ if (!on) {
574
+ await b.click({ timeout: 5000 });
575
+ return true;
576
+ }
577
+ }
578
+ return 'every option is already selected';
579
+ }
580
+ return `unknown control kind (${c.kind})`;
581
+ }
582
+
583
+ function finish() {
584
+ const checks = {};
585
+ for (const f of failures) checks[f.check] = (checks[f.check] ?? 0) + 1;
586
+ const report = { ok: failures.length === 0, base, expectBrand: opt.expectBrand, sizes: opt.sizes, themes: opt.themes, langs: opt.langs, states, minima, checks, failures };
587
+ mkdirSync(opt.out, { recursive: true });
588
+ writeFileSync(join(opt.out, 'gate-report.json'), JSON.stringify(report, null, 2));
589
+ return report;
590
+ }
591
+ }
592
+
593
+ function slug(route) {
594
+ return route === '/' ? 'app' : route.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '');
595
+ }