hbengine 0.2.3 → 0.4.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.
Files changed (72) hide show
  1. package/engine-cli/hbengine.mjs +106 -36
  2. package/engine-cli/outage.mjs +9 -0
  3. package/engine-cli/templates.mjs +1 -0
  4. package/engine-cli/upload-errors.mjs +18 -0
  5. package/engine-cli/upload.mjs +172 -0
  6. package/engine-conformance/analyse.mjs +155 -15
  7. package/engine-conformance/bundle.mjs +23 -5
  8. package/engine-conformance/census-worker.mjs +9 -0
  9. package/engine-conformance/chrome-flags.mjs +19 -0
  10. package/engine-conformance/diagnostic-text.mjs +19 -0
  11. package/engine-conformance/diagnostics.mjs +52 -0
  12. package/engine-conformance/focus.mjs +42 -0
  13. package/engine-conformance/hints.mjs +86 -0
  14. package/engine-conformance/js-releases.json +2365 -0
  15. package/engine-conformance/js-releases.mjs +75 -0
  16. package/engine-conformance/run.mjs +226 -39
  17. package/engine-conformance/scan.mjs +71 -18
  18. package/engine-conformance/sound.mjs +45 -0
  19. package/engine-conformance/travel.mjs +132 -6
  20. package/engine-examples/keno/math-kit/pack.mjs +87 -10
  21. package/engine-examples/lines/math-kit/pack.mjs +87 -10
  22. package/engine-examples/plinko/math-kit/pack.mjs +87 -10
  23. package/engine-examples/plinko-jar/AGENTS.md +3 -0
  24. package/engine-examples/plinko-jar/README.md +16 -0
  25. package/engine-examples/plinko-jar/bundle/assets.json +1 -0
  26. package/engine-examples/plinko-jar/bundle/game.css +10 -0
  27. package/engine-examples/plinko-jar/bundle/game.js +113 -0
  28. package/engine-examples/plinko-jar/bundle/hbtest.json +4 -0
  29. package/engine-examples/plinko-jar/bundle/index.html +16 -0
  30. package/engine-examples/plinko-jar/bundle/model.json +1 -0
  31. package/engine-examples/plinko-jar/math/gen.mjs +47 -0
  32. package/engine-examples/plinko-jar/math/manifest.json +1 -0
  33. package/engine-examples/plinko-jar/math/plinko-jar-12-high.tsv +13 -0
  34. package/engine-examples/plinko-jar/math/plinko-jar-12-low.tsv +13 -0
  35. package/engine-examples/plinko-jar/math/plinko-jar-12-medium.tsv +13 -0
  36. package/engine-examples/plinko-jar/math/plinko-jar-16-high.tsv +17 -0
  37. package/engine-examples/plinko-jar/math/plinko-jar-16-low.tsv +17 -0
  38. package/engine-examples/plinko-jar/math/plinko-jar-16-medium.tsv +17 -0
  39. package/engine-examples/plinko-jar/math/plinko-jar-8-high.tsv +9 -0
  40. package/engine-examples/plinko-jar/math/plinko-jar-8-low.tsv +9 -0
  41. package/engine-examples/plinko-jar/math/plinko-jar-8-medium.tsv +9 -0
  42. package/engine-examples/plinko-jar/math/plinko-jar-meter-jar.tsv +5 -0
  43. package/engine-examples/plinko-jar/math-kit/pack.mjs +242 -0
  44. package/engine-examples/plinko-jar/math-kit/package.json +8 -0
  45. package/engine-examples/plinko-jar/math-kit/slot.mjs +94 -0
  46. package/engine-examples/plinko-jar/math-kit/tumble.mjs +94 -0
  47. package/engine-examples/tumble/math-kit/pack.mjs +87 -10
  48. package/engine-math/pack.mjs +87 -10
  49. package/engine-mcp/server.mjs +30 -14
  50. package/engine-sdk/GUIDE.md +234 -32
  51. package/engine-sdk/bet-field.js +16 -3
  52. package/engine-sdk/bet.js +88 -0
  53. package/engine-sdk/frames.js +75 -0
  54. package/engine-sdk/progress.js +128 -0
  55. package/engine-sdk/protocol.js +3 -0
  56. package/engine-sdk/sdk.d.ts +35 -0
  57. package/engine-sdk/sdk.js +9 -0
  58. package/engine-sdk/ui.js +3 -0
  59. package/engine-shell/core/rgs.js +1 -0
  60. package/engine-shell/core/router.js +46 -54
  61. package/engine-shell/host/audio-session.js +24 -0
  62. package/engine-shell/host/boot.js +5 -2
  63. package/engine-shell/host/engine-v1.js +17 -4
  64. package/engine-shell/host/fair.js +27 -0
  65. package/engine-shell/host/index.html +44 -9
  66. package/engine-shell/host/intro.js +99 -0
  67. package/engine-shell/host/page.js +25 -5
  68. package/engine-shell/host/sandbox-panel.js +23 -3
  69. package/engine-shell/host/sandbox-scenarios.js +23 -11
  70. package/engine-shell/host/site-bridge.js +4 -2
  71. package/engine-shell/verifiers/engine-verify.js +12 -3
  72. package/package.json +2 -1
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ // Adds rows to js-releases.json: the SHA-256 of every JavaScript file of a
3
+ // library release as published on npm. Usage:
4
+ //
5
+ // node packages/engine-conformance/js-releases.mjs PixiJS pixi.js@8.16.0 [pixi.js@8.15.0 …]
6
+ //
7
+ // The first argument is the library's name as KNOWN_LIBRARIES (scan.mjs)
8
+ // spells it; each following one a package at an exact version. The tarball is
9
+ // fetched from the registry, checked against the registry's integrity, and read
10
+ // in memory; nothing in it is run. A file in a bundle's vendor/ whose bytes are
11
+ // one of these is read as that library (scan.mjs, recognisedLibrary).
12
+ import { createHash } from 'node:crypto';
13
+ import { readFileSync, writeFileSync } from 'node:fs';
14
+ import { gunzipSync } from 'node:zlib';
15
+
16
+ const FILE = new URL('./js-releases.json', import.meta.url);
17
+ const REGISTRY = 'https://registry.npmjs.org';
18
+ // The files a studio copies into vendor/: a release's browser builds (dist/,
19
+ // build/, umd/, browser/, or the package's root) and three.js's loader glue
20
+ // (examples/jsm/libs/), not its sources or modules.
21
+ const SHIPPED = /^(?:(?:dist|build|umd|browser|examples\/jsm\/libs)\/(?:[^/]+\/)*)?[^/]+\.m?js$/i;
22
+
23
+ // The regular files of a tar archive: { name, data }. ustar and GNU long names;
24
+ // the package/ prefix npm puts on every entry is removed.
25
+ export function tarFiles(buf) {
26
+ const out = [];
27
+ let longName = null;
28
+ for (let off = 0; off + 512 <= buf.length; ) {
29
+ const h = buf.subarray(off, off + 512);
30
+ if (h.every((b) => b === 0)) break;
31
+ const str = (a, n) => { const s = h.subarray(a, a + n); const z = s.indexOf(0); return s.subarray(0, z < 0 ? n : z).toString('utf8'); };
32
+ const size = parseInt(str(124, 12).trim() || '0', 8);
33
+ const type = String.fromCharCode(h[156] || 48);
34
+ const prefix = str(345, 155);
35
+ let name = longName ?? (prefix ? `${prefix}/${str(0, 100)}` : str(0, 100));
36
+ longName = null;
37
+ const data = buf.subarray(off + 512, off + 512 + size);
38
+ if (type === 'L') longName = data.toString('utf8').replace(/\0+$/, '');
39
+ else if (type === '0' || type === '\0') out.push({ name: name.replace(/^[^/]+\//, ''), data });
40
+ off += 512 + Math.ceil(size / 512) * 512;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ async function release(spec) {
46
+ const at = spec.lastIndexOf('@');
47
+ if (at <= 0) throw new Error(`${spec}: give package@version`);
48
+ const pkg = spec.slice(0, at), version = spec.slice(at + 1);
49
+ const meta = await (await fetch(`${REGISTRY}/${pkg.replace('/', '%2f')}/${version}`)).json();
50
+ if (!meta?.dist?.tarball || !meta.dist.integrity?.startsWith('sha512-')) throw new Error(`${spec}: not on the registry`);
51
+ const tgz = Buffer.from(await (await fetch(meta.dist.tarball)).arrayBuffer());
52
+ if ('sha512-' + createHash('sha512').update(tgz).digest('base64') !== meta.dist.integrity) throw new Error(`${spec}: the tarball does not match the registry's integrity`);
53
+ return { pkg, version, files: tarFiles(gunzipSync(tgz)).filter((f) => SHIPPED.test(f.name)) };
54
+ }
55
+
56
+ if (import.meta.url === `file://${process.argv[1]}`) {
57
+ const [library, ...specs] = process.argv.slice(2);
58
+ if (!library || !specs.length) { console.error('usage: js-releases.mjs <library> <package@version>…'); process.exit(2); }
59
+ const doc = JSON.parse(readFileSync(FILE, 'utf8'));
60
+ let added = 0;
61
+ for (const spec of specs) {
62
+ const r = await release(spec);
63
+ for (const f of r.files) {
64
+ const sha = createHash('sha256').update(f.data).digest('hex');
65
+ const row = doc.files[sha] ?? (doc.files[sha] = { library, file: f.name, bytes: f.data.length, releases: [] });
66
+ if (row.library !== library) throw new Error(`${sha}: already a file of ${row.library}`);
67
+ const release = `${r.pkg}@${r.version}`;
68
+ if (!row.releases.includes(release)) { row.releases.push(release); added++; }
69
+ }
70
+ console.log(`${spec}: ${r.files.length} files`);
71
+ }
72
+ doc.files = Object.fromEntries(Object.entries(doc.files).sort(([, a], [, b]) => a.library.localeCompare(b.library) || a.file.localeCompare(b.file) || a.releases[0].localeCompare(b.releases[0], undefined, { numeric: true })));
73
+ writeFileSync(FILE, JSON.stringify(doc, null, 2) + '\n');
74
+ console.log(`${added} rows or versions added`);
75
+ }
@@ -25,8 +25,11 @@
25
25
  * to the policy's report path (only the browser reports there)
26
26
  * fit the game fits the frame it is given, as a House Original does:
27
27
  * no page scrolls inside it and every visible control lies
28
- * within it, at 720 high and again in fullscreen (desktop
29
- * 1920×1080, phone 390×844). The site never grows the frame.
28
+ * within it, at 720 high, at the shortest frame the site gives
29
+ * (desktop 1194×582, a 1366×768 laptop; phone 364×440, a
30
+ * 375×667 phone between the site's header and bottom nav) and
31
+ * in fullscreen (desktop 1920×1080, phone 390×844). The site
32
+ * never grows the frame.
30
33
  * bet with the studio's hbtest.json: one bet placed through the
31
34
  * game's own UI reaches engine.play and is followed by
32
35
  * `revealed` (the site's round-end) within the reveal deadline.
@@ -40,6 +43,10 @@
40
43
  * outage the next bet meets an ambiguous wallet answer (engine-dev's
41
44
  * /dev/fault): the game does not break, the site is asked to
42
45
  * re-read the wallet
46
+ * sound untouched, the game starts no sound (no media element plays
47
+ * unmuted, no Web Audio source starts) before the player's first
48
+ * tap; a control labelled mute or sound is looked for, and a
49
+ * game without one is a warning for the reviewer, not a fail
43
50
  *
44
51
  * The run plays against engine-dev (the offline sandbox, `--rgs`), never a
45
52
  * platform: it needs no credential, and holds none. Chrome runs with its own
@@ -124,15 +131,22 @@
124
131
  * names an element whose text must carry a value of the round (data.0.side).
125
132
  */
126
133
  import http from 'node:http';
134
+ import { fileURLToPath } from 'node:url';
127
135
  import { randomBytes } from 'node:crypto';
128
- import { readFileSync, existsSync, statSync, writeFileSync } from 'node:fs';
136
+ import { readFileSync, readdirSync, existsSync, statSync, writeFileSync } from 'node:fs';
129
137
  import { flatShare } from './picture.mjs';
130
138
  import { join, extname, normalize } from 'node:path';
131
139
  import { moneyFor } from '../engine-sdk/money.js';
132
140
  import { walletFrom } from '../engine-shell/host/boot.js';
133
141
  import { bundleCSP, shellCSP, reportingEndpoints } from './csp.mjs';
134
- import { GRID, STORAGE_FILE_MAX, WALK, carriedStorage, checksFor, clockFor, clockShim, clockTrap, controlProblem, credentialHits, fitsBudget, forcedRows, handedStorage, inputSignature, lateShotName, medianGrid, shortVariantEstimate, shotName, startInstant, trapOptions, travelAnswer, variantsFor, visualDifference, workerShim } from './travel.mjs';
142
+ import { GRID, STORAGE_FILE_MAX, WALK, carriedStorage, checksFor, checkerOnlySilent, clockFor, clockShim, clockTrap, controlProblem, credentialHits, fitsBudget, failureShotName, forcedRows, handedStorage, inputSignature, lateShotName, medianGrid, missedPress, shortVariantEstimate, shotName, startInstant, trapOptions, travelAnswer, travelClocks, variantsFor, visualDifference, withClocks, workerShim, OUTCOME_SHOTS, outcomeShotName, tierOf } from './travel.mjs';
143
+ import { hintFor } from './hints.mjs';
135
144
  import { clockSource, siteJump, startClock } from './clock.mjs';
145
+ import { appendDiagnostic } from './diagnostic-text.mjs';
146
+ import { enableFeatures } from './chrome-flags.mjs';
147
+ import { SOUND_CONTROL, soundControls, soundProbe, soundVerdict } from './sound.mjs';
148
+ import { consoleRecorder, failureEvidence, handshakeProbe, runnerInfo } from './diagnostics.mjs';
149
+ import { focusProbe, focusVerdict } from './focus.mjs';
136
150
 
137
151
  const args = process.argv.slice(2);
138
152
  const BUNDLE = args[0];
@@ -154,6 +168,11 @@ const TRAVEL_BUDGET_MS = Math.max(10, Number(opt('travel-budget', '240')) || 240
154
168
  const TRAVEL_TOTAL_MS = Math.max(0, Number(opt('travel-total', '0')) || 0) * 1000;
155
169
  // `--variants base,long`: a part of the matrix, for working on one variant (never the job's).
156
170
  const TRAVEL_ONLY = opt('variants', '') ? opt('variants').split(',') : null;
171
+ // `--travel-clocks <json>`: a dated variant's instant, zone and language given by
172
+ // the caller instead of travel.mjs's ({"plus7d": {"at": ISO, "tz", "locale"}}).
173
+ // The check job draws its own for every upload, so the dates a game is played at
174
+ // there are not the ones this file names (travel.mjs travelClocks checks them).
175
+ const TRAVEL_CLOCKS = travelClocks(opt('travel-clocks', ''));
157
176
  // `--storage <file>`: the game's storage as an earlier run left it, read before
158
177
  // the first variant and written after the last (the daily canary carries it
159
178
  // from one day to the next, so a counter kept across visits counts the
@@ -163,7 +182,7 @@ const UNSANDBOXED = process.env.ENGINE_RUN_UNSANDBOXED === '1';
163
182
  // Chrome: --chrome, CHROME_PATH, the Mac install; otherwise playwright-core's
164
183
  // own Chromium (`npx playwright-core install chromium`).
165
184
  const CHROME = opt('chrome', process.env.CHROME_PATH || (process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : undefined));
166
- const PKG = new URL('..', import.meta.url).pathname; // packages/
185
+ const PKG = fileURLToPath(new URL('..', import.meta.url)); // packages/
167
186
  // Three origins on free ports, so any number of runs play at once: the
168
187
  // site on localhost, the shell and the studio on 127.0.0.1 (another site).
169
188
  const ORIGIN = { site: '', shell: '', studio: '' };
@@ -318,7 +337,15 @@ if (args.includes('--dev')) {
318
337
  console.log(`playing ${SLUG} from ${BUNDLE}\nopen ${ORIGIN.site}/#${encodeURIComponent(url)}\n(edit the bundle and reload; Ctrl-C to stop)`);
319
338
  await new Promise(() => {});
320
339
  }
321
- const hbtest = existsSync(join(BUNDLE, 'hbtest.json')) ? JSON.parse(readFileSync(join(BUNDLE, 'hbtest.json'), 'utf8')) : null;
340
+ // Without hbtest.json, a game that loads the SDK's Bet button (the platform's
341
+ // /engine-sdk/bet.js, <hb-bet>) is pressed through it: the run finds the
342
+ // button a player presses by itself, at the game's own stake.
343
+ const usesBetButton = () => {
344
+ const files = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((d) => (d.isDirectory() ? files(join(dir, d.name)) : /\.(html?|m?js)$/i.test(d.name) ? [join(dir, d.name)] : []));
345
+ try { return files(BUNDLE).some((f) => /engine-sdk\/bet\.js/.test(readFileSync(f, 'utf8'))); } catch { return false; }
346
+ };
347
+ const hbtest = existsSync(join(BUNDLE, 'hbtest.json')) ? JSON.parse(readFileSync(join(BUNDLE, 'hbtest.json'), 'utf8'))
348
+ : usesBetButton() ? { bet: { page: 'index.html', selector: 'hb-bet' } } : null;
322
349
  const results = [];
323
350
  const check = (size, rule, ok, detail, status) => results.push({ size, rule, ok, detail, ...(status ? { status } : {}) });
324
351
  const { chromium } = await import('playwright-core');
@@ -342,7 +369,10 @@ const launchEnv = { PATH: process.env.PATH, HOME: process.env.HOME || '/tmp', LA
342
369
  // sandbox (the job: one upload, no role, no egress); a run without the sandbox (a stand on a
343
370
  // machine that cannot give one) keeps WebGL off.
344
371
  const SOFTWARE_WEBGL = UNSANDBOXED ? [] : ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'];
345
- const launchChrome = (tz) => chromium.launch({ executablePath: CHROME, headless: true, chromiumSandbox: !UNSANDBOXED, env: tz ? { ...launchEnv, TZ: tz } : launchEnv, ignoreDefaultArgs: ['--enable-automation'], args: ['--autoplay-policy=no-user-gesture-required', '--disable-dev-shm-usage', '--disable-blink-features=AutomationControlled', ...SOFTWARE_WEBGL] });
372
+ // A process per origin, so a game drawing in software cannot keep the shell
373
+ // from answering the clock (chrome-flags.mjs).
374
+ const FEATURES = [enableFeatures(process.env)];
375
+ const launchChrome = (tz) => chromium.launch({ executablePath: CHROME, headless: true, chromiumSandbox: !UNSANDBOXED, env: tz ? { ...launchEnv, TZ: tz } : launchEnv, ignoreDefaultArgs: ['--enable-automation'], args: ['--autoplay-policy=no-user-gesture-required', '--disable-dev-shm-usage', '--disable-blink-features=AutomationControlled', ...FEATURES, ...SOFTWARE_WEBGL] });
346
376
  const browsers = new Map();
347
377
  const browserFor = async (tz) => { if (!browsers.has(tz)) browsers.set(tz, await launchChrome(tz)); return browsers.get(tz); };
348
378
  let browser;
@@ -354,6 +384,43 @@ try {
354
384
  process.exit(3);
355
385
  }
356
386
 
387
+ // How fast this machine runs a game, measured the same way everywhere (the
388
+ // upload's job, a studio's laptop): plain JavaScript, a 2D canvas, and WebGL
389
+ // as this Chrome draws it (on the CPU here, SwiftShader). Recorded, not judged:
390
+ // it is how a run on a studio's machine is held to the job's pace.
391
+ async function machineSpeed(chrome) {
392
+ const page = await chrome.newPage();
393
+ try {
394
+ return await page.evaluate(() => {
395
+ const time = (f) => { const t = performance.now(); f(); return Math.round(performance.now() - t); };
396
+ const js = time(() => { let x = 0; for (let i = 0; i < 3e7; i++) x += Math.sqrt(i); return x; });
397
+ const c2 = document.createElement('canvas'); c2.width = 1194; c2.height = 720;
398
+ const g = c2.getContext('2d');
399
+ const d2 = time(() => { for (let i = 0; i < 200; i++) { g.fillStyle = `hsl(${i},60%,50%)`; g.fillRect(0, 0, 1194, 720); for (let k = 0; k < 50; k++) g.fillRect((k * 37 + i) % 1194, (k * 53) % 720, 64, 64); } g.getImageData(0, 0, 1, 1); });
400
+ const c3 = document.createElement('canvas'); c3.width = 1194; c3.height = 720;
401
+ const gl = c3.getContext('webgl');
402
+ let webgl = null;
403
+ if (gl) {
404
+ // A full-frame quad through a small shader, 60 times: what a frame of a 2D game costs, roughly.
405
+ const sh = (type, src) => { const o = gl.createShader(type); gl.shaderSource(o, src); gl.compileShader(o); return o; };
406
+ const prog = gl.createProgram();
407
+ gl.attachShader(prog, sh(gl.VERTEX_SHADER, 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}'));
408
+ gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, 'precision mediump float;uniform float t;void main(){vec2 u=gl_FragCoord.xy/700.;float v=0.;for(int i=0;i<8;i++)v+=sin(u.x*float(i)+t)*cos(u.y*float(i));gl_FragColor=vec4(v,u,1.);}'));
409
+ gl.linkProgram(prog); gl.useProgram(prog);
410
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
411
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
412
+ const at = gl.getAttribLocation(prog, 'p'); gl.enableVertexAttribArray(at); gl.vertexAttribPointer(at, 2, gl.FLOAT, false, 0, 0);
413
+ const ut = gl.getUniformLocation(prog, 't');
414
+ webgl = time(() => { for (let i = 0; i < 60; i++) { gl.uniform1f(ut, i); gl.drawArrays(gl.TRIANGLES, 0, 6); gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4)); } });
415
+ }
416
+ return { jsMs: js, canvasMs: d2, webglMs: webgl };
417
+ });
418
+ } finally {
419
+ await page.close().catch(() => {});
420
+ }
421
+ }
422
+ const bench = await machineSpeed(browser).catch(() => null);
423
+
357
424
  // The currency matrix: each size plays under another of the player's displays,
358
425
  // a fiat at a rate and the bare coin. A game must show that currency and send
359
426
  // the stake the Originals would (engine-sdk money.js on the shell's wallet).
@@ -362,6 +429,11 @@ const DISPLAYS = {
362
429
  phone: { symbol: 'USDT', name: 'USDT', isFiat: false, rate: 1, precision: 8, shows: 'USDT' },
363
430
  };
364
431
  const FULLSCREEN = { desktop: { width: 1920, height: 1080 }, phone: { width: 390, height: 844 } };
432
+ // The shortest frame the site gives a game, at the frame's own width: on a
433
+ // phone the frame is the room between the site's header and bottom nav (a
434
+ // 375×667 iPhone with Safari's bars: 440 high); on a laptop the window caps it
435
+ // (1366×768: 582 high). A game lays itself out in any frame from these up.
436
+ const SHORT = { desktop: 582, phone: 440 };
365
437
  const SESSION_CURRENCY = { code: 1, symbol: 'USDT', decimals: 8 };
366
438
 
367
439
  const network = (size, outside, refused, rtc = 0, workers = 0, reports = 0) => check(size, 'network', !outside.length && !refused.length && !rtc && !workers && !reports,
@@ -435,6 +507,19 @@ const CLOCK = CLOCK_SOURCE ? { source: CLOCK_SOURCE, start: String(startClock) }
435
507
  // (given the fake clock's text when the variant travels, for the workers).
436
508
  const initScript = ({ travel, at }) => `(() => {
437
509
  const scope = globalThis;
510
+ // location.origin identifies the URL even when the sandbox's security
511
+ // origin is opaque. An about:blank/srcdoc child inherits its parent's.
512
+ const studioFrame = (() => {
513
+ let frame = scope;
514
+ for (let depth = 0; depth < 20; depth++) {
515
+ try {
516
+ if (frame.location.origin === ${JSON.stringify(ORIGIN.studio)}) return true;
517
+ if (frame === frame.parent) break;
518
+ frame = frame.parent;
519
+ } catch { break; }
520
+ }
521
+ return false;
522
+ })();
438
523
  const natives = (${String(clockShim)})(scope, { capture: true });
439
524
  let fake = null, jump = null;
440
525
  ${travel ? `{
@@ -445,6 +530,11 @@ const initScript = ({ travel, at }) => `(() => {
445
530
  }` : ''}
446
531
  const setAt = (${String(clockShim)})(scope, { natives, fake });
447
532
  (${String(INSTRUMENT_PAGE)})(scope, ${JSON.stringify(RUN_TOKEN)});
533
+ if (studioFrame) (${String(handshakeProbe)})(scope, ((say) => (key) => say(${JSON.stringify(RUN_TOKEN)} + ':init:' + key))(scope.console.debug.bind(scope.console)));
534
+ if (studioFrame) (${String(focusProbe)})(scope, ((say) => (key) => say(${JSON.stringify(RUN_TOKEN)} + ':focus:' + key))(scope.console.debug.bind(scope.console)));
535
+ // The sound rule is about the game: the site's page and the House Engine
536
+ // shell (whose loader plays its own short sound) are ours, not the studio's.
537
+ if (!${JSON.stringify([ORIGIN.site, ORIGIN.shell])}.includes(scope.location.origin)) (${String(soundProbe)})(scope, ((say) => (key) => say(${JSON.stringify(RUN_TOKEN)} + ':sound:' + key))(scope.console.debug.bind(scope.console)));
448
538
  (${String(clockTrap)})(scope, ${JSON.stringify(trapOptions(RUN_TOKEN, travel ? CLOCK : null))}, { setAt, jump, realNow: natives.Date.now });
449
539
  })();\n`;
450
540
  // A frame of the game's: the bundle's own pages, and every frame they make,
@@ -461,9 +551,9 @@ const listIn = async (page, expr) => (await Promise.all(studioFrames(page).map((
461
551
  // through the console (dialogs, peer connections, workers, canvas text). The
462
552
  // same for every run.
463
553
  function instrument(page) {
464
- const errors = [], outside = [], refused = [], plays = [], consoleErrors = [], dialogs = [], workers = [], canvasText = [];
465
- let reports = 0, rtc = 0, unshimmed = 0;
466
- page.on('pageerror', (e) => errors.push(e.message.slice(0, 160)));
554
+ const errors = [], outside = [], refused = [], plays = [], consoleErrors = [], dialogs = [], workers = [], canvasText = [], sounds = [], scriptFocus = [];
555
+ let reports = 0, rtc = 0, unshimmed = 0, initSeen = 0;
556
+ page.on('pageerror', (e) => appendDiagnostic(errors, e.message));
467
557
  page.on('console', (m) => {
468
558
  const text = m.text();
469
559
  if (text.startsWith(RUN_TOKEN + ':')) {
@@ -475,12 +565,15 @@ function instrument(page) {
475
565
  else if (kind === 'worker') workers.push(payload);
476
566
  else if (kind === 'unshimmed') unshimmed++;
477
567
  else if (kind === 'text') canvasText.push(payload);
478
- else if (kind === 'uncaught') errors.push(payload.slice(0, 160)); // a timer's exception under the fake clock: uncaught in production
568
+ else if (kind === 'sound') sounds.push(payload);
569
+ else if (kind === 'init') initSeen++;
570
+ else if (kind === 'focus') scriptFocus.push(payload);
571
+ else if (kind === 'uncaught') appendDiagnostic(errors, payload); // a timer's exception under the fake clock: uncaught in production
479
572
  return;
480
573
  }
481
574
  if (m.type() !== 'error') return;
482
575
  if (text.includes('Content Security Policy')) refused.push(text.slice(0, 120));
483
- else consoleErrors.push(text.slice(0, 160));
576
+ else appendDiagnostic(consoleErrors, text);
484
577
  });
485
578
  page.on('request', (r) => {
486
579
  const u = r.url();
@@ -495,7 +588,7 @@ function instrument(page) {
495
588
  }
496
589
  if (u.includes('/engine/v1/play')) plays.push(r.postData() || '');
497
590
  });
498
- return { errors, outside, refused, plays, consoleErrors, workers, canvasText, reports: () => reports, rtcCount: () => rtc, dialogs: () => [...dialogs], unshimmed: () => unshimmed };
591
+ return { errors, outside, refused, plays, consoleErrors, workers, canvasText, sounds, scriptFocus, initReceived: () => initSeen > 0, reports: () => reports, rtcCount: () => rtc, dialogs: () => [...dialogs], unshimmed: () => unshimmed };
499
592
  }
500
593
 
501
594
  // The site's log of the shell's messages, and the hbtest steps on the game's own pages.
@@ -521,11 +614,29 @@ function helpers(page) {
521
614
  else await press(f.locator(step.selector).first());
522
615
  };
523
616
  // A `then` step (Next, Cash out) is pressed only when its control is there to press.
524
- const thenSteps = async (steps) => {
617
+ // The round can end between the look and the press (a ladder's Cash out
618
+ // turns off with it): that press is let go and the caller looks again.
619
+ // A control that will not take the click (covered, moving) breaks the
620
+ // run, with its evidence, after `tries` misses of that step in a row: one
621
+ // by default (the frame sizes, the short variants), more for the long
622
+ // session's loop (travel.mjs missedPress). Its control going off or away,
623
+ // or a press that lands, starts the count again.
624
+ const missed = new Map();
625
+ const thenSteps = async (steps, tries = 1) => {
525
626
  for (const s of steps || []) {
627
+ const key = s.page + ' ' + s.selector;
526
628
  const f = gameFrame(s.page);
527
629
  const control = f && f.locator(s.selector).first();
528
- if (control && await control.isVisible().catch(() => false) && await control.isEnabled().catch(() => false)) await press(control);
630
+ const on = async () => !!control && await control.isVisible().catch(() => false) && await control.isEnabled().catch(() => false);
631
+ if (!await on()) { missed.delete(key); continue; }
632
+ try {
633
+ await press(control);
634
+ missed.delete(key);
635
+ } catch (e) {
636
+ const r = missedPress({ on: await on(), covered: /intercepts pointer events/.test(String(e && e.message)), missed: missed.get(key) || 0, tries });
637
+ if (r.verdict === 'break') { missed.delete(key); throw e; }
638
+ if (r.missed) missed.set(key, r.missed); else missed.delete(key);
639
+ }
529
640
  }
530
641
  };
531
642
  const roundEnds = (l) => l.filter((m) => m.d.type === 'housebets-game-round-end').length;
@@ -550,17 +661,22 @@ function helpers(page) {
550
661
  // The console errors of each size, for the JSON (`console`): a test bundle's
551
662
  // own report of what it saw (travel.browser.test.mjs) is read from here.
552
663
  const frameConsole = {};
664
+ const diagnostics = {};
553
665
  for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
554
666
  const { shows, ...display } = DISPLAYS[size];
555
667
  const money = moneyFor(walletFrom(JSON.stringify(display), SESSION_CURRENCY));
556
668
  const context = await browser.newContext({ viewport: { width, height: 900 } });
669
+ try {
670
+ const started = Date.now();
557
671
  const page = await context.newPage();
672
+ const consoleLines = consoleRecorder(page, RUN_TOKEN);
673
+ const evidence = () => failureEvidence(page, { console: consoleLines, runner: runnerInfo(browser), started, shot: `${size}.png`, shotFile: join(opt('shots', process.cwd()), `conformance-${SLUG}-${size}.png`), log: () => page.evaluate(() => window.__log || []), initReceived: initReceived() });
558
674
  await page.addInitScript(initScript({ travel: false }));
559
- const { errors, outside, refused, plays, reports, rtcCount, consoleErrors } = instrument(page);
675
+ const { errors, outside, refused, plays, reports, rtcCount, consoleErrors, sounds, initReceived, scriptFocus } = instrument(page);
560
676
  // Kept for the JSON once the size is done: the first distinct lines, bounded
561
677
  // (a test bundle's report of what it saw runs to some sixty lines).
562
678
  const keepConsole = () => { frameConsole[size] = [...new Set(consoleErrors)].slice(0, 200); };
563
- const { log, waitFor, act, press, gameFrame, roundEnds } = helpers(page);
679
+ const { log, waitFor, act, gameFrame, roundEnds, thenSteps } = helpers(page);
564
680
  await page.goto(ORIGIN.site + '/');
565
681
  const { url } = await launch(display);
566
682
  await page.evaluate((u) => window.__launch(u), url);
@@ -568,11 +684,11 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
568
684
  check(size, 'boot', ready, ready ? 'hello answered, ready reported' : 'no ready within 25 s (the game never connected, or never called engine.ready())');
569
685
  if (!ready) {
570
686
  // Nothing after boot means anything when the game never started.
571
- for (const rule of ['idle', 'fit', 'bet', 'currency', 'outage']) check(size, rule, false, 'not reached: the game did not boot');
687
+ for (const rule of ['idle', 'focus', 'sound', 'fit', 'bet', 'currency', 'outage']) check(size, rule, false, 'not reached: the game did not boot');
572
688
  check(size, 'errors', errors.length === 0, errors.length ? [...new Set(errors)].slice(0, 4).join(' | ') : 'none');
573
689
  network(size, outside, refused, rtcCount(), context.serviceWorkers().length, reports());
574
690
  keepConsole();
575
- await page.close();
691
+ diagnostics[size] = await evidence();
576
692
  continue;
577
693
  }
578
694
  // Nobody touches the game for a while: it must not bet on its own. A
@@ -580,6 +696,14 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
580
696
  const idleFrom = plays.length;
581
697
  await page.waitForTimeout(Number(opt('idle-ms', '15000')));
582
698
  check(size, 'idle', plays.length === idleFrom, plays.length === idleFrom ? 'no bet without the player' : `${plays.length - idleFrom} bet(s) placed while nobody touched the game`);
699
+ // Nothing has been touched yet: any sound so far started before the
700
+ // player's first tap. A mute control is the game's to give (the shell has
701
+ // none); one is looked for by its label, and its absence is a warning.
702
+ {
703
+ const control = (await Promise.all(studioFrames(page).map((f) => f.evaluate(soundControls, SOUND_CONTROL.source).catch(() => [])))).flat()[0];
704
+ const v = soundVerdict(sounds, control);
705
+ check(size, 'sound', v.ok, v.detail, v.status);
706
+ }
583
707
 
584
708
  // fit: every page of the game at its frame size, now and after each page
585
709
  // the test opens
@@ -587,6 +711,9 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
587
711
  // clips scrolls nowhere), unless a scroller holds it; what is not shown
588
712
  // (hidden, a closed <details>, aria-hidden, inert) is not there to press.
589
713
  const scrolling = new Set();
714
+ // What the short frame alone found (364×440, 1194×582): a warning for now,
715
+ // while studios lay their games out for the site's shorter frames.
716
+ const shortOnly = new Set();
590
717
  const measure = async (at = '') => {
591
718
  for (const f of studioFrames(page)) {
592
719
  const d = await f.evaluate(() => {
@@ -641,6 +768,13 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
641
768
  };
642
769
  await measure();
643
770
 
771
+ // focus: nothing has been pressed yet (the idle wait, sound and fit press
772
+ // nothing), so every focus said so far was the script's own. Read here, at
773
+ // the last moment before the first click, so a timer's focus counts too.
774
+ // A warning for now, not a fail: some frameworks focus the window at start-up
775
+ // by default (Phaser's autoFocus), and no live game has been measured against it yet.
776
+ { const v = focusVerdict(scriptFocus); check(size, 'focus', true, v.detail, v.ok ? undefined : 'warning'); }
777
+
644
778
  // bet + outage through the studio's own UI
645
779
  if (!hbtest) check(size, 'bet', false, 'no hbtest.json: the run cannot place a bet through the game');
646
780
  else {
@@ -654,9 +788,7 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
654
788
  // `then` step is played only when its control is there to press.
655
789
  for (const s of hbtest.then || []) {
656
790
  await page.waitForTimeout(1500);
657
- const f = gameFrame(s.page);
658
- const control = f && f.locator(s.selector).first();
659
- if (control && await control.isVisible().catch(() => false) && await control.isEnabled().catch(() => false)) await press(control);
791
+ await thenSteps([s]);
660
792
  }
661
793
  const revealed = await waitFor((l) => roundEnds(l) > ends, 10000);
662
794
  // One press, one bet: a game that sends two plays per press charges the player twice.
@@ -723,7 +855,7 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
723
855
  // and answers "pending"): the game must not break, and the site
724
856
  // must be asked to re-read the wallet.
725
857
  if (SKIP_OUTAGE) {
726
- check(size, 'outage', true, 'skipped: this job has no wallet fault stub; the real round checks completed', 'skipped');
858
+ check(size, 'outage', true, 'skipped: this engine has no wallet fault stub; the real round checks completed', 'skipped');
727
859
  } else {
728
860
  const armed = await fetch(RGS + '/dev/fault', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ count: 1 }) }).then((r) => r.ok).catch(() => false);
729
861
  if (!armed) check(size, 'outage', false, 'engine-dev has no /dev/fault: update the sandbox (hbengine) to run the outage check');
@@ -736,24 +868,36 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
736
868
  }
737
869
  }
738
870
  } catch (e) {
871
+ diagnostics[size] = await evidence();
739
872
  check(size, 'bet', false, 'hbtest.json step failed: ' + e.message);
740
873
  for (const rule of ['currency', 'outage']) check(size, rule, false, 'not reached: the bet step failed');
741
874
  }
742
875
  }
743
876
  await measure();
877
+ // The site on a short screen: the same frame, less high.
878
+ const short = SHORT[size];
879
+ await page.evaluate((h) => { document.getElementById('c').style.height = h + 'px'; }, short);
880
+ await page.waitForTimeout(1500);
881
+ const before = new Set(scrolling);
882
+ await measure(`${width}×${short}: `);
883
+ for (const x of scrolling) if (!before.has(x)) { scrolling.delete(x); shortOnly.add(x); }
744
884
  // The site's fullscreen button: the frame takes the whole screen.
745
885
  const full = FULLSCREEN[size];
746
886
  await page.setViewportSize(full);
747
887
  await page.evaluate(() => { document.getElementById('c').style.height = innerHeight + 'px'; });
748
888
  await page.waitForTimeout(1500);
749
889
  await measure('fullscreen: ');
750
- check(size, 'fit', scrolling.size === 0, scrolling.size ? 'does not fit: ' + [...scrolling].join('; ') : `fits ${width}×720 and fullscreen ${full.width}×${full.height}`);
890
+ if (scrolling.size) check(size, 'fit', false, 'does not fit: ' + [...scrolling].join('; '));
891
+ else if (shortOnly.size) check(size, 'fit', true, `fits ${width}×720 and fullscreen ${full.width}×${full.height}; not yet the shorter ${width}×${short} frame the site gives on smaller screens: ` + [...shortOnly].join('; '), 'warning');
892
+ else check(size, 'fit', true, `fits ${width}×720, ${width}×${short} and fullscreen ${full.width}×${full.height}`);
751
893
  check(size, 'errors', errors.length === 0, errors.length ? [...new Set(errors)].slice(0, 4).join(' | ') : 'none');
752
894
  network(size, outside, refused, rtcCount(), context.serviceWorkers().length, reports());
753
895
  keepConsole();
754
896
  const shot = await page.screenshot().catch(() => null);
755
897
  if (shot) {
756
- writeFileSync(`${opt('shots', process.cwd())}/conformance-${SLUG}-${size}.png`, shot);
898
+ // Keep the first failure's image; the later fullscreen measurement
899
+ // must not replace the state diagnostics[size] describes.
900
+ if (!diagnostics[size]?.screenshot) writeFileSync(`${opt('shots', process.cwd())}/conformance-${SLUG}-${size}.png`, shot);
757
901
  // A frame of one flat colour: the game drew nothing the screenshot can see
758
902
  // (a canvas the browser could not draw, or a board that never painted).
759
903
  // A warning for the reviewer for now: the run's software WebGL is new.
@@ -761,6 +905,9 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
761
905
  try { flat = flatShare(shot); } catch { /* not a picture we can read: say nothing */ }
762
906
  if (flat !== null) check(size, 'picture', true, flat >= 0.98 ? `the screenshot is one flat colour (${Math.round(flat * 100)}% of it): the game may draw nothing a player can see` : 'the screenshot shows the game', flat >= 0.98 ? 'warning' : undefined);
763
907
  }
908
+ } finally {
909
+ await context.close().catch(() => {});
910
+ }
764
911
  }
765
912
 
766
913
  // ---- time travel: the matrix of clocks, zones and languages ------------------------------
@@ -779,7 +926,7 @@ let timeTravel = null;
779
926
  if (TRAVEL) {
780
927
  const now = new Date();
781
928
  const matrixStart = Date.now();
782
- const variants = variantsFor(TRAVEL, now).filter((v) => !TRAVEL_ONLY || TRAVEL_ONLY.includes(v.id)).map((v) => ({ ...v, rounds: v.rounds ? TRAVEL_ROUNDS : undefined, at: clockFor(v, now).toISOString() }));
929
+ const variants = withClocks(variantsFor(TRAVEL, now), TRAVEL_CLOCKS, now).filter((v) => !TRAVEL_ONLY || TRAVEL_ONLY.includes(v.id)).map((v) => ({ ...v, rounds: v.rounds ? TRAVEL_ROUNDS : undefined, at: v.at || clockFor(v, now).toISOString() }));
783
930
  const manifest = opt('manifest', '') && existsSync(opt('manifest')) ? JSON.parse(readFileSync(opt('manifest'), 'utf8')) : null;
784
931
  const { shows, ...display } = DISPLAYS.desktop; // the frame-size run's pairing at 1194 px
785
932
  const shotsDir = opt('shots', process.cwd());
@@ -961,18 +1108,29 @@ if (TRAVEL) {
961
1108
  // not counted by its clock while the site's and the shell's run on;
962
1109
  // those drift ahead of the game's by that much (clockSkewMs records how
963
1110
  // far), which the game cannot see (other origins) and the server does
964
- // not follow. A frame that did not answer within 10 s is a frame whose
1111
+ // not follow. A frame that did not answer within 60 s is a frame whose
965
1112
  // clock did not move: the variant breaks rather than judging a game on
966
- // clocks that disagree.
1113
+ // clocks that disagree. An answer after 10 s is the job's software
1114
+ // graphics behind a game that draws every frame (the answer waits for
1115
+ // the frames queued before it), not the game's clock: the variant goes
1116
+ // on and says how long it waited (slowJumpMs).
967
1117
  const advance = async (op, ms) => {
968
1118
  let timer;
969
- const r = await Promise.race([
970
- page.evaluate(siteJump, [RUN_TOKEN, op, ms]),
971
- new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 10000); }),
972
- ]).finally(() => clearTimeout(timer)).catch(async (e) => {
1119
+ const started = Date.now();
1120
+ const answer = page.evaluate(siteJump, [RUN_TOKEN, op, ms]);
1121
+ const within = (limit) => Promise.race([answer, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), limit); })]).finally(() => clearTimeout(timer));
1122
+ const r = await within(10000).catch(() => within(50000)).catch(async (e) => {
973
1123
  const tally = await page.evaluate(() => window.__clock).catch(() => null);
974
- throw new Error(`the fake clock did not move (${op} ${ms} ms) in every frame within 10 s${tally ? `: ${tally.from.length} of ${tally.posted} window(s) answered (${tally.from.join(', ')})` : ''}: ${e.message}`);
1124
+ // Every window of the game answered and only ours did not (the
1125
+ // site's page, the shell): the checker's own lag, said first,
1126
+ // since the line is cut at 160 characters. Still a break: a
1127
+ // game must not win a skipped variant by slowing the checker.
1128
+ const ours = tally && checkerOnlySilent(page.frames().map((f) => ({ url: f.url(), game: isGameFrame(f) })), tally.from, tally.posted);
1129
+ if (ours) throw new Error(`the checker's own page did not keep up with the clock (${op} ${ms} ms) within 60 s, while every window of the game did (${tally.from.length} of ${tally.posted} answered, silent: ${ours.join(' ')})`);
1130
+ throw new Error(`the fake clock did not move (${op} ${ms} ms) in every frame within 60 s${tally ? `: ${tally.from.length} of ${tally.posted} window(s) answered (${tally.from.join(', ')})` : ''}: ${e.message}`);
975
1131
  });
1132
+ const waited = Date.now() - started;
1133
+ if (waited > 10000) v.slowJumpMs = Math.max(v.slowJumpMs || 0, waited);
976
1134
  const far = Math.max(...r.nows), near = Math.min(...r.nows);
977
1135
  v.clockSkewMs = Math.max(v.clockSkewMs || 0, far - near);
978
1136
  const game = r.nows.filter((_, i) => String(r.from[i] || '').startsWith(ORIGIN.studio));
@@ -997,6 +1155,7 @@ if (TRAVEL) {
997
1155
  const pageDialogs = [];
998
1156
  page.on('dialog', (d) => { pageDialogs.push(d.type()); d.dismiss().catch(() => {}); });
999
1157
  const m = instrument(page);
1158
+ const consoleLines = consoleRecorder(page, RUN_TOKEN);
1000
1159
  const h = helpers(page);
1001
1160
  // The game's storage as it stood before the walk (at the variant's own
1002
1161
  // instant), for a variant whose clock is not past the walk's end.
@@ -1101,11 +1260,23 @@ if (TRAVEL) {
1101
1260
  let done = h.roundEnds(await h.log()) > ends;
1102
1261
  for (let k = 0; k < ROUND_GAME_MS / STEP_MS && !done; k++) {
1103
1262
  await step();
1104
- if (hbtest.then?.length) await h.thenSteps(hbtest.then);
1263
+ if (hbtest.then?.length) await h.thenSteps(hbtest.then, 3);
1105
1264
  done = h.roundEnds(await h.log()) > ends;
1106
1265
  }
1107
1266
  if (!done) { v.stuck = `round ${i + 1} did not end within ${ROUND_GAME_MS / 1000} s of game time${await screenSays(page)}`; break; }
1108
1267
  v.settled++;
1268
+ // The first round of each new forced row (a loss, then each pay
1269
+ // tier): its screen kept, with what the maths pays for it, for
1270
+ // the reviewer's "what the screen shows matches what was paid".
1271
+ if (rows && i >= 1 && (v.outcomes ||= []).length < OUTCOME_SHOTS && !v.outcomes.some((o) => o.row === rows[i])) {
1272
+ const name = outcomeShotName(v.outcomes.length + 1);
1273
+ const png = await page.screenshot().catch(() => null);
1274
+ if (png) {
1275
+ writeFileSync(join(shotsDir, name), png);
1276
+ const played = lastPlayBody && typeof lastPlayBody.mode === 'string' ? { mode: lastPlayBody.mode, bet: lastPlayBody.bet } : null;
1277
+ v.outcomes.push({ row: rows[i], multBp: tierOf(manifest, played, rows[i]), mode: played?.mode || '', bet: played?.bet || '', shot: name });
1278
+ }
1279
+ }
1109
1280
  // The two clocks read against each other every so often: the
1110
1281
  // server must not fall behind the game over a long session.
1111
1282
  if (v.settled % 25 === 0) await syncClock(page);
@@ -1120,7 +1291,11 @@ if (TRAVEL) {
1120
1291
  }
1121
1292
  }
1122
1293
  await record(v, page, m);
1123
- if (v.rounds) await shotTo(page, shotName(v.id), 'boot', v.id);
1294
+ // The picture after the session is the reviewer's, not a verdict:
1295
+ // the session is over, and a capture the job's software renderer
1296
+ // could not make in 30 s is reported as missing (a warning that
1297
+ // names both causes) instead of breaking the session.
1298
+ if (v.rounds) await shotTo(page, shotName(v.id), 'boot', v.id).catch((e) => { v.shotMissing = e.message.split('\n')[0].slice(0, 160); });
1124
1299
  }
1125
1300
  } catch (e) {
1126
1301
  v.broke = e.message.split('\n')[0].slice(0, 160);
@@ -1133,6 +1308,11 @@ if (TRAVEL) {
1133
1308
  let bound;
1134
1309
  await Promise.race([record(v, page, m), new Promise((r) => { bound = setTimeout(r, 5000); })]).catch(() => {}).finally(() => clearTimeout(bound));
1135
1310
  }
1311
+ if (!v.boot || v.broke) {
1312
+ const shot = failureShotName(v.id);
1313
+ v.diagnostics = await failureEvidence(page, { console: consoleLines, runner: runnerInfo(await browserFor(v.tz)), started, shot, shotFile: join(shotsDir, shot), log: h.log, initReceived: m.initReceived() });
1314
+ if (v.diagnostics.screenshot) v.failureShot = shot;
1315
+ }
1136
1316
  v.requests = [...new Set(m.outside)];
1137
1317
  v.refused = [...new Set(m.refused)];
1138
1318
  v.errors = [...new Set([...m.errors, ...v.errors])];
@@ -1209,9 +1389,16 @@ await Promise.race([Promise.all([...browsers.values()].map((b) => b.close())), n
1209
1389
  servers.forEach((s) => s.close());
1210
1390
 
1211
1391
  const ok = results.every((r) => r.ok);
1212
- if (args.includes('--json')) { console.log(JSON.stringify({ ok, slug: SLUG, sandbox: !UNSANDBOXED, softwareWebGL: SOFTWARE_WEBGL.length > 0, checks: results, ...(Object.keys(frameConsole).length ? { console: frameConsole } : {}), ...(timeTravel ? { timeTravel } : {}) })); process.exit(ok ? 0 : 1); }
1213
- console.log(`${ok ? 'PASS' : 'FAIL'} conformance ${SLUG}${TRAVEL ? ` time travel (${TRAVEL})` : ''}${UNSANDBOXED ? ' (Chrome sandbox OFF)' : ''}`);
1214
- for (const r of results) console.log(` ${r.status === 'skipped' ? 'skip' : r.status === 'warning' ? 'warn' : r.ok ? 'ok ' : 'FAIL'} ${r.size.padEnd(8)} ${r.rule.padEnd(8)} ${r.detail}`);
1215
- if (timeTravel) for (const v of timeTravel.variants) console.log(` ${v.id.padEnd(8)} ${((v.ms || 0) / 1000).toFixed(1)}s${v.visual ? ` visual ${Math.round(v.visual.changed * 100)}%` : ''}${v.visualLate ? ` late ${Math.round(v.visualLate.changed * 100)}%` : ''}${v.rounds ? ` settled ${v.settled || 0}/${v.rounds}${v.forcedAsked ? ` forced ${v.forcedApplied || 0}/${v.forcedAsked}` : ''}` : ''}${v.betControl ? ` bet ${v.betControl.tag}${v.betControl.id ? '#' + v.betControl.id : ''} ${v.betControl.w}×${v.betControl.h}` : ''}${v.clockSkewMs ? ` skew ${v.clockSkewMs}ms` : ''}${v.clockDriftMs ? ` drift ${v.clockDriftMs}ms` : ''}${v.unshimmedWorkers ? ` unshimmed ${v.unshimmedWorkers}` : ''}${v.skipped ? ' skipped' : ''}`);
1392
+ const incomplete = results.filter((r) => r.status === 'skipped').map((r) => ({ size: r.size, rule: r.rule, detail: r.detail }));
1393
+ for (const v of timeTravel?.variants || []) if (v.rounds && v.boot && !v.skipped && !v.broke && !v.stuck && (v.settled || 0) < v.rounds) incomplete.push({ size: v.id, rule: 'time-travel', detail: `${v.settled || 0} of ${v.rounds} rounds completed` });
1394
+ const coverage = { complete: incomplete.length === 0, incomplete };
1395
+ // What each failure means and what to change (hints.mjs): in the report the
1396
+ // dashboard shows, and under the line here.
1397
+ for (const r of results) { const hint = hintFor(r); if (hint) r.hint = hint; }
1398
+ if (args.includes('--json')) { console.log(JSON.stringify({ ok, slug: SLUG, sandbox: !UNSANDBOXED, softwareWebGL: SOFTWARE_WEBGL.length > 0, checks: results, ...(Object.keys(diagnostics).length ? { diagnostics } : {}), ...(bench ? { bench } : {}), ...(Object.keys(frameConsole).length ? { console: frameConsole } : {}), ...(timeTravel ? { timeTravel } : {}) })); process.exit(ok ? 0 : 1); }
1399
+ if (bench) console.log(`machine: JavaScript ${bench.jsMs} ms, 2D canvas ${bench.canvasMs} ms, WebGL ${bench.webglMs ?? '-'} ms (the upload's job is the yardstick)`);
1400
+ console.log(`${ok ? (coverage.complete ? 'PASS' : 'PASS WITH INCOMPLETE COVERAGE') : 'FAIL'} conformance ${SLUG}${TRAVEL ? ` time travel (${TRAVEL})` : ''}${UNSANDBOXED ? ' (Chrome sandbox OFF)' : ''}`);
1401
+ for (const r of results) console.log(` ${r.status === 'skipped' ? 'skip' : r.status === 'warning' ? 'warn' : r.ok ? 'ok ' : 'FAIL'} ${r.size.padEnd(8)} ${r.rule.padEnd(8)} ${r.detail}${r.hint ? `\n → ${r.hint}` : ''}`);
1402
+ if (timeTravel) for (const v of timeTravel.variants) console.log(` ${v.id.padEnd(8)} ${((v.ms || 0) / 1000).toFixed(1)}s${v.visual ? ` visual ${Math.round(v.visual.changed * 100)}%` : ''}${v.visualLate ? ` late ${Math.round(v.visualLate.changed * 100)}%` : ''}${v.rounds ? ` settled ${v.settled || 0}/${v.rounds}${v.forcedAsked ? ` forced ${v.forcedApplied || 0}/${v.forcedAsked}` : ''}` : ''}${v.betControl ? ` bet ${v.betControl.tag}${v.betControl.id ? '#' + v.betControl.id : ''} ${v.betControl.w}×${v.betControl.h}` : ''}${v.clockSkewMs ? ` skew ${v.clockSkewMs}ms` : ''}${v.slowJumpMs ? ` slow jump ${(v.slowJumpMs / 1000).toFixed(1)}s` : ''}${v.clockDriftMs ? ` drift ${v.clockDriftMs}ms` : ''}${v.unshimmedWorkers ? ` unshimmed ${v.unshimmedWorkers}` : ''}${v.skipped ? ' skipped' : ''}`);
1216
1403
  if (timeTravel?.storage) console.log(` storage ${timeTravel.storage.from}: ${timeTravel.storage.keys} key(s) carried in${timeTravel.storage.kept !== undefined ? `, ${timeTravel.storage.kept} kept` : ''}`);
1217
1404
  process.exit(ok ? 0 : 1);