From 5b222f939bc9c0045d5d929ba2dbdd929edd39bc Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 11:03:02 +0300 Subject: [PATCH 1/7] =?UTF-8?q?[feat]=20pipeline-1=20P1:=20desk=20battery?= =?UTF-8?q?=20=E2=80=94=20end-of-half=20retry,=20per-check=20fingerprints,?= =?UTF-8?q?=20release=20baselines,=20flake=20log,=20coverage=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/ci/battery-desk.cjs runs tests/e2e/battery.json the way an integrator does (one half per e2e slot, the self-hosted / production-host / modules-flight cases from data), reruns every red once at the end of its half, fingerprints each failed check, saves a per-tag baseline and diffs a union run against it (pre-existing / flaky / NEW). battery.json gains modules, desk and exclude; the 1.43 battery's extra suites join as optional on runners. tests/unit/batteryDesk.test.js: every e2e file is listed or excluded. Co-Authored-By: Claude Opus 5.5 (1M context) --- RELEASING.md | 11 + scripts/ci/battery-desk.cjs | 426 +++++++++++++++++++++++++++++++++ scripts/ci/battery.cjs | 14 +- tests/e2e/battery.json | 80 ++++++- tests/unit/batteryDesk.test.js | 77 ++++++ 5 files changed, 604 insertions(+), 4 deletions(-) create mode 100644 scripts/ci/battery-desk.cjs create mode 100644 tests/unit/batteryDesk.test.js diff --git a/RELEASING.md b/RELEASING.md index 768e5289..ba5c034a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -85,6 +85,17 @@ released version instead of drifting (it carried a stale 1.8.0 before 1.11.0). ``` (plus every changed module file; the same three lines with `scenes`/`format-2` and `packs`/`format-1`). A modules merge alone no longer reaches production's gallery. +- **Record the release's known reds** (the desk battery's baseline, pipeline-1): right after the tag, run the + battery once ON THE TAG — two halves, each on a pc slot where the e2e-slot setup has them — and save it: + ```sh + node scripts/ci/battery-desk.cjs run --half A --root --out --name blA # and --half B + node scripts/ci/battery-desk.cjs baseline save vX.Y.Z + ``` + The next integrator runs the same halves on its union and calls `battery-desk.cjs diff --baseline vX.Y.Z `: + a red whose every twice-red check is red on the tag too is PRE-EXISTING (no A/B run), a red that went green on + the end-of-half retry is FLAKY (logged; `battery-desk.cjs flakes` prints the rates), and only NEW reds need a look. + A new `tests/e2e/*.test.cjs` goes into `tests/e2e/battery.json` (suites, or `exclude` with a reason) — + `tests/unit/batteryDesk.test.js` fails on a file in neither. - The peers warn (never block) on version mismatches, and `.tpscene`/`.tpmodule` files confirm before loading a NEWER format int — older files always load silently. Bump `SESSION_FORMAT`/`MODULE_FORMAT` only when the shape actually diff --git a/scripts/ci/battery-desk.cjs b/scripts/ci/battery-desk.cjs new file mode 100644 index 00000000..90bf0bda --- /dev/null +++ b/scripts/ci/battery-desk.cjs @@ -0,0 +1,426 @@ +#!/usr/bin/env node +// THE INTEGRATORS' BATTERY ON A DESK, AGAINST A BASELINE OF THE LAST RELEASE. +// +// battery.cjs runs tests/e2e/battery.json in shards on GitHub runners. This is the same list run the +// way an integrator runs it before a release: one half at a time on an e2e slot, against a dev server +// the slot started, with the suites a public runner skips (the self-hosted signaling box, the +// production hostname mapped here, the modules repo's flights). What it adds over a plain loop: +// +// - EVERY RED RUNS ONCE MORE, AT THE END OF THE HALF, not straight after. A red that came from the +// moment (a sibling's build filling the box, a cold vite pre-bundle) meets a different moment the +// second time. Green then = flaky: logged with its checks, never handed to the integrator. +// - EVERY FAILED CHECK GETS A FINGERPRINT (suite + the FAIL line with numbers, JSON and paths taken +// out), so "the same red as on the release" is a set lookup, not an A/B run. +// - A BASELINE per release tag: the battery once on the tag right after the release (`baseline`). +// `diff` sorts a run's reds into pre-existing (every check red in both runs is red on the tag too), +// flaky (no check red twice) and NEW — the only ones that need the integrator. +// - ONE SUMMARY LINE at the end (stdout, and /.done), so a lane that started this in the +// background is woken by the exit, not by polling. +// +// node scripts/ci/battery-desk.cjs run --half A [--of 2] [--out dir] [--name u1A] [--root tree] [--no-retry] +// node scripts/ci/battery-desk.cjs run --only a,b,MOD:untangle ... +// node scripts/ci/battery-desk.cjs baseline save ... # record a tag's runs +// node scripts/ci/battery-desk.cjs diff [--baseline tag|file] ... # exit 1 when anything is NEW +// node scripts/ci/battery-desk.cjs flakes [--days 30] # flake rate per suite +// node scripts/ci/battery-desk.cjs coverage # every e2e file listed or excluded +// +// State (baselines, the outcome log) lives in $BATTERY_STATE, default ~/.local/state/theprototype/battery. +// The environment a suite reads (APP_URL, MODULES_REPO, the *_TPSCENE paths, PEER_CONFIG_SELF) is the +// caller's: source the integrator's env file first, exactly as for the old battery.sh. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); +const { load, assign, counts } = require('./battery.cjs'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const STATE = process.env.BATTERY_STATE || path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), 'theprototype', 'battery'); + +/** + * @typedef {import('./battery.cjs').Battery & { modules?: Record, desk?: { 'prod-host'?: string[], 'peer-self'?: string[], gpu?: string[] }, + * exclude?: Record }} DeskBattery + * @typedef {{ rc: number|null, secs: number, pass: number, fail: number, last: string, fps: string[] }} Attempt + * @typedef {{ name: string, status: 'pass'|'flaky'|'red'|'gone', first: Attempt, retry?: Attempt, fps: string[] }} DeskResult + * @typedef {{ name: string, root: string, commit: string, slot: string, started: string, secs: number, results: DeskResult[] }} DeskRun + * @typedef {{ tag: string, commit: string, made: string, runs: string[], suites: Record }} Baseline + */ + +// ---------------------------------------------------------------- fingerprints + +/** + * One failed check as a stable key: the line a suite printed for it, without what changes from run to + * run — numbers (ms, px, counts, line:col), JSON details, absolute paths, colour codes. "dock resize + * widens (320 → 440)" and "(318 → 437)" are the same check failing; "the Cut button could not be + * clicked" and "the Cut button is disabled" are not. + * @param {string} line + */ +function normalise(line) { + return line + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/^\s*(✗|FAIL|not ok)\s*:?\s*/, '') + .replace(/\{.*\}/g, '{…}') + .replace(/(?:\/[\w.@+-]+)+\/([\w.@+-]+)/g, '$1') + .replace(/0x[0-9a-f]+/gi, '#') + .replace(/\d+(?:\.\d+)?/g, '#') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 200); +} + +/** + * The fingerprints of one suite's log: one per FAIL line, the SCRIPT FAILED message, a timeout, and — + * when a red log names none of those (a crash before the first check) — its last line. + * @param {string} suite @param {string} log @param {number|null} rc + * @returns {string[]} + */ +function fingerprints(suite, log, rc) { + if (rc === 0) return []; + const out = new Set(); + for (const l of log.split('\n')) { + if (/^\s*(?:✗|FAIL\b|not ok\b)/.test(l)) out.add(`${suite} :: ${normalise(l)}`); + else if (/^\s*SCRIPT FAILED/.test(l)) out.add(`${suite} :: ${normalise(l)}`); + } + if (/\[battery\] TIMEOUT/.test(log)) out.add(`${suite} :: TIMEOUT`); + if (!out.size) out.add(`${suite} :: rc=${rc} ${normalise((log.split('\n').filter((l) => l.trim()).pop() || '').slice(0, 200))}`); + return [...out].sort(); +} + +// ---------------------------------------------------------------- the run + +/** the desk set: every listed suite (skip is about public runners) and the modules flights @param {DeskBattery} b */ +function deskEntries(b) { + return /** @type {[string, number][]} */ ([...Object.entries(b.suites), ...Object.entries(b.modules || {}).map(([m, s]) => [`MOD:${m}`, s])]); +} + +/** @param {DeskBattery} b @param {string} half @param {number} of */ +function half(b, half, of) { + const i = half.toUpperCase().charCodeAt(0) - 65; + const shards = assign({ ...b, skip: {}, suites: Object.fromEntries(deskEntries(b)) }, of); + if (!(i >= 0 && i < of)) throw new Error(`--half ${half}: there are ${of} halves (A..${String.fromCharCode(64 + of)})`); + return shards[i].suites; +} + +/** @param {string} url @returns {Promise} */ +function reachable(url) { + const u = new URL(url); + return new Promise((resolve) => { + const req = require('https').get({ host: u.hostname, port: u.port, path: u.pathname, rejectUnauthorized: false, timeout: 300000 }, (res) => { + res.resume(); + res.on('end', () => resolve((res.statusCode || 0) < 500)); + }); + req.on('timeout', () => req.destroy()); + req.on('error', () => resolve(false)); + }); +} + +/** + * One suite, the way battery.sh ran it: node in the tree, the caller's environment, the per-suite + * cases the list names (desk.prod-host: APP_URL on the production hostname, which /etc/hosts maps + * here; desk.peer-self: the self-hosted signaling box; MOD:x: the modules repo's runner, GPU ones + * with E2E_GPU=1). + * @param {DeskBattery} b @param {string} name @param {string} root @param {string} logFile + * @returns {Promise} + */ +function attempt(b, name, root, logFile) { + const env = { ...process.env }; + let cmd = ['node', path.join(root, 'tests', 'e2e', `${name}.test.cjs`)]; + let cwd = root; + let limit = 1500; + if (name.startsWith('MOD:')) { + const m = name.slice(4); + cwd = process.env.MOD_REPO || process.env.MODULES_REPO || ''; + cmd = ['node', 'tests/run.cjs', m]; + if (b.desk?.gpu?.includes(name)) env.E2E_GPU = '1'; + else limit = 1200; + } else if (b.desk?.['prod-host']?.includes(name)) env.APP_URL = (env.APP_URL || '').replace('localhost', 'theprototype.app'); + else if (b.desk?.['peer-self']?.includes(name)) env.PEER_CONFIG = env.PEER_CONFIG_SELF || ''; + const fd = fs.openSync(logFile, 'w'); + const t0 = Date.now(); + return new Promise((resolve) => { + const child = spawn(cmd[0], cmd.slice(1), { cwd: cwd || root, env, stdio: ['ignore', fd, fd], detached: true }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + try { + process.kill(-(child.pid || 0), 'SIGKILL'); + } catch {} + }, limit * 1000); + const done = (/** @type {number|null} */ code) => { + clearTimeout(timer); + fs.closeSync(fd); + if (timedOut) fs.appendFileSync(logFile, `\n[battery] TIMEOUT after ${limit}s\n`); + const text = fs.readFileSync(logFile, 'utf8'); + const rc = timedOut ? null : code; + resolve({ rc, secs: Math.round((Date.now() - t0) / 1000), ...counts(text), fps: fingerprints(name, text, rc) }); + }; + child.on('exit', done); + child.on('error', () => done(127)); + }); +} + +/** battery.sh's summary line, so the integrators' greps and `battery.cjs refresh --summary` still read it @param {string} name @param {Attempt} a */ +function line(name, a) { + return `${name} rc=${a.rc} pass~${a.pass} fail~${a.fail} ${a.secs}s :: ${a.last.slice(0, 140)}`; +} + +/** @param {string} root */ +function commitOf(root) { + try { + return require('child_process').execFileSync('git', ['-C', root, 'describe', '--tags', '--always', '--dirty'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return '?'; + } +} + +/** @param {object} row */ +function logOutcome(row) { + try { + fs.mkdirSync(STATE, { recursive: true }); + fs.appendFileSync(path.join(STATE, 'outcomes.jsonl'), JSON.stringify(row) + '\n'); + } catch {} +} + +/** @param {string[]} argv @param {string} flag */ +function opt(argv, flag) { + const i = argv.indexOf(flag); + return i >= 0 ? argv[i + 1] : undefined; +} + +/** @param {string[]} argv */ +async function run(argv) { + const b = /** @type {DeskBattery} */ (load(opt(argv, '--battery'))); + const root = path.resolve(opt(argv, '--root') || ROOT); + const of = Number(opt(argv, '--of') || 2); + const h = opt(argv, '--half'); + const only = opt(argv, '--only'); + const list = opt(argv, '--list'); + const names = only ? only.split(',') : list ? fs.readFileSync(list, 'utf8').split(/\s+/).filter(Boolean) : h ? half(b, h, of) : deskEntries(b).map(([n]) => n); + const name = opt(argv, '--name') || (h ? `half${h.toUpperCase()}` : 'run'); + const out = path.resolve(opt(argv, '--out') || process.env.BATTERY_OUT || path.join(root, 'test-results', 'battery')); + const retry = !argv.includes('--no-retry'); + const url = process.env.APP_URL || ''; + fs.mkdirSync(path.join(out, 'logs'), { recursive: true }); + const sumFile = path.join(out, `${name}.txt`); + const slot = process.env.E2E_SLOT_PC ? `pc:${process.env.E2E_SLOT_PC}` : process.env.E2E_SLOT_REMOTE ? 'remote' : process.env.E2E_SLOT ? 'local' : 'none'; + const commit = commitOf(root); + const started = new Date().toISOString(); + const t0 = Date.now(); + fs.writeFileSync(sumFile, `snapshot: ${commit} root=${root} url=${url} slot=${slot} suites=${names.length}\n`); + const say = (/** @type {string} */ s) => { + console.log(s); + fs.appendFileSync(sumFile, s + '\n'); + }; + /** @type {Map} */ + const results = new Map(); + // the dev server's first SSR + client graph is the slow load; one warm-up so the first suite does not pay it + if (url) await reachable(url); + for (const n of names) { + if (!n.startsWith('MOD:') && !fs.existsSync(path.join(root, 'tests', 'e2e', `${n}.test.cjs`))) { + const a = { rc: null, secs: 0, pass: 0, fail: 0, last: 'no such suite in this tree', fps: [] }; + results.set(n, { name: n, status: 'gone', first: a, fps: [] }); + say(`${n} rc=gone :: no such suite in this tree`); + continue; + } + // a dead dev server turns every later suite red for the wrong reason: wait for it, say so + if (url && !n.startsWith('MOD:') && !(await reachable(url))) say(`[battery] dev server unreachable before ${n}`); + const a = await attempt(b, n, root, path.join(out, 'logs', `${n.replace(':', '-')}.log`)); + results.set(n, { name: n, status: a.rc === 0 ? 'pass' : 'red', first: a, fps: a.fps }); + say(line(n, a)); + for (const f of a.fps) fs.appendFileSync(sumFile, ` ${f.split(' :: ')[1]}\n`); + } + const reds = [...results.values()].filter((r) => r.status === 'red'); + if (retry && reds.length) { + say(`[battery] retry: ${reds.length} red suite(s) once more, at the end of the half`); + for (const r of reds) { + const a = await attempt(b, r.name, root, path.join(out, 'logs', `${r.name.replace(':', '-')}.retry.log`)); + r.retry = a; + if (a.rc === 0) { + r.status = 'flaky'; + r.fps = []; + } else { + // a check red in BOTH runs is the real one; checks red once are the moment's + const both = r.first.fps.filter((f) => a.fps.includes(f)); + r.fps = both.length ? both : a.fps; + } + say(`retry ${line(r.name, a)}${a.rc === 0 ? ' -> FLAKY' : ''}`); + } + } + for (const r of results.values()) { + logOutcome({ t: started, commit, slot, suite: r.name, status: r.status, first: r.first.fps, retry: r.retry?.fps }); + } + /** @type {DeskRun} */ + const rec = { name, root, commit, slot, started, secs: Math.round((Date.now() - t0) / 1000), results: [...results.values()] }; + fs.writeFileSync(path.join(out, `${name}.json`), JSON.stringify(rec, null, 1)); + const all = [...results.values()]; + const n = (/** @type {string} */ s) => all.filter((r) => r.status === s).length; + const redNames = all.filter((r) => r.status === 'red').map((r) => r.name); + const summary = `BATTERY-DONE ${name} @${commit} ${slot}: ${all.length} suites, ${n('pass')} green, ${n('flaky')} flaky, ${redNames.length} red${redNames.length ? ` (${redNames.join(' ')})` : ''}, ${n('gone')} gone, ${Math.round(rec.secs / 60)} min -> ${path.join(out, name + '.json')}`; + say(summary); + fs.writeFileSync(path.join(out, `${name}.done`), summary + '\n'); + process.exit(redNames.length ? 1 : 0); +} + +// ---------------------------------------------------------------- baselines and the diff + +/** @param {string[]} dirs @returns {DeskRun[]} */ +function readRuns(dirs) { + /** @type {DeskRun[]} */ + const runs = []; + for (const d of dirs) { + const st = fs.statSync(d); + const files = st.isDirectory() ? fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => path.join(d, f)) : [d]; + for (const f of files) { + const j = JSON.parse(fs.readFileSync(f, 'utf8')); + if (Array.isArray(j.results) && j.started) runs.push(j); + } + } + return runs; +} + +/** @param {string} tag @param {string[]} dirs */ +function baselineSave(tag, dirs) { + const runs = readRuns(dirs); + if (!runs.length) throw new Error(`no desk runs (.json) in ${dirs.join(' ')}`); + /** @type {Baseline} */ + const bl = { tag, commit: runs[0].commit, made: new Date().toISOString(), runs: runs.map((r) => `${r.name} ${r.slot} ${r.started}`), suites: {} }; + for (const r of runs) { + for (const s of r.results) { + // a baseline remembers EVERY check it saw red, on either attempt: a flaky check on the tag is + // a known one on the union too + const fps = [...new Set([...s.first.fps, ...(s.retry?.fps || [])])].sort(); + bl.suites[s.name] = { status: s.status, fps }; + } + } + const dir = path.join(STATE, 'baselines'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `${tag}.json`); + fs.writeFileSync(file, JSON.stringify(bl, null, 1)); + const by = (/** @type {string} */ s) => Object.values(bl.suites).filter((v) => v.status === s).length; + console.log(`baseline ${tag} @${bl.commit}: ${Object.keys(bl.suites).length} suites (${by('pass')} green, ${by('flaky')} flaky, ${by('red')} red, ${by('gone')} gone) -> ${file}`); +} + +/** the newest saved baseline, by file time, when the caller names none */ +function latestBaseline() { + const dir = path.join(STATE, 'baselines'); + const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.json')) : []; + files.sort((x, y) => fs.statSync(path.join(dir, y)).mtimeMs - fs.statSync(path.join(dir, x)).mtimeMs); + return files[0] ? path.join(dir, files[0]) : ''; +} + +/** + * @param {Baseline} bl @param {DeskRun[]} runs + * @returns {{ green: string[], flaky: string[], known: string[], fresh: { name: string, fps: string[], why: string }[], gone: string[] }} + */ +function classify(bl, runs) { + /** @type {ReturnType} */ + const out = { green: [], flaky: [], known: [], fresh: [], gone: [] }; + for (const r of runs) { + for (const s of r.results) { + if (s.status === 'pass') out.green.push(s.name); + else if (s.status === 'flaky') out.flaky.push(s.name); + else if (s.status === 'gone') out.gone.push(s.name); + else { + const base = bl.suites[s.name]; + const unknown = s.fps.filter((f) => !base?.fps.includes(f)); + if (!unknown.length) out.known.push(s.name); + else out.fresh.push({ name: s.name, fps: unknown, why: !base ? 'not in the baseline' : base.status === 'pass' ? 'green on the baseline' : 'a check the baseline did not fail' }); + } + } + } + return out; +} + +/** @param {string[]} argv */ +function diff(argv) { + const named = opt(argv, '--baseline'); + const file = !named ? latestBaseline() : named.endsWith('.json') ? named : path.join(STATE, 'baselines', `${named}.json`); + if (!file || !fs.existsSync(file)) throw new Error(`no baseline ${named || '(none saved)'} — run the battery on the release tag and \`baseline save\` it`); + const dirs = argv.filter((a, i) => !a.startsWith('--') && argv[i - 1] !== '--baseline'); + /** @type {Baseline} */ + const bl = JSON.parse(fs.readFileSync(file, 'utf8')); + const c = classify(bl, readRuns(dirs)); + console.log(`vs baseline ${bl.tag} @${bl.commit}: ${c.green.length} green, ${c.flaky.length} flaky (green on the retry), ${c.known.length} pre-existing, ${c.fresh.length} NEW, ${c.gone.length} gone`); + if (c.flaky.length) console.log(` flaky: ${c.flaky.join(' ')}`); + if (c.known.length) console.log(` pre-existing (same checks red on ${bl.tag}; no A/B needed): ${c.known.join(' ')}`); + for (const f of c.fresh) { + console.log(` NEW ${f.name} — ${f.why}`); + for (const fp of f.fps) console.log(` ${fp.split(' :: ')[1]}`); + } + process.exit(c.fresh.length ? 1 : 0); +} + +/** @param {string[]} argv */ +function flakes(argv) { + const days = Number(opt(argv, '--days') || 30); + const since = Date.now() - days * 86400e3; + const file = path.join(STATE, 'outcomes.jsonl'); + /** @type {Record} */ + const per = {}; + const rows = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean) : []; + for (const l of rows) { + const r = JSON.parse(l); + if (Date.parse(r.t) < since || r.status === 'gone') continue; + const p = (per[r.suite] ||= { runs: 0, flaky: 0, red: 0 }); + p.runs++; + if (r.status === 'flaky') p.flaky++; + if (r.status === 'red') p.red++; + } + const list = Object.entries(per).filter(([, p]) => p.flaky).sort((x, y) => y[1].flaky / y[1].runs - x[1].flaky / x[1].runs || (x[0] < y[0] ? -1 : 1)); + const runs = Object.values(per).reduce((t, p) => t + p.runs, 0); + const flaky = Object.values(per).reduce((t, p) => t + p.flaky, 0); + console.log(`last ${days} days: ${runs} suite runs, ${flaky} flaky (${runs ? ((100 * flaky) / runs).toFixed(1) : 0} %), ${list.length} suites ever flaky`); + for (const [n, p] of list) console.log(` ${((100 * p.flaky) / p.runs).toFixed(0).padStart(3)} % ${n} (${p.flaky} of ${p.runs} runs flaky, ${p.red} red twice)`); +} + +// ---------------------------------------------------------------- coverage + +/** + * Every tests/e2e/*.test.cjs is in the battery or excluded with a reason, and nothing listed is + * missing. A suite nobody listed is a suite no release ever runs — the reason this check exists. + * @param {DeskBattery} b @param {string[]} files suite names on disk + */ +function coverage(b, files) { + const excluded = new Set(Object.values(b.exclude || {}).flat()); + const listed = new Set([...Object.keys(b.suites), ...excluded]); + return { + unlisted: files.filter((f) => !listed.has(f)), + missing: [...listed].filter((n) => !files.includes(n)), + both: Object.keys(b.suites).filter((n) => excluded.has(n)) + }; +} + +function e2eFiles(root = ROOT) { + return fs + .readdirSync(path.join(root, 'tests', 'e2e')) + .filter((f) => f.endsWith('.test.cjs')) + .map((f) => f.slice(0, -'.test.cjs'.length)) + .sort(); +} + +if (require.main === module) { + const [cmd, ...argv] = process.argv.slice(2); + try { + if (cmd === 'run') run(argv); + else if (cmd === 'baseline' && argv[0] === 'save' && argv[1]) baselineSave(argv[1], argv.slice(2)); + else if (cmd === 'diff') diff(argv); + else if (cmd === 'flakes') flakes(argv); + else if (cmd === 'coverage') { + const c = coverage(/** @type {DeskBattery} */ (load()), e2eFiles()); + for (const n of c.unlisted) console.log(`unlisted: ${n} — add it to tests/e2e/battery.json suites, or to exclude with a reason`); + // listed-but-absent is information, not a failure: a feat branch carries the list of a + // release whose suites have not merged into it yet, and both runners report them as gone + for (const n of c.missing) console.log(`(listed, no file here: ${n})`); + for (const n of c.both) console.log(`both: ${n} is a suite AND excluded`); + process.exit(c.unlisted.length + c.both.length ? 1 : 0); + } else { + console.error('usage: battery-desk.cjs run --half A|B [--of N] [--only a,b] [--list f] [--out d] [--name n] [--root tree] [--no-retry]\n' + ' | baseline save ... | diff [--baseline tag|file] ... | flakes [--days N] | coverage'); + process.exit(2); + } + } catch (e) { + console.error(`battery-desk: ${/** @type {Error} */ (e).message}`); + process.exit(2); + } +} + +module.exports = { normalise, fingerprints, classify, coverage, e2eFiles, deskEntries, half }; diff --git a/scripts/ci/battery.cjs b/scripts/ci/battery.cjs index f994cf22..4e5c7dd8 100755 --- a/scripts/ci/battery.cjs +++ b/scripts/ci/battery.cjs @@ -27,7 +27,7 @@ const BATTERY = path.join(ROOT, 'tests', 'e2e', 'battery.json'); /** * @typedef {{ comment?: string, refs: Record, shards: {target_minutes: number, max: number, min: number}, * pc?: {games: string[], levels: string[]}, skip: Record, optional: Record, setup: Record, - * suites: Record }} Battery + * suites: Record, modules?: Record, desk?: Record, exclude?: Record }} Battery * @typedef {{ name: string, status: 'pass'|'flaky'|'fail'|'timeout'|'gone', required: boolean, rc: number|null, secs: number, * retrySecs?: number, pass: number, fail: number, last: string, shard: number }} SuiteResult */ @@ -300,6 +300,10 @@ function write(b) { `\t"setup": ${obj(b.setup, '\t')}`, `\t"suites": ${obj(b.suites, '\t')}` ]; + // the desk battery's keys (battery-desk.cjs): written only when present, so a file without them stays byte-identical + if (b.modules) body.push(`\t"modules": ${obj(b.modules, '\t')}`); + if (b.desk) body.push(`\t"desk": ${obj(b.desk, '\t')}`); + if (b.exclude) body.push(`\t"exclude": ${obj(Object.fromEntries(Object.entries(b.exclude).map(([k, v]) => [k, [...v].sort()])), '\t')}`); fs.writeFileSync(BATTERY, `{\n${body.join(',\n')}\n}\n`); } @@ -309,8 +313,14 @@ function refresh(argv) { const lists = opts(argv, '--list'); if (lists.length) { // the integrator's battery lists ARE the battery: names not in them leave, new ones arrive at 120 s - const names = new Set(lists.flatMap((f) => fs.readFileSync(f, 'utf8').split(/\s+/).filter((n) => n && !n.startsWith('MOD:')))); + const all = lists.flatMap((f) => fs.readFileSync(f, 'utf8').split(/\s+/).filter(Boolean)); + const names = new Set(all.filter((n) => !n.startsWith('MOD:'))); b.suites = Object.fromEntries([...names].map((n) => [n, b.suites[n] ?? 120])); + // the modules repo's flights (MOD:x) are the desk battery's, never a runner's + const mods = all.filter((n) => n.startsWith('MOD:')).map((n) => n.slice(4)); + if (mods.length) b.modules = Object.fromEntries(mods.map((m) => [m, b.modules?.[m] ?? 600])); + // a suite that joined the battery is no longer excluded + if (b.exclude) for (const k of Object.keys(b.exclude)) b.exclude[k] = b.exclude[k].filter((/** @type {string} */ n) => !names.has(n)); } /** @type {Record} */ const desk = {}; diff --git a/tests/e2e/battery.json b/tests/e2e/battery.json index 5d5d9725..b3eed92e 100644 --- a/tests/e2e/battery.json +++ b/tests/e2e/battery.json @@ -1,5 +1,5 @@ { - "comment": "deps-auto: the e2e battery deps.yml runs in shards on GitHub-hosted runners (scripts/ci/battery.cjs). suites = name -> seconds on a runner (2x the desk time until a runner has measured it); skip = cannot run on a public runner (the reason); optional = runs and reports, does not gate (the reason); setup = extra install a suite needs. Refresh after an integrator battery: node scripts/ci/battery.cjs refresh --list --list --summary ... (RELEASING.md, Dependency updates). pc = the games (fps) and levels (shots) scripts/ci/pc-battery.sh measures on a desktop GPU.", + "comment": "deps-auto: the e2e battery deps.yml runs in shards on GitHub-hosted runners (scripts/ci/battery.cjs). suites = name -> seconds on a runner (2x the desk time until a runner has measured it); skip = cannot run on a public runner (the reason); optional = runs and reports, does not gate (the reason); setup = extra install a suite needs. Refresh after an integrator battery: node scripts/ci/battery.cjs refresh --list --list --summary ... (RELEASING.md, Dependency updates). pc = the games (fps) and levels (shots) scripts/ci/pc-battery.sh measures on a desktop GPU. Desk battery (scripts/ci/battery-desk.cjs): modules = the modules repo flights (MOD:x) with desk seconds; desk = per-suite cases (gpu: E2E_GPU=1, peer-self: PEER_CONFIG=$PEER_CONFIG_SELF, prod-host: APP_URL on the production hostname); exclude = reason -> e2e files that are deliberately NOT in the battery (tests/unit/batteryCoverage.test.js fails on a file in neither).", "refs": { "modules": {"repo":"theprototype-app/modules","ref":"80fca17f7cd963cf698d6b5f29beaff14f28f9e1"}, "packs": {"repo":"theprototype-app/packs","ref":"360f31686652f0f9c7e240ff09785c6b6c60d678"}, @@ -17,37 +17,66 @@ }, "optional": { "animation-sync": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "ar-table": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "behaviours": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "char-controller": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "checkpoint-clean": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "code-popout": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "collider-live": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "context-menu-redesign": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "edit-move-games": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "explorer-files": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "fb-water": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "flow-code": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "flow-code-text": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "flow-physics-actions": "flaky on every app/driver pair (deps-1 ab3, 2026-10-23)", "flow-physics-nodes": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "game-loop-v4": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "game-sky-run": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "helpers-in-play": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "hud-editor": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "hud-persist": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "hud-screen-model": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "kit-instancing": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "lock-search": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "mesh-toolbox-redesign": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "model-preview": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "net-handshake": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "net-reconnect": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "net-stress": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "panels": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "phone-sheets": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "physics-discoverability": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "physics-kinematic": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "play-exits": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "play-reentry": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "pointer-capture-play": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "post-play-mode": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "scene-budget": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "scene-post": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "scene-post-effects": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "scene-versions": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "script-node-v2": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "search-menus": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "select-41": "G4 flick timing flake (deps-1 ab3: 12/12 green on --local, red under load)", + "session-scenes": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "sessions": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "settings-layout": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "shader-sync": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "shared-text": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "shared-text-doc": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "sound-node": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "themes": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "timeline-export": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "touch-actions": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", - "water-migration": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)" + "touch-play": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "union-50": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "vr-locomotion-flags": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "vr-mode-spawn": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "vr-passthrough": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "vr-walk-interact": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "water-migration": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "workspace-restore": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green" }, "setup": { "agent-bridge": "agent" @@ -65,6 +94,7 @@ "animation-sync": 120, "animation-window": 28, "aquarium-reef": 48, + "ar-table": 120, "arch-primitives": 98, "auto-quality-notice": 60, "autosave-object-flows": 28, @@ -83,7 +113,9 @@ "char-controller": 116, "character-studio": 86, "chat-v2": 70, + "checkpoint-clean": 132, "code-node": 64, + "code-popout": 120, "code-sidebars": 118, "collider-custom": 30, "collider-decompose": 244, @@ -136,6 +168,7 @@ "explorer-delete-confirm": 216, "explorer-drag-fixes": 274, "explorer-drop": 168, + "explorer-files": 120, "explorer-header-panels": 52, "explorer-keyboard": 34, "explorer-multiselect": 366, @@ -152,6 +185,8 @@ "file-preview": 640, "files-format-row": 276, "floating-window-focus": 106, + "flow-code": 120, + "flow-code-text": 360, "flow-debugger": 74, "flow-dock-toggle": 32, "flow-impact-static": 30, @@ -197,12 +232,16 @@ "gltf-export-selection": 24, "hdri": 54, "heic-import": 284, + "helpers-in-play": 48, "hotkeys-layout": 26, "hud-core": 72, + "hud-editor": 46, "hud-editor-wysiwyg": 60, "hud-kinds": 28, + "hud-persist": 120, "hud-picker": 34, "hud-play-keyboard": 50, + "hud-screen-model": 70, "hud-sync": 286, "image-editor": 140, "import-button": 52, @@ -262,8 +301,10 @@ "modules-phone-jobs": 38, "move-wire-shape": 150, "multiselect-v2": 706, + "net-handshake": 120, "net-mesh": 30, "net-peer-id": 120, + "net-reconnect": 494, "net-stress": 120, "network-disconnect": 158, "node-breadcrumb": 76, @@ -310,12 +351,16 @@ "place-drag-cache": 44, "place-drag-extras": 22, "placeholders": 98, + "play-exits": 42, "play-interact": 188, "play-menu-mode": 74, "play-mode-menu": 54, + "play-reentry": 120, "player-access": 124, + "pointer-capture-play": 120, "pool-toys-waterline": 32, "post-backends": 22, + "post-play-mode": 120, "prefabs": 286, "profiler-sidebars": 148, "profiler-themes": 46, @@ -343,12 +388,16 @@ "scene-post-effects": 120, "scene-post-ui": 156, "scene-switch-keep": 626, + "scene-versions": 120, "scene-versions-in-file": 32, + "script-node-v2": 120, "script-nodes": 448, "scrub-undo": 116, "search-menus": 120, "select-41": 114, "select-inside-36": 38, + "session-scenes": 560, + "sessions": 120, "settings-autorestore-colors": 150, "settings-inventory": 32, "settings-inventory-b": 36, @@ -368,6 +417,8 @@ "shader-post-domain": 98, "shader-sync": 120, "shader-window": 126, + "shared-text": 120, + "shared-text-doc": 372, "sheet-handoff": 214, "shortcut-rebind": 146, "shortcut-warnings": 44, @@ -424,6 +475,7 @@ "toolbar-topright-43": 214, "toolbox-window": 78, "touch-actions": 154, + "touch-play": 194, "touch-tools": 332, "tours": 424, "tpscene": 98, @@ -436,6 +488,7 @@ "union-47": 46, "union-48": 120, "union-49": 22, + "union-50": 432, "union-53": 74, "unsaved-toast-stable": 214, "user-modules": 184, @@ -450,14 +503,18 @@ "vr-inset-confirm": 18, "vr-iwer": 22, "vr-locomotion": 32, + "vr-locomotion-flags": 30, + "vr-mode-spawn": 120, "vr-panel-sharpness": 12, "vr-panels-v3": 28, + "vr-passthrough": 120, "vr-radial-menu": 32, "vr-settings-36": 154, "vr-settings-immediacy": 14, "vr-settings-panel": 18, "vr-snapturn-mirror": 28, "vr-teleport": 18, + "vr-walk-interact": 120, "vr-world": 36, "water": 68, "water-migration": 72, @@ -478,6 +535,25 @@ "window-tabs-move": 30, "wire-hardening": 22, "workspace-layouts": 214, + "workspace-restore": 52, "z-scale-layering": 104 + }, + "modules": { + "dungeon-realms": 458, + "football": 200, + "fx-car-blocks": 60, + "untangle": 146 + }, + "desk": { + "gpu": ["MOD:fx-car-blocks","MOD:untangle"], + "peer-self": ["arch-primitives","game-race"], + "prod-host": ["error-reports"] + }, + "exclude": { + "a VR (IWER) editing suite not in the 1.43 integrator battery: triage owed (test-health-1)": ["music-vr-patch","novr-inset","play-here-iwer","vr-ai-panel","vr-ai-voice","vr-bounded-teleport","vr-box-select","vr-chat","vr-collider-edit","vr-controller-handedness","vr-create-face","vr-create-face-facing","vr-edit-caps","vr-edit-menu","vr-editmenu-reposition","vr-editmenu-tabs","vr-editmode-persist","vr-face-correctness","vr-face-edit","vr-face-live-adjust","vr-face-manip","vr-face-move-fix","vr-face-polygon","vr-face-weld","vr-grips-by-mode","vr-helper-eyes","vr-hz-pinch","vr-keyboard","vr-keyboard-search","vr-laser-panels","vr-mesh-undo","vr-multi-prefab","vr-multiselect-ring","vr-nav-suppression","vr-objects-groups","vr-objects-panel","vr-palette","vr-panel-sharpness-all","vr-panels-on-top","vr-peer-approve","vr-peer-hands","vr-peer-hands-net","vr-ping-accuracy","vr-prefabs","vr-props-panel","vr-ptt","vr-radial-stick","vr-ray-hover","vr-reticle-v2","vr-sleeve","vr-snap-menu","vr-source-rebind","vr-stats","vr-stretch","vr-stretch-fix","vr-stretch-sliders","vr-sweep","vr-teleport-handedness","vr-teleport-toggle","vr-two-hand-grab","vr-ungroup","vr-vertex-edit","vr-vertex-handle-bounds","vr-vertex-hold","vr-vertex-polish","vr-vertex-ray","vr-vertex-trigger","vr-window-grab","vr-world-grab","vr-world-reel","vr-worldgrab-presence"], + "a docs / screenshot generator, not a gate": ["flow-node-docs","shader-node-docs","vr-eye-shots"], + "a frame-rate / timing measurement: run by hand on an --exclusive slot, never in a battery half": ["avatar-knocked-perf","avatars-budget","import-budget","mesh-budget","perf-detailed","perf-live","perf-moment","perf-stale-modules","perf-stutter","place-drag-perf"], + "not in the 1.43 integrator battery (listA5/listB5): kept for focused runs, triage owed (test-health-1)": ["add-menu","add-menu-cursor","advanced-mode","ai-assistant","ai-local-model","ai-presets","ai-reference","anim-pack-refs","animated-base","animation-autokey","animation-autokey-material","animation-bake","animation-look-channels","animation-loop-pause","animation-markers","animation-marquee","animation-node","animation-onion","animation-persist","animation-relative-motion","annotation-anchor","approval-timeout","asset-cache-opfs","audio-devices","audio-engine","audio-harness","audio-pack","audio-patch","audio-sdk","author-kit","avatar-fly","avatar-v2","button","camera-bookmarks","camera-clipping","camera-looks","camera-objects","camera-preview-control","checkpoints","clear-scene","code-workspace","collectibles-v2","collision-groups","colocation-anchor","colocation-ar-mode","colocation-calibrate","colocation-core","colocation-nudge","colocation-presence","connect-pin-slide-43","content-index-cache","contest-starters","controls-state","controls-transform-tint","convert-to-mesh","deleted-folders","deselect-unlock","desktop-face-gizmo","dial-metadata","drawing","dungeon","dungeon-play","duplicate-after-create","duplicate-parity","edit-overlay-gaps","editmesh-focus","editmesh-mode-memory","env-fog-sticks","environment-v2","explorer-columns","explorer-duplicate","explorer-duplicate-scene-folder","explorer-expandable","explorer-inline-input","explorer-menu-icons","explorer-mount-open-peers","explorer-mounts","explorer-mounts-edit","explorer-rename","explorer-tree-floor","faces-nested-toolbar","faces-toolbar","far-clipping","fast-channel-fallback","fast-channel-loss","fb-input","fbx-mtl-import","file-windows-esc","flow-adjustable-params","flow-audit","flow-background","flow-contact-filter","flow-curvy-edges","flow-customnode-io","flow-explorer-floating","flow-input-moddefs","flow-mouse-bindings","flow-new-nodes","flow-node-duplicate","flow-node-undocked","flow-nodes-extended","flow-object-embed","flow-palette","flow-palette-reflow","flow-particle","flow-properties","flow-spawner","flow-window-mobile","gallery-submit","game-editor-flow","game-effects","game-feel-nodes","game-haptics","game-loop","game-loop-v2","game-loop-v3","game-presence","geometry-lock","geometry-params","gizmo-orbit-leak","gizmo-recall","grab-key","graph-games-lint","graph-tidy","graph-tree","grid-snapping","hand-models","hud-actions","hud-content","hud-editor-tools","hud-inputs","hud-nodes","hud-visibility","import-duplicates","import-reduce-gate","kit-entities","launch-inbox","level-templates","level-templates-33","light-aim","light-catalog","llms-txt","load-primitives-first-36","load-supersede-36","material-sharing","material-types","mesh-adjust","mesh-advanced-ops","mesh-bevel","mesh-bridge-normals","mesh-cleanup","mesh-edge-bevel","mesh-edge-gizmo","mesh-edge-mode","mesh-edge-vertex-ops","mesh-edit-display","mesh-edit-materials","mesh-edit-popup","mesh-falloff-rotate-scale","mesh-falloff-sync","mesh-fixes-round2","mesh-fixes-round3","mesh-generation","mesh-gizmo-modes","mesh-grab-uv","mesh-inset-move","mesh-loop-cut","mesh-loop-hardening","mesh-loop-select","mesh-multishell-ops","mesh-ops","mesh-ops-p5b","mesh-pivot-gizmo","mesh-proportional","mesh-quad-select","mesh-selection-undo","mesh-session-undo","mesh-symmetrize","mesh-symmetry-live","mesh-uv-preserve","mesh-vertex-bevel","mesh-vertex-slide","model-preview-controls","module-lifecycle-repo","module-node-io","module-pointer-seam","module-sdk","module-sdk-world","module-seed-graph","module-storage","module-update-on-load","module-world-root","modules-discoverability","multi-select","multiselect-menu","music-clock","music-flow-nodes","music-prefabs","music-sampling","music-template","music-toolbox","net-backoff","net-locks","network-quality","node-editor-open-view","node-search","node-ux-library","note-replies","object-list-drag","object-list-raise","object-list-size","object-origin","object-properties","object-search","object-sync","objectlist-hidden-icon","objectlist-search","open-core-m1","origin-rows","outline-scene-swap","pack-behavior","pack-undo","packs-explorer","palette","palette-groups","particles","path-node","peer-ice-config","peer-variables","pick-tiny-objects","ping","ping-highlights","ping-v2","placeholders-peers","play-free-cursor","play-here","play-reach-jump","pong","possess","prefab-explorer","prefab-library","prefab-propagation","preview-animation","private-scene","problem-report","project-file","project-manifest","race-touch-vr","recording","resync-converge","roadmap-13-notifications-notes","roadmap-13-viewport-camera","save-as-formats","scene-adopt","scene-assets","scene-default-material","scene-duplicate","scene-folders","scene-identity","scene-isolation","scene-levels","scene-load","scene-music","scene-poke","scene-presence","scene-rename","scene-rooms","scene-rooms-matrix","scene-rooms-truth","scene-stress","scene-switch","scene-switch-clear","scene-switch-games","script-guard","sdk-game-seams","sdk-input","sdk-pointer-ray","select-through","selection-extras","session-clock","session-download-formats","session-scene-data","session-zip","sessions-open","sessions-open-peers","sessions-packs","shader-editor","shader-inspector","shader-module-flow","shader-persist","shader-scene-default","shadows","share-gate-defer","share-stash","shared-library","shared-library-copies","sidebar-toggle","small-fixes-37","snap-advanced","spatial-voice","spline-tool","stars-clap","start-view","start-view-hold","stroke-streaming","switcher-mux","template-modules","terrain","topo-channel","towers-levels","trackpad-nav","trackpad-pinch","ui-fixes-15b","underlay","uv-dense","uv-live-faces","uv-materials","uv-pen-pressure","uv-slots","uv-slots-persist","uv-smart-unwrap","uv-target","uv-texture-params","uv-texture-tools","uv-transform","uv-unwrap","uv-unwrap-module","value-graph","variadic-nodes","version","vertices-create-face","view-mode","viewport-selection","viewport-tools-menu","walker-sensors","watch-look","waypoint-viz","wheel-classify"], + "the 38 R1 behaviour lock: npm run e2e -- @lock (tests/e2e/lock.json), not the release battery": ["dock-tabs-coexist","flow-dock-mixed","flow-dock-tabs","flow-explorer-dock","inspector-multi-edit","node-drag-fields","units"] } } diff --git a/tests/unit/batteryDesk.test.js b/tests/unit/batteryDesk.test.js new file mode 100644 index 00000000..ba6dcaea --- /dev/null +++ b/tests/unit/batteryDesk.test.js @@ -0,0 +1,77 @@ +// pipeline-1: the desk battery (scripts/ci/battery-desk.cjs) — check fingerprints, the baseline diff, and +// the guard that every tests/e2e file is in tests/e2e/battery.json or excluded with a reason. +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { normalise, fingerprints, classify, coverage, e2eFiles, half } = require('../../scripts/ci/battery-desk.cjs'); +const battery = require('../../tests/e2e/battery.json'); + +/** @param {string} name @param {'pass'|'flaky'|'red'|'gone'} status @param {string[]} fps */ +const res = (name, status, fps = []) => ({ name, status, first: { rc: status === 'pass' ? 0 : 1, secs: 1, pass: 1, fail: fps.length, last: '', fps }, fps }); + +describe('battery-desk: a failed check is the same check on another run', () => { + it('drops numbers, JSON details, paths and colour codes', () => { + expect(normalise('FAIL dock resize widens (320 → 440)')).toBe(normalise('FAIL dock resize widens (318 → 437)')); + expect(normalise('FAIL an occupied edge takes a tab ({"flow":"right","listShown":false})')).toBe('an occupied edge takes a tab ({…})'); + expect(normalise(' at /home/x/run-50-int/tests/e2e/touch-actions.test.cjs:235:92')).toBe('at touch-actions.test.cjs:#:#'); + expect(normalise('\x1b[31mFAIL\x1b[0m the Cut button could not be clicked')).toBe('the Cut button could not be clicked'); + }); + it('keeps different checks apart', () => { + expect(normalise('FAIL the Cut button could not be clicked')).not.toBe(normalise('FAIL the Cut button is disabled')); + }); + it('fingerprints FAIL lines, SCRIPT FAILED and timeouts; a green log has none', () => { + const log = 'PASS a\nFAIL b took 1200 ms\nSCRIPT FAILED: locator.click: Timeout 30000ms exceeded.\n1 FAILURES\n'; + expect(fingerprints('s', log, 1)).toEqual(['s :: SCRIPT FAILED: locator.click: Timeout #ms exceeded.', 's :: b took # ms']); + expect(fingerprints('s', log, 0)).toEqual([]); + expect(fingerprints('s', '✓ a\n✗ b is red\n', 1)).toEqual(['s :: b is red']); + expect(fingerprints('s', 'PASS a\n[battery] TIMEOUT after 1500s\n', null)).toContain('s :: TIMEOUT'); + expect(fingerprints('s', 'booting\nError: listen EADDRINUSE :5480\n', 1)).toEqual(['s :: rc=1 Error: listen EADDRINUSE :#']); + }); +}); + +describe('battery-desk: a run against the release baseline', () => { + const baseline = { + tag: 'v1.0.0', + commit: 'abc', + made: '', + runs: [], + suites: { old: { status: 'red', fps: ['old :: x'] }, mixed: { status: 'red', fps: ['mixed :: x'] }, fine: { status: 'pass', fps: [] } } + }; + it('same checks red on the tag = pre-existing; a new check or a suite green on the tag = NEW', () => { + const run = { + name: 'u1A', + root: '', + commit: '', + slot: '', + started: '', + secs: 0, + results: [res('old', 'red', ['old :: x']), res('mixed', 'red', ['mixed :: x', 'mixed :: y']), res('fine', 'red', ['fine :: z']), res('fresh', 'red', ['fresh :: q']), res('wobbly', 'flaky'), res('ok', 'pass')] + }; + const c = classify(baseline, [run]); + expect(c.known).toEqual(['old']); + expect(c.flaky).toEqual(['wobbly']); + expect(c.green).toEqual(['ok']); + expect(c.fresh.map((/** @type {{name: string}} */ f) => f.name)).toEqual(['mixed', 'fine', 'fresh']); + expect(c.fresh[0].fps).toEqual(['mixed :: y']); + expect(c.fresh[1].why).toBe('green on the baseline'); + expect(c.fresh[2].why).toBe('not in the baseline'); + }); +}); + +describe('battery-desk: every e2e file is in the battery or excluded', () => { + it('tests/e2e/battery.json lists or excludes every *.test.cjs', () => { + const c = coverage(battery, e2eFiles()); + expect(c.unlisted, 'add these to tests/e2e/battery.json suites (a measured time) or to exclude with a reason').toEqual([]); + expect(c.both).toEqual([]); + }); + it('the guard notices a new file', () => { + expect(coverage({ ...battery, exclude: {} }, ['brand-new-suite']).unlisted).toEqual(['brand-new-suite']); + }); + it('the two halves split the desk set (suites + modules flights) with nothing lost', () => { + const a = half(battery, 'A', 2); + const b = half(battery, 'B', 2); + expect(a.length + b.length).toBe(Object.keys(battery.suites).length + Object.keys(battery.modules || {}).length); + expect(a.filter((/** @type {string} */ n) => b.includes(n))).toEqual([]); + }); +}); From b4149d83fb758fd25fccd90506050f37339d98ab Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 11:22:28 +0300 Subject: [PATCH 2/7] [fix] battery-desk: ask both loopbacks for the dev server (vite may listen on ::1 only); BATTERY_COMMIT names a snapshot Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/ci/battery-desk.cjs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/scripts/ci/battery-desk.cjs b/scripts/ci/battery-desk.cjs index 90bf0bda..e4edeab7 100644 --- a/scripts/ci/battery-desk.cjs +++ b/scripts/ci/battery-desk.cjs @@ -101,17 +101,25 @@ function half(b, half, of) { return shards[i].suites; } -/** @param {string} url @returns {Promise} */ -function reachable(url) { +/** + * the dev server answers. `vite --host localhost` may listen on ::1 only while node resolves localhost + * to 127.0.0.1, so both loopbacks are asked (battery.cjs's reachable, with the request's own host header). + * @param {string} url @returns {Promise} + */ +async function reachable(url) { const u = new URL(url); - return new Promise((resolve) => { - const req = require('https').get({ host: u.hostname, port: u.port, path: u.pathname, rejectUnauthorized: false, timeout: 300000 }, (res) => { - res.resume(); - res.on('end', () => resolve((res.statusCode || 0) < 500)); + /** @param {string} host @returns {Promise} */ + const ask = (host) => + new Promise((resolve) => { + const req = require('https').get({ host, port: u.port, path: u.pathname, headers: { host: u.host }, servername: u.hostname, rejectUnauthorized: false, timeout: 300000 }, (res) => { + res.resume(); + res.on('end', () => resolve((res.statusCode || 0) < 500)); + }); + req.on('timeout', () => req.destroy()); + req.on('error', () => resolve(false)); }); - req.on('timeout', () => req.destroy()); - req.on('error', () => resolve(false)); - }); + if (u.hostname !== 'localhost') return ask(u.hostname); + return (await ask('::1')) || (await ask('127.0.0.1')); } /** @@ -166,6 +174,8 @@ function line(name, a) { /** @param {string} root */ function commitOf(root) { + // a snapshot (rsync of a checkout, no .git) says what it is through the environment + if (process.env.BATTERY_COMMIT) return process.env.BATTERY_COMMIT; try { return require('child_process').execFileSync('git', ['-C', root, 'describe', '--tags', '--always', '--dirty'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { From d8718cd62d08598aaa890422c5da7762471543d6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 11:26:19 +0300 Subject: [PATCH 3/7] =?UTF-8?q?[docs]=20pipeline-1=20P4:=20CLAUDE-index.md?= =?UTF-8?q?=20=E2=80=94=20every=20section,=20entry=20and=20bold=20sub-entr?= =?UTF-8?q?y=20of=20CLAUDE.md=20with=20its=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md is ~7,000 lines and an agent is shown only its start; the index (generated by scripts/claude-index.cjs, checked by tests/unit/claudeIndex.test.js) is what to grep first. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE-index.md | 636 +++++++++++++++++++++++++++++++++ CLAUDE.md | 4 + scripts/claude-index.cjs | 70 ++++ tests/unit/claudeIndex.test.js | 20 ++ 4 files changed, 730 insertions(+) create mode 100644 CLAUDE-index.md create mode 100644 scripts/claude-index.cjs create mode 100644 tests/unit/claudeIndex.test.js diff --git a/CLAUDE-index.md b/CLAUDE-index.md new file mode 100644 index 00000000..dbd3f29e --- /dev/null +++ b/CLAUDE-index.md @@ -0,0 +1,636 @@ +# CLAUDE-index.md — where things are in CLAUDE.md + +Generated by `node scripts/claude-index.cjs` (never edit; on a merge conflict, run it again). +Grep this file for a word, then read CLAUDE.md from that line (`Read CLAUDE.md offset= limit=60`). +7059 lines indexed. + + 1 # CLAUDE.md — theprototype.app + + 12 ## Architecture map + 14 - `src/stores/` — `appStore` (UI panels + `openSceneSection(label)`/`inspectorScrollTo` + 25 - `src/lib/peerHandler.svelte.js` — PeerConnection class; **all incoming messages** + 35 - Open-core seams (#13 M1 + #14 PM, contract in committed `OPEN-CORE.md`): a closed + 77 - `src/lib/triggerSync.js` (DEVX #18, the gameSync/hudSync shape — data in the leaf + 105 - `src/lib/sim/` (36-sim, 1.22): buoyancy, jiggle, fluid tanks. ONE per-frame entry + 128 - `src/lib/spawner.js` + `src/lib/transientObjects.js` (21-B B7): mid-sim body creation, + 147 - `src/lib/sharedLibrary.js` + `src/lib/transferLedger.js` + `components/editors/ + 204 - `src/lib/mountedVolumes.js` (R22 round 13, a LEAF: svelte/store + idb + toast; `sessions` + 228 - `src/lib/storageUsage.js` + `components/menu/StorageModal.svelte` (R22 round 13, a + 246 - `src/lib/explorerView.js` (R22 round 9, a LEAF — stores + arithmetic, imports NOTHING + 252 · THE MODE IS GLOBAL; COLUMNS AND SORT ARE PER VIEW. + 264 · THE SORT IS APPLIED IN `gridEntries` + 281 · R22 ROUND 11 — THE HEADER BECOMES A REAL ONE + 290 · THE SPACER CELL, and why a resizable table needs one. + 305 - R22 ROUND 36 — DELETED KEEPS ITS STRUCTURE + 394 - R22 ROUND 36 (ROOMS) — THE UNNAMED WORLD IS A ROOM WITH AN IDENTITY + 430 - `src/lib/filePreview.js` + `components/editors/FilePreviewWindow.svelte` + + 435 · THE SIBLING LIST IS PUBLISHED, NOT DERIVED. + 446 · PASSTHROUGH STANDS THE PANEL DOWN, NOT THE BODY + 463 · R22 ROUND 12 — MULTIPLE WINDOWS, AND THE 3D FACE. + 481 · R22 ROUND 13 — THE MODEL IS THE SWITCH, AND THE VIEW IS NAVIGABLE. + 498 · R22 ROUND 15 — THE PREVIEW PLAYS ANIMATIONS + 523 · "TYPING" IS NOT "FOCUS IS ON A CONTROL" + 528 · R22 ROUNDS 19-28 — THE CORNER, THE HEADER, AND THE TRANSPORTS. + 547 - `src/lib/saveAs.js` (R22 round 11) — "SAVE AS…", AND WHAT A PREFAB IS MADE OF. A prefab + 549 · A PREFAB RECORD MAY NOW CARRY A `format` AND THE FILE'S OWN `bytes` + 553 · THE BYTES RIDE BESIDE THE SNAPSHOT, NEVER INSTEAD OF IT. + 571 - WINDOW CHROME, R22 ROUNDS 20/25/26/28 + 579 - ROOMS ARE CONTENT PARTITIONS (R22 round 30, the whole arc) + 594 · THE RE-SYNC that makes the gate safe + 604 · getgame was measured INERT and removed + 607 · ROUND 32: THE ARRIVING REPLY HEALS + 621 · ROUND 32: THE ASK HOLDS BOTH DIRECTIONS + 638 · ROUND 33: THE CONNECT DECISION + 663 · ROUND 34: A SAVE NAMES THE ROOM + 673 · ROUND 35: PRIVATE SCENES + 703 · THE CONNECT CONTRACT + 727 · THE MANIFEST + 740 · THE LIBRARY'S FIRST CONTACT + 751 - `src/lib/objectPermissions.js` (#14, store-only) — viewer object permissions, ONLY + 765 - `src/lib/connectionState.js` (store-only) — `sessionHost` (peer that approved OUR + 771 - `src/components/menu/Connect.svelte` — a 3-state pill (`data-state` + 792 - `src/lib/commandsHandler.svelte.js` — receive-side scene appliers + `sendObjects` + 794 - Domain modules in `src/lib/`: `objectActions`, `geometries.svelte.js`, + 822 · The mesh is a triangle soup, but the face TOPOLOGY IS STORED NOW + 1016 · CL-A colliders v2 + 1043 · THE LOWER PEER ID KEEPS THE WORLD + 1272 · SHADER GRAPHS + 1351 · SH5 + 1371 · THE HUD + 1384 · The anchor is the 9-GRID plus PIXEL offsets, never 0..1 fractions + 1390 · **GOLDEN RULE 8, the decision that matters: the authored DOCUMENT replicates + 1424 · 21-D — THE HUD BECOMES INTERACTIVE, AND THE GAME SHELL + 1492 · 21-E — GAME HARDENING + 1538 · E7 HUD content + 1553 · 21-F — LEVELS, COLLECTIBLES v2, HUD EDITOR POLISH + 1626 · 21-G1 — SCENES NOT LEVELS; RECIPE RE-HOMING + 1651 · 21-G2..G5 — PROJECTS, PER-PLAYER PROGRESS, CROSS-SCENE PRESENCE. + 1665 · R22 ROUND 11 CORRECTS THE "PROVEN STABLE" CLAIM + 1901 · SCOPE, settled after C4 shipped and worth not re-litigating: + 1990 · R22 round 12 + 2004 · R22 round 11 + 2014 - `src/modules/` — core modules (hello = the smallest complete example, button = + 2019 · 17-A moved piano/avatar/essentials/car/DUNGEON OUT to `theprototype-app/modules` + 2029 - UI: `components/menu/*` (drawers/modals; visibility via stores + `hidePanels/ + 2104 - `tests/e2e/` — committed Playwright suites (`npm run e2e`, subset by name); + 2106 - Docs (2026-07-24 split): SDK authoring docs live on the PUBLIC docs site + 2112 - `src/lib/knock.js` + `knockMath.js` (24-A) — A HAND KNOCKS A BODY. `knockMath` is the pure + 2126 - `src/lib/lookPresence.js` (P2) — a peer's LOOK as PRESENCE, in the `campreview` shape: + 2131 - `src/lib/postGraphs.js` + `postGraphPresets.js` (P4) — a shader graph whose output IS a + 2136 - `src/lib/materialSharing.js` (D2) — two objects, ONE material, by id. The id is scene data + 2143 - `src/lib/flowPrefs.js` (114) — the node editor's mouse bindings as a LOCAL pref leaf + 2146 - `docking.js` (81.4) — an edge holds TWO stacked panels: `docked` is + 2150 - `meshEdit.js` (F1/F3) — a proportional drag ends in ONE whole-geometry `meshgeo` commit + 2157 - THE VOCABULARY (roadmap 22 R4, locked): + 2160 · room + 2162 - `src/lib/sceneRename.js` (R22 R5, a LEAF, imports nothing): `manifest.renames = {from:{to,at}}` + 2181 - `peerScenes.peersAtScene(map, scene, mine, host)` (R22 R4) — the per-scene session roster, + 2188 - `src/lib/sceneLoader.js` (33 L1, a LEAF: svelte/store only) — A SCENE LOAD MAY TAKE TIME; IT + 2220 · The autosave writes pristine kit pieces as STUBS + 2229 - `src/lib/flowLayout.js` + `src/lib/coalesce.js` (R29 S1/S2, both LEAVES, vitest-covered): + 2235 - `scripts/templates/` — ONE FILE PER TEMPLATE (34 R4 A3). + 2241 - `scripts/author-templates.cjs` (21-C C6-b): a `MODULE_DEFS` LOADER region (now in `templates/index.cjs`; … + 2253 - `playMode.js` `embedMode` / `embedSceneId` / `embedOpenUrl()` (R29 fork 4): the additive + 2261 - ROADMAP 30 — EDIT / INTERACT, AND THE SELECTION THAT FINDS THINGS + 2277 · THE POINTER-CAPTURE THROW (P0) + 2283 · SELECT-THROUGH + 2292 · MODULE CONTENT IN THE OBJECT LIST + 2304 - ROADMAP 30 — GAMES START ON START + 2316 · FREE-CURSOR GAMES + 2325 · STORAGE + 2332 · CONSOLE HYGIENE + 2337 - ROADMAP 30 — THE AUTHOR KIT + 2357 - ROADMAP 30b — THE QUEST ROUND (games in a headset) + 2395 - ROADMAP 30c — THE MESHY ASSET ROUND + 2408 - ROADMAP 31 — TOWERS BECOMES A GAME + 2437 - ROADMAP 31 — THE SECOND QUEST ROUND + 2469 · 34 R5: + 2474 · 34 PF/R1 — `src/lib/perf/` + 2481 · 36 (1.21) — export, touch, loading, theme tokens. + 2498 - ROADMAP 33 — MODULES ACROSS A SCENE SWITCH (33-scene-switch, L2/L3/L4) + 2542 - ROADMAP 33 — LOD GROUPS (33-lod-editor, contract P1) + 2557 - ROADMAP 33 — FUNCTIONAL PACK ITEMS (33-anim-core, contract P2) + 2558 · NOTHING AUTOPLAYS + 2568 · `animRef` (33-scenes) + 2570 - ROADMAP 33 — KIT INSTANCING (33-scenes) + 2579 - ROADMAP 34 — THE GAME KIT, part 1 (34-kit-core, contract T3) + 2605 - ROADMAP 34 R3 — BEHAVIOURS, code-first game logic with a DERIVED live node view (34-behaviours, D1+D2) + 2638 - ROADMAP 33 — EDITOR UI + GAMES (33-editor-ui, 33-games, 33-untangle-core) + + 2652 ## UI design system (redesign epic) + 2654 - All UI follows the redesign spec kept in the `cloud` repo: `docs/design/redesign/SPEC.md` + 2657 - Build UI only from `src/components/ui` primitives (WindowChrome, Tabs, Segmented, Chips, + 2660 - Colours, radii, spacing and type come from the semantic tokens in `app.css` only — no raw + 2662 - Every icon goes through `ui/Icon.svelte` (sizes 16 or 20 only; custom glyphs as `tool:`; enforced by … + 2663 - A redesign change must not alter behaviour: DragRow scrubbing, modifiers, click-to-type, + + 2667 ## Replication golden rules + + 2759 ## Hard-won gotchas (do not rediscover) + 2761 - RAPIER'S KinematicCharacterController COLLIDES WITH SENSORS unless the query passes `EXCLUDE_SENSORS` + 2765 - HEADLESS GPU SCREENSHOTS ON THIS MACHINE (ANGLE/Vulkan, RADV) CAN MISS TEXTURED KIT MESHES + 2768 - A LEVEL WITH NO DYNAMIC BODY STARTS NO SIMULATION, so its walker collides with nothing + 2770 - A sliced load changes what "right after" means. + 2773 - A two-page "in phase" check must compare CLOCKS, not poses + 2776 - A HEADSET'S EYE BUFFER IS NEVER LOWERED + 2780 - AN INSTALLED USER MODULE NEVER UPDATES ITSELF. + 2784 - A VITE STARTED INSIDE `e2e-slot` INHERITS FDS 8/9 AND HOLDS BOTH MACHINE-WIDE E2E LOCKS + 2787 - A LEGACY-MODE TEMPLATE CALL `fn(x)` IS UNTRACKED BEYOND ITS ARGUMENTS + 2790 - A `$state` (deep) PROXY OVER THREE OBJECTS IS A PER-FRAME COST + 2793 - `a ?? b + '/'` IS `a ?? (b + '/')` + 2796 - **HELPER_LAYER MUST NEVER BE 1 OR 2: three's WebXRManager gives the LEFT eye layer 1 and the + 2800 - three's `Euler.toArray()` IS `[x, y, z, 'XYZ']` + 2804 - A BLANKET SHAPE RENAME HITS EVERY KEY OF THAT SHAPE. + 2808 - A rapier collider with no body is FIXED AND SOLID. + 2813 - A THIRD SOFTWARE-GL PAGE BOOTS PAST setupPage's 30 s ON THE 1.17 UNION + 2818 - An ADDITIVE burst vanishes against a bright sky + 2821 - A module effect/click on a Quest cannot rely on the DOM HUD + 2824 - Known, not fixed (30c pack lanes): + 2828 - `TubeGeometry` HANDED A 2D `Shape` BUILDS EVERY VERTEX AS NaN + 2833 - A rAF TIMESTAMP IS THE FRAME'S START, WHICH CAN PRECEDE THE CALL THAT SCHEDULED IT. + 2838 - A HUD TEXT BOX IS A FLEX ROW, SO `text-align` ALONE NEVER CENTRED A SINGLE LINE. + 2842 - A perRound LATCH READS UN-SET THE INSTANT THE ROUND ENDS + 2845 - PARTICLE EMITTERS ARE COUNTED PER PAIR + 2848 - `--use-angle=gl` IS SWIFTSHADER ON LINUX. + 2851 - **EVERY TOP-LEVEL OBJECT IN objectsGroup BECOMES A FIXED PHYSICS BODY, and a decoration GROUP's + 2854 - DESKTOP PLAY SPAWNS AT Scene.svelte's FIXED `` + 2856 - THE EDITOR GRID DRAWS AT y = 0 + 2859 - A FLAT GROUND DISC ENDS IN A BAND, NOT A HORIZON + 2862 - THREE LANES NUMBERING ONE LIST FROM THE SAME BASE COLLIDE. + 2865 - jsDelivr PARSES `@v2` AS A SEMVER VERSION, AND A VERSION IS NEVER RE-RESOLVED. + 2874 · 29f CLOSED IT BY MEASUREMENT + 2888 - A jsDelivr BRANCH ref is `max-age=604800` IN THE BROWSER + 2895 - flowbite-svelte's `Button` FREEZES its class string at mount. + 2904 - An INVALIDATION KEY must name the thing whose change should invalidate. + 2910 - TWO LANES CAN EACH BE CORRECT AND COMPOSE WRONG — the enumerated-roots edition. + 2918 - A guard that saves INTO a store the guarded action then WIPES is a lie. + 2926 - A movement keydown guard must include ALT. + 2931 - e2e: the runner kills a suite at 8 minutes + 2940 - SessionsManager test hooks + 2948 - Vite watches `tests/` + 2954 - **A mount SAVE is several awaits long, so an edit made DURING one is not in the bytes + 2959 - ConfirmModal RESTORES FOCUS AFTER the thing you open next mounts. + 2964 - A SUITE THAT PASSES WITH SECONDS OF HEADROOM AGAINST THE 480s RUNNER CAP IS A COIN. + 2968 - CONSENT MUST BE RECORDED BEFORE THE WRITE THAT PUBLISHES IT. + 2974 - **A HASH IN `currentLevel` MEANS "THESE ARE THE BYTES I LOADED", and adoption must not + 2982 - A RE-SYNC REQUEST IS NOT A JOIN, and the receiver cannot tell unless it is told. + 2988 - FORK 3'S CARRY SEMANTICS MAKE A PULLED gameState ALWAYS LOSE. + 2994 - **WHEN A FLEX ROW OVERFLOWS, WHAT FALLS OFF THE END IS WHATEVER IS LAST IN THE MARKUP — + 3005 - A GROUPED WINDOW IS PLACED BY ITS GROUP, AND `dragWindow` HAS TO BE TOLD. + 3020 - ZERO IS NOT A WIDTH. + 3025 - A TAB GROUP'S MINIMUM IS THE WORST CASE ACROSS ITS MEMBERS, not a constant. + 3032 - A GATE THAT DISABLES THE GESTURE THAT WOULD UNDO ITS OWN STATE STRANDS THE USER. + 3038 - **A COUNTER THAT GAINS A DIGIT MOVES EVERYTHING AFTER IT, and `tabular-nums` alone does + 3045 - **A FRAME -> SECONDS -> FRAME ROUND TRIP IS NOT THE IDENTITY, and the first casualty is + 3056 - **DUPLICATE DOM ids ACROSS COMPONENT INSTANCES ARE HARMLESS UNTIL A `