Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
c2f718e
[test] test-health-1 P0: budget targets follow the Quest/phone research
AlexZ005 Oct 24, 2026
607b3fe
[test] test-health-1 P1: e2e quarantine manifest + the battery's non-…
AlexZ005 Oct 24, 2026
b4b46ab
[test] test-health-1 P3a: e2e-smoke can go red, and its two runner re…
AlexZ005 Oct 24, 2026
bc453ae
[test] test-health-1 P3b: script-guard says why the slow-node badge n…
AlexZ005 Oct 24, 2026
e77ca3f
[test] test-health-1 WIP (checkpoint, NOT yet verified by a run): P2 …
AlexZ005 Oct 24, 2026
4a79a2a
[test] test-health-1 P4: type debt ratchet -10 (298 -> 288)
AlexZ005 Oct 24, 2026
b1487f6
[test] test-health-1 P2: lock-search re-recorded (Storage section + P…
AlexZ005 Oct 24, 2026
cce8f19
[test] test-health-1 P2: panels asserts Save options, not the removed…
AlexZ005 Oct 24, 2026
3a21998
[test] test-health-1 P2: physics-discoverability starts from bare boxes
AlexZ005 Oct 24, 2026
3fd133b
[test] test-health-1 P2: mesh-toolbox-redesign drives the SheetGrip g…
AlexZ005 Oct 24, 2026
f986e9e
[test] test-health-1 P2: model-preview expects the file preview windo…
AlexZ005 Oct 24, 2026
4a695ba
[test] test-health-1 P2: five suites verified green after e77ca3f7 — …
AlexZ005 Oct 24, 2026
b8e60ba
[test] test-health-1 P2: behaviours — dial the live id, and let B's v…
AlexZ005 Oct 24, 2026
5ac7d80
[test] test-health-1 P2: fb-water gates again — four software-GL chec…
AlexZ005 Oct 24, 2026
42e7513
[test] test-health-1 P2: scene-post-effects spies the per-connection …
AlexZ005 Oct 24, 2026
c8b1807
[test] test-health-1 P2: post-play-mode measures the fill on a chrome…
AlexZ005 Oct 24, 2026
7ee9643
[test] test-health-1 P2: sound-node — no Shared folder, and the edito…
AlexZ005 Oct 24, 2026
fffefb4
[test] test-health-1 P2: themes expects tailwind 4's gray-800
AlexZ005 Oct 24, 2026
edcc56d
[test] test-health-1 P2: hud-editor — 'hud' is a FLOW_FAMILY member, …
AlexZ005 Oct 24, 2026
1929ebd
[test] test-health-1 P2: vr-passthrough compares with the preset's ow…
AlexZ005 Oct 24, 2026
aaac230
[test] test-health-1 P2: search-menus — node search is the menu's fil…
AlexZ005 Oct 24, 2026
ecb50e1
[fix] test-health-1 P2: a physics action acts on a trigger stamp it h…
AlexZ005 Oct 24, 2026
d02bbc8
[test] test-health-1 P2: settings-layout judges a squeezed descriptio…
AlexZ005 Oct 24, 2026
1f3023e
[test] test-health-1 P2: physics-kinematic starts from a scenery slab
AlexZ005 Oct 24, 2026
51c024f
[test] test-health-1 P2: knock-physics reads the prediction over B's …
AlexZ005 Oct 24, 2026
d092f20
[test] test-health-1 P2: code-sidebars S7 reverts the room's buffer b…
AlexZ005 Oct 24, 2026
3fdd37c
[test] test-health-1 P2: scene-budget judges the objects row, not the…
AlexZ005 Oct 24, 2026
7aad552
[test] test-health-1 P2: aquarium-reef — nose over the moving samples…
AlexZ005 Oct 24, 2026
755ea56
[fix] test-health-1 P5: reload-memory probe (measured: no growth acro…
AlexZ005 Oct 24, 2026
68cf1ce
[ci] test-health-1 P3: e2e-smoke is a gate now
AlexZ005 Oct 24, 2026
d31552a
[fix] test-health-1 P2: the local feet reading survives a slow frame;…
AlexZ005 Oct 24, 2026
fec7d89
[test] test-health-1 P2: select-41 waits on the grab and the crate, a…
AlexZ005 Oct 24, 2026
62ea2ad
[test] test-health-1 P2: helpers.connect opens a squeezed connect pil…
AlexZ005 Oct 24, 2026
cfcc5e1
[test] test-health-1 P2: water-migration compares the scene, not the …
AlexZ005 Oct 24, 2026
7451aad
[fix] test-health-1 P2: a real right-click on the viewport opens its …
AlexZ005 Oct 24, 2026
3af68d7
[test] test-health-1 P2: char-controller compares speed per moving fr…
AlexZ005 Oct 24, 2026
6bd61d3
[test] test-health-1 P2: sessions reads the toast, not its screen-rea…
AlexZ005 Oct 24, 2026
e5f4010
[test] test-health-1 P2: scene-post 9.12 waits for the composer and p…
AlexZ005 Oct 24, 2026
0f3b228
[fix] test-health-1 P2: a late joiner compiles a shader graph whose o…
AlexZ005 Oct 24, 2026
270ed3c
[test] test-health-1 P2: timeline-export is green against a build — i…
AlexZ005 Oct 24, 2026
4f0dd12
[fix] test-health-1 P2/P5: a dropped peer comes back — the reconnect …
AlexZ005 Oct 24, 2026
f925429
[test] test-health-1 P5: net-drop — a 5 s connection drop does not te…
AlexZ005 Oct 24, 2026
756d58d
[test] test-health-1 P3: script-guard counts the slow node's runs, no…
AlexZ005 Oct 25, 2026
cbeba84
[test] test-health-1 P5: battery.json runs net-drop and reload-memory
AlexZ005 Oct 25, 2026
ebfbf5a
[revert] test-health-1: the gizmo-tap change from 7451aad9 did not fi…
AlexZ005 Oct 25, 2026
8cd2578
[test] test-health-1 P2: animation-sync waits for the door to reach t…
AlexZ005 Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 24 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
# enforced by whoever remembered to run it, and a broken main was discovered at release
# time. Everything below is what a maintainer already does by hand.
#
# WHY e2e-smoke IS NOT REQUIRED YET: it renders WebGL through SwiftShader on a GPU-less
# runner, and this project's own notes measure ~4.5 fps there plus a documented family of
# timing-sensitive suites. Blocking every PR on that before it has been observed green on
# GitHub would train people to ignore a red tick, which is worse than not having it. It
# reports, it does not gate, and the comment below says when to flip it.
# e2e-smoke GATES since test-health-1 (feat/60). It renders WebGL through SwiftShader on a
# GPU-less runner (~4.5 fps), so it was report-only until it had been seen green on GitHub:
# the runner step now really fails (it used to `|| echo` every red away), a failed suite runs
# once more (green on the retry = flaky, reported), and a known flake is entered in
# tests/e2e/quarantine.json with an owner and a review date instead of blocking every PR.
#
# Two-peer suites stay a MANUAL gate: they meet on the self-hosted signaling box, which a
# public runner cannot reach and should not be pointed at.
Expand Down Expand Up @@ -150,9 +150,7 @@ jobs:

e2e-smoke:
runs-on: ubuntu-latest
# NOT a gate yet — see the header. Flip this to false once it has been green on a few
# PRs in a row; until then a red here is a signal to read, not a block.
continue-on-error: true
# a gate — see the header (green on GitHub for bc453ae8 and e77ca3f7 before the flip)
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand Down Expand Up @@ -180,14 +178,20 @@ jobs:
# Deliberately NOT added: net-stress and signaling-reconnect are multi-peer and
# meet on the self-hosted signaling box, which a public runner cannot reach and
# should not be pointed at (see the header) — they stay a manual gate.
for s in net-backoff net-mesh wire-hardening runtime-resilience diagnostics mesh-budget script-guard dispose approval-timeout; do
echo "::group::$s"
npm run e2e -- "$s" || echo "SUITE FAILED: $s"
echo "::endgroup::"
done
# test-health-1: through the battery runner, so a red is a red (the old loop's
# `|| echo SUITE FAILED` could never fail the step), a failed suite runs once more
# (green on the retry = flaky, reported) and tests/e2e/quarantine.json applies.
node scripts/ci/battery.cjs run --only net-backoff,net-mesh,wire-hardening,runtime-resilience,diagnostics,mesh-budget,script-guard,dispose,approval-timeout --port 5173 --out /tmp/smoke
- name: dev server log on failure
if: failure()
run: tail -40 /tmp/dev.log
- name: suite logs
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-smoke-logs
path: /tmp/smoke/logs
if-no-files-found: ignore

# 53-reader (A4): THE ACCESSIBILITY GATE. axe-core over the main surfaces (editor shell, object
# list, Inspector, Settings, Asset Browser on recorded fixtures, phone shell) FAILS on serious and
Expand Down Expand Up @@ -230,6 +234,13 @@ jobs:
- name: dev server log on failure
if: failure()
run: tail -40 /tmp/dev.log
- name: suite logs
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-smoke-logs
path: /tmp/smoke/logs
if-no-files-found: ignore

# 38 (NOTES-38 #18): THE TOKEN SCREENSHOT DIFF. /kit renders every redesign primitive in every
# state; this job shoots it in the dark, light and custom themes on the PR's BASE ref and on its
Expand Down
4 changes: 2 additions & 2 deletions check-baseline.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.",
"errors": 299,
"errors": 288,
"warnings": 39,
"measured": "2026-10-11"
"measured": "2026-10-24"
}
6 changes: 3 additions & 3 deletions perf/budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
"textureMB": 64
},
"targets": {
"comment": "46 B1/B2 — the editor's budget chip and Statistics ▸ Budget judge a scene against ONE of these (picked in the chip's menu, LOCAL). Quest's four gated metrics are `defaults` above and are NOT repeated here (perfBudget.cjs refuses a copy, so the chip and the gate cannot disagree); the gate never reads this block. shadowLights / shadowCasters are meter-only rows; shadowCasters counts shadow DRAWS (casters x shadow lights) and is held to the draw-call budget, since every one is a call. Phone and desktop are starting values, not gated: phone = sceneBudget's measured VR/mobile green ceilings, desktop = 26-E's measured desktop green ceilings (owed on device).",
"comment": "46 B1/B2 — the editor's budget chip and Statistics ▸ Budget judge a scene against ONE of these (picked in the chip's menu, LOCAL). Quest's four gated metrics are `defaults` above and are NOT repeated here (perfBudget.cjs refuses a copy, so the chip and the gate cannot disagree); the gate never reads this block. shadowLights / shadowCasters are meter-only rows; shadowCasters counts shadow DRAWS (casters x shadow lights) and is held to the draw-call budget, since every one is a call. Phone and desktop are starting values, not gated: phone = sceneBudget's measured VR/mobile green ceilings, desktop = 26-E's measured desktop green ceilings (owed on device) test-health-1 (2026-10-24) reconciled them with cloud plans/core/pending/research/quest-phone-perf.md §1: phone = its MID phone row (calls 100-150 [3P] -> 150, shadow draws held to that; 500k triangles, 256 MB, one shadow map already inside it), desktop texture memory 512 -> 1024 MB (its 1-2 GB [U]). Quest is unchanged: 150 calls is inside <200 [M] / 100-150 [3P], and its triangles / texture MB are STRICTER than the research's [U] web estimates (500-750k, <=512 MB), which say measure on device.",
"quest": { "label": "Quest / VR", "short": "Quest", "shadowLights": 1, "shadowCasters": 150 },
"phone": { "label": "Phone", "short": "Phone", "calls": 300, "triangles": 500000, "lights": 3, "textureMB": 256, "shadowLights": 1, "shadowCasters": 300 },
"desktop": { "label": "Desktop", "short": "Desktop", "calls": 2000, "triangles": 4000000, "lights": 8, "textureMB": 512, "shadowLights": 4, "shadowCasters": 2000 }
"phone": { "label": "Phone", "short": "Phone", "calls": 150, "triangles": 500000, "lights": 3, "textureMB": 256, "shadowLights": 1, "shadowCasters": 150 },
"desktop": { "label": "Desktop", "short": "Desktop", "calls": 2000, "triangles": 4000000, "lights": 8, "textureMB": 1024, "shadowLights": 4, "shadowCasters": 2000 }
},
"games": {},
"levels": {},
Expand Down
53 changes: 36 additions & 17 deletions scripts/ci/battery.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
// The battery is the list an integrator runs before a release: tests/e2e/battery.json holds it
// (suite → seconds measured on a GitHub runner, or twice the desk time until one has run), the
// suites a public runner must skip (`skip`: they need the self-hosted signaling box, a hosts-file
// origin or a built app) and the ones that run but do not gate (`optional`: red or flaky on
// SwiftShader with the same red on the unchanged base — the reason is on the line). Nothing here
// names a suite; the list is data, refreshed from the integrators' battery runs (`refresh`).
// origin or a built app). What runs but does not gate is tests/e2e/quarantine.json (test-health-1:
// it replaced battery.json's suite-wide `optional` map) — per CHECK, with an owner and a review date,
// read through scripts/ci/quarantine.cjs. Nothing here names a suite; the list is data, refreshed from
// the integrators' battery runs (`refresh`).
//
// node scripts/ci/battery.cjs plan # matrix JSON for GITHUB_OUTPUT
// node scripts/ci/battery.cjs run --shard 3 --of 14 # run one shard (serves the app itself)
Expand All @@ -15,20 +16,22 @@
//
// Sharding is longest-first onto the least-loaded shard, by name on ties: every shard computes the
// same assignment from the same file, so the plan job only has to say how many shards there are.
// A failed suite runs once more; green on the retry = `flaky` (reported, not a red).
// A failed suite runs once more; green on the retry = `flaky` (reported, not a red). Red twice and
// every failed check quarantined = `quarantined` (reported in its own tier, not a red).

const fs = require('fs');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const quarantine = require('./quarantine.cjs');

const ROOT = path.resolve(__dirname, '..', '..');
const BATTERY = path.join(ROOT, 'tests', 'e2e', 'battery.json');

/**
* @typedef {{ comment?: string, refs: Record<string, {repo: string, ref: string}>, shards: {target_minutes: number, max: number, min: number},
* pc?: {games: string[], levels: string[]}, skip: Record<string, string>, optional: Record<string, string>, setup: Record<string, string>,
* pc?: {games: string[], levels: string[]}, skip: Record<string, string>, setup: Record<string, string>,
* suites: Record<string, number> }} Battery
* @typedef {{ name: string, status: 'pass'|'flaky'|'fail'|'timeout'|'gone', required: boolean, rc: number|null, secs: number,
* @typedef {{ name: string, status: 'pass'|'flaky'|'quarantined'|'fail'|'timeout'|'gone', required: boolean, rc: number|null, secs: number,
* retrySecs?: number, pass: number, fail: number, last: string, shard: number }} SuiteResult
*/

Expand Down Expand Up @@ -166,14 +169,20 @@ function runSuite(name, est, out, tag) {
fs.closeSync(log);
const text = fs.readFileSync(logFile, 'utf8');
if (timedOut) fs.appendFileSync(logFile, `\n[battery] TIMEOUT after ${timeout / 1000}s\n`);
resolve({ rc: timedOut ? null : code, secs: Math.round((Date.now() - t0) / 1000), timedOut, ...counts(text) });
resolve({ rc: timedOut ? null : code, secs: Math.round((Date.now() - t0) / 1000), timedOut, text, ...counts(text) });
});
});
}

/** @param {string[]} argv */
async function run(argv) {
const b = load();
const q = quarantine.load();
const qErrors = quarantine.validate(q);
if (qErrors.length) {
for (const e of qErrors) console.error('quarantine.json: ' + e);
process.exit(2);
}
const of = Number(opt(argv, '--of') || shardCount(b));
const shard = Number(opt(argv, '--shard') || 1);
const out = path.resolve(opt(argv, '--out') || process.env.BATTERY_OUT || path.join(ROOT, '..', 'battery-out'));
Expand All @@ -187,7 +196,7 @@ async function run(argv) {
const results = [];
console.log(`shard ${shard}/${of}: ${mine.length} suites, est ${Math.round(mine.reduce((t, s) => t + (b.suites[s] || 60), 0) / 60)} min`);
for (const name of mine) {
const required = !(name in b.optional);
const required = true;
if (!fs.existsSync(path.join(ROOT, 'tests', 'e2e', name + '.test.cjs'))) {
results.push({ name, status: 'gone', required: false, rc: null, secs: 0, pass: 0, fail: 0, last: 'no such suite (refresh battery.json)', shard });
continue;
Expand All @@ -214,10 +223,19 @@ async function run(argv) {
if (again.rc === 0) status = 'flaky';
else r = again;
}
const res = { name, status, required, rc: r.rc, secs: r.secs, retrySecs, pass: r.pass, fail: r.fail, last: r.last, shard };
/** @type {string[]} */
let quarantinedChecks = [];
if (status === 'fail' || status === 'timeout') {
const v = quarantine.classify(q, name, r.text, { timedOut: r.timedOut });
if (v.quarantined) {
status = 'quarantined';
quarantinedChecks = v.whole ? ['(whole suite)'] : v.failed;
}
}
const res = { name, status, required, rc: r.rc, secs: r.secs, retrySecs, pass: r.pass, fail: r.fail, last: quarantinedChecks.length ? 'quarantined: ' + quarantinedChecks.join(' | ').slice(0, 140) : r.last, shard };
results.push(res);
if (status === 'pass' || status === 'flaky') fs.rmSync(path.join(out, 'evidence', name), { recursive: true, force: true });
console.log(`${name} ${status}${required ? '' : ' (optional)'} rc=${r.rc} pass~${r.pass} fail~${r.fail} ${r.secs}s${retrySecs ? ` retry ${retrySecs}s` : ''} :: ${r.last}`);
if (status === 'pass' || status === 'flaky' || status === 'quarantined') fs.rmSync(path.join(out, 'evidence', name), { recursive: true, force: true });
console.log(`${name} ${status} rc=${r.rc} pass~${r.pass} fail~${r.fail} ${r.secs}s${retrySecs ? ` retry ${retrySecs}s` : ''} :: ${r.last}`);
}
stop(server);
fs.writeFileSync(path.join(out, `results-${shard}.json`), JSON.stringify({ shard, of, results }, null, 1));
Expand All @@ -232,14 +250,14 @@ async function run(argv) {
function table(results, title) {
const red = results.filter((r) => r.status === 'fail' || r.status === 'timeout');
const req = red.filter((r) => r.required);
const opt = red.filter((r) => !r.required);
const quar = results.filter((r) => r.status === 'quarantined');
const flaky = results.filter((r) => r.status === 'flaky');
const secs = results.reduce((t, r) => t + r.secs + (r.retrySecs || 0), 0);
const row = (/** @type {SuiteResult} */ r) => `| ${r.name} | ${r.status} | ${r.shard} | ${r.pass}/${r.fail} | ${r.secs}s | ${r.last.replace(/\|/g, '\\|')} |`;
const head = '| suite | status | shard | ✓/✗ | time | last line |\n|---|---|---|---|---|---|\n';
let md = `### ${title}: ${results.length} suites, ${req.length} required red, ${opt.length} optional red, ${flaky.length} flaky (${Math.round(secs / 60)} suite-min)\n\n`;
let md = `### ${title}: ${results.length} suites, ${req.length} required red, ${quar.length} quarantined, ${flaky.length} flaky (${Math.round(secs / 60)} suite-min)\n\n`;
if (req.length) md += `**Required reds**\n\n${head}${req.map(row).join('\n')}\n\n`;
if (opt.length) md += `<details><summary>Optional reds (do not gate)</summary>\n\n${head}${opt.map(row).join('\n')}\n\n</details>\n\n`;
if (quar.length) md += `<details><summary>Quarantined reds (tests/e2e/quarantine.json — do not gate)</summary>\n\n${head}${quar.map(row).join('\n')}\n\n</details>\n\n`;
if (flaky.length) md += `<details><summary>Flaky (green on the retry)</summary>\n\n${head}${flaky.map(row).join('\n')}\n\n</details>\n\n`;
const gone = results.filter((r) => r.status === 'gone');
if (gone.length) md += `Listed but gone (refresh tests/e2e/battery.json): ${gone.map((r) => r.name).join(', ')}\n\n`;
Expand All @@ -262,6 +280,8 @@ function report(argv) {
const b = load();
const missing = Array.from({ length: of || shardCount(b) }, (_, i) => i + 1).filter((s) => !seen.has(s));
let md = table(all, 'e2e battery');
const qWarn = quarantine.warnings(quarantine.load(), all, Object.keys(b.suites));
if (qWarn.length) md += `<details><summary>Quarantine to review (${qWarn.length})</summary>\n\n${qWarn.map((w) => '- ' + w).join('\n')}\n\n</details>\n\n`;
if (missing.length) md += `**Shards with no results (crashed or cancelled): ${missing.join(', ')}**\n`;
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md);
else console.log(md);
Expand Down Expand Up @@ -296,7 +316,6 @@ function write(b) {
`\t"shards": ${JSON.stringify(b.shards)}`,
`\t"pc": ${JSON.stringify(b.pc || { games: [], levels: [] })}`,
`\t"skip": ${obj(b.skip, '\t')}`,
`\t"optional": ${obj(b.optional, '\t')}`,
`\t"setup": ${obj(b.setup, '\t')}`,
`\t"suites": ${obj(b.suites, '\t')}`
];
Expand Down Expand Up @@ -329,9 +348,9 @@ function refresh(argv) {
for (const [n, s] of Object.entries(JSON.parse(fs.readFileSync(f, 'utf8')))) if (n in b.suites) b.suites[n] = Number(s);
}
}
for (const k of Object.keys(b.optional)) if (!(k in b.suites)) delete b.optional[k];
write(b);
console.log(`battery.json: ${Object.keys(b.suites).length} suites, ${Object.keys(b.skip).length} skipped, ${Object.keys(b.optional).length} optional, ${shardCount(b)} shards`);
console.log(`battery.json: ${Object.keys(b.suites).length} suites, ${Object.keys(b.skip).length} skipped, ${shardCount(b)} shards`);
for (const w of quarantine.warnings(quarantine.load(), [], Object.keys(b.suites))) console.log('warning: ' + w);
}

// ---------------------------------------------------------------- plan
Expand Down
Loading
Loading