} fn @param {number} timeout */
+async function until(fn, timeout) {
+ const end = Date.now() + timeout;
+ while (Date.now() < end) {
+ if (await fn().catch(() => false)) return true;
+ await new Promise((r) => setTimeout(r, 400));
+ }
+ return false;
+}
+
+/** @param {any} page */
+const inboxCacheExists = (page) => page.evaluate(() => caches.has('tp-share-inbox'));
+
+h.run(async () => {
+ // the dev server's certificate is self-signed: pages load past it, but a service worker
+ // script fetch is refused unless the browser itself ignores certificate errors
+ const browser = await h.launch({ args: ['--ignore-certificate-errors'] });
+ const peer = await h.setupPage(browser, 'A');
+ const { page } = peer;
+
+ // ---- 1. Chromium's verdicts ---------------------------------------------------------------
+ const worker = await ensureWorker(page);
+ h.check(worker === 'app' || worker === 'suite', `a service worker controls the page (${worker})`);
+ const cdp = await page.context().newCDPSession(page);
+ const man = await cdp
+ .send('Page.getAppManifest')
+ .catch((e) => ({ errors: [{ message: String(e) }] }));
+ h.check(
+ Array.isArray(man.errors) && man.errors.length === 0,
+ 'the manifest parses with no errors' +
+ (man.errors?.length ? ': ' + JSON.stringify(man.errors) : '')
+ );
+ const raw = man.data ? JSON.parse(man.data) : {};
+ h.check(
+ raw.share_target?.action === '/share-target' &&
+ raw.file_handlers?.length === 1 &&
+ raw.launch_handler?.client_mode?.[0] === 'focus-existing',
+ 'served manifest carries share_target, file_handlers, launch_handler'
+ );
+ const inst = await cdp
+ .send('Page.getInstallabilityErrors')
+ .catch((e) => ({ installabilityErrors: [{ errorId: String(e) }] }));
+ h.check(
+ (inst.installabilityErrors ?? []).length === 0,
+ 'Chromium says the app is installable' +
+ (inst.installabilityErrors?.length ? ': ' + JSON.stringify(inst.installabilityErrors) : '')
+ );
+ const appId = await cdp.send('Page.getAppId').catch(() => ({}));
+ console.log(
+ ` (manifest id: ${appId.appId ?? '?'}; native launchQueue in a tab: ${await page.evaluate(() => 'launchQueue' in window)})`
+ );
+
+ // ---- 2. share an image, then a model ------------------------------------------------------
+ await share(page, [{ name: 'shared-photo.png', mimeType: 'image/png', buffer: PNG }]);
+ await h.answerRestore(peer);
+ await until(() => lastLaunch(page), 60000);
+ const img = await lastLaunch(page);
+ h.check(
+ img?.source === 'share' && img?.images === 1,
+ 'a shared image was received once: ' +
+ JSON.stringify(img) +
+ ' stage ' +
+ (await page.evaluate(async () => (await window.__stores.launchInbox)?.lastLaunch?.stage))
+ );
+ const lib = await libraryNames(page);
+ h.check(
+ lib.includes('shared-photo.png'),
+ 'it is in the Explorer Library: ' + JSON.stringify(lib)
+ );
+ h.check(!/[?&]shared=/.test(page.url()), '?shared= left the address bar: ' + page.url());
+ h.check(!(await inboxCacheExists(page)), 'the share inbox cache is gone');
+ h.check(
+ await page
+ .getByText(/shared-photo\.png is in your Library/)
+ .first()
+ .isVisible()
+ .catch(() => false),
+ 'a toast says where it went (with Show in Library)'
+ );
+ // 60 Q5: the same toast makes the shared image a reference underlay
+ const refBtn = page.getByRole('button', { name: 'Use as reference' }).first();
+ const refShown = await refBtn.isVisible().catch(() => false);
+ h.check(refShown, 'the toast offers Use as reference');
+ // a DOM click: an overlay toast (auto quality) can sit on the button and eat a real one
+ if (refShown) await refBtn.dispatchEvent('click').catch((e) => console.log(' (click: ' + e.message + ')'));
+ const refs = async () =>
+ page.evaluate(async () => (await window.__stores.underlays).underlaysDebug().mine.map((u) => u.name));
+ await until(async () => (await refs()).length > 0, 15000);
+ h.check(
+ (await refs()).length === 1,
+ 'Use as reference makes it one reference image: ' +
+ JSON.stringify(await refs()) +
+ ' (' +
+ (await page.evaluate(async () => (await window.__stores.launchInbox)?.lastLaunch?.reference)) +
+ ') toasts ' +
+ JSON.stringify(await page.locator('.tp-toast').allInnerTexts().catch(() => []))
+ );
+
+ await share(page, [
+ { name: 'shared-door.glb', mimeType: 'model/gltf-binary', buffer: fs.readFileSync(DOOR) }
+ ]);
+ await h.answerRestore(peer, 'dismiss');
+ await until(async () => (await objectNames(page)).some((n) => /shared-door/.test(n)), 30000);
+ const door = await objectNames(page);
+ h.check(
+ door.some((n) => /shared-door/.test(n)),
+ 'a shared .glb is imported into the scene: ' + JSON.stringify(door)
+ );
+
+ // ---- 3. the restore rule ------------------------------------------------------------------
+ await page.evaluate(async () => (await window.__stores.autosave).saveNow());
+ await share(page, [
+ { name: 'second-door.glb', mimeType: 'model/gltf-binary', buffer: fs.readFileSync(DOOR) }
+ ]);
+ const offered = await page
+ .waitForFunction(
+ async () => {
+ const m = await window.__stores.autosave;
+ let v = null;
+ m.restoreAvailable.subscribe((x) => (v = x))();
+ return !!v;
+ },
+ null,
+ { timeout: 20000 }
+ )
+ .then(() => true)
+ .catch(() => false);
+ h.check(offered, 'the reload after the share offers "Restore previous session?"');
+ await page.waitForTimeout(3000);
+ const held = { names: await objectNames(page), launch: await lastLaunch(page) };
+ h.check(
+ held.names.length === 0 && held.launch === null,
+ 'while the question is open NOTHING is imported',
+ held
+ );
+ await h.answerRestore(peer, 'restore', 20000);
+ await until(async () => {
+ const n = await objectNames(page);
+ return n.some((x) => /second-door/.test(x)) && n.some((x) => /shared-door/.test(x));
+ }, 30000);
+ const after = await objectNames(page);
+ h.check(
+ after.some((n) => /shared-door/.test(n)) && after.some((n) => /second-door/.test(n)),
+ 'after Restore: the restored door AND the newly shared one: ' + JSON.stringify(after)
+ );
+ await until(async () => !!(await lastLaunch(page)), 5000);
+ h.check((await lastLaunch(page))?.models === 1, 'the share was routed after the answer');
+
+ // ---- 4. file handlers ---------------------------------------------------------------------
+ // 4a. best effort: a real install + launch through CDP (Chromium's PWA domain)
+ try {
+ const bcdp = await browser.newBrowserCDPSession();
+ const manifestId = appId.appId || appId.recommendedId;
+ await bcdp.send('PWA.install', { manifestId, installUrlOrBundleUrl: h.URL });
+ const r = await bcdp.send('PWA.launchFilesInApp', { manifestId, files: [SCENE] });
+ console.log(
+ ` (CDP PWA.install + launchFilesInApp: ok, targets ${JSON.stringify(r.targetIds ?? r)})`
+ );
+ } catch (e) {
+ console.log(
+ ` (CDP PWA.install / launchFilesInApp not available here: ${String(e?.message ?? e).split('\n')[0]} — using the app's launchQueue consumer)`
+ );
+ }
+
+ // 4b. the app's launchQueue consumer, as Chromium calls it for an installed app window
+ const lq = await h.setupPage(browser, 'B', {
+ init: () => {
+ /** @type {any} */ (window).__lqConsumers = [];
+ Object.defineProperty(window, 'launchQueue', {
+ configurable: true,
+ value: {
+ setConsumer: (/** @type {any} */ fn) => /** @type {any} */ (window).__lqConsumers.push(fn)
+ }
+ });
+ }
+ });
+ await h.answerRestore(lq);
+ await lq.page
+ .waitForFunction(() => window.__lqConsumers?.length === 1, null, { timeout: 30000 })
+ .catch(() => {});
+ h.check(
+ await lq.page.evaluate(() => window.__lqConsumers?.length === 1),
+ 'the app set ONE launchQueue consumer'
+ );
+ /** @param {string} name @param {Buffer} buf */
+ const launch = (name, buf) =>
+ lq.page.evaluate(
+ ([n, b64]) => {
+ const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
+ const handle = { kind: 'file', name: n, getFile: async () => new File([bytes], n) };
+ window.__lqConsumers[0]({ targetURL: location.href, files: [handle] });
+ },
+ [name, buf.toString('base64')]
+ );
+ await launch('fluid-tank-toy.tpscene', fs.readFileSync(SCENE));
+ // software GL loads fluid-tank-toy slowly: 30 s / 10 s were red on every local slot (60-union probe)
+ await until(async () => (await lastLaunch(lq.page))?.scene === 'fluid-tank-toy.tpscene', 180000);
+ await until(
+ () =>
+ lq.page.evaluate(async () => {
+ let v = null;
+ (await window.__stores.levels).currentLevel.subscribe((x) => (v = x))();
+ return v?.name;
+ }),
+ 60000
+ );
+ const level = await lq.page.evaluate(async () => {
+ const m = await window.__stores.levels;
+ let v = null;
+ m.currentLevel.subscribe((x) => (v = x))();
+ return v;
+ });
+ const sceneObjects = await objectNames(lq.page);
+ h.check(
+ !!level?.name && level.unsaved === true && sceneObjects.length > 0,
+ `a launched .tpscene OPENS as the current scene ("${level?.name}", unsaved, ${sceneObjects.length} objects)`
+ );
+ await launch('launched-door.glb', fs.readFileSync(DOOR));
+ await until(async () => (await objectNames(lq.page)).some((n) => /launched-door/.test(n)), 30000);
+ const into = await objectNames(lq.page);
+ h.check(
+ into.some((n) => /launched-door/.test(n)) && into.length === sceneObjects.length + 1,
+ 'a second launch into the same window imports the .glb INTO that scene'
+ );
+ const levelAfter = await lq.page.evaluate(async () => {
+ const m = await window.__stores.levels;
+ let v = null;
+ m.currentLevel.subscribe((x) => (v = x))();
+ return v?.name;
+ });
+ h.check(levelAfter === level?.name, 'and the scene stayed the current one');
+ await launch('notes.txt', Buffer.from('hello'));
+ await until(async () => (await lastLaunch(lq.page))?.skipped?.[0] === 'notes.txt', 10000);
+ h.check(
+ (await objectNames(lq.page)).length === into.length,
+ 'an unsupported file changes nothing'
+ );
+
+ h.check(h.pageErrors(peer).length === 0 && h.pageErrors(lq).length === 0, 'no page errors');
+ await h.finish(browser);
+});
diff --git a/tests/e2e/material-presets-big.test.cjs b/tests/e2e/material-presets-big.test.cjs
index e4407a99a..972ffa023 100644
--- a/tests/e2e/material-presets-big.test.cjs
+++ b/tests/e2e/material-presets-big.test.cjs
@@ -119,9 +119,11 @@ h.run(async () => {
for (const conn of Object.values(peer.connections)) {
if (conn.__f2) continue;
const orig = conn.send.bind(conn);
- conn.send = (m) => {
+ // pass `chunked` through: peerjs re-sends each chunk of a big message as send(chunk, true),
+ // and dropping the flag re-chunks the chunk until the stack overflows (a test-made page error)
+ conn.send = (m, ...rest) => {
if (m?.type === 'matpresetpart') window.__parts++;
- return orig(m);
+ return orig(m, ...rest);
};
conn.__f2 = true;
}
diff --git a/tests/e2e/mesh-toolbox-redesign.test.cjs b/tests/e2e/mesh-toolbox-redesign.test.cjs
index 08bcc31b3..4262d0be5 100644
--- a/tests/e2e/mesh-toolbox-redesign.test.cjs
+++ b/tests/e2e/mesh-toolbox-redesign.test.cjs
@@ -826,7 +826,7 @@ h.run(async () => {
const r = el.getBoundingClientRect();
const body = el.querySelector('.toolbox-body');
const grip = el.querySelector('.dw-resize');
- const grab = el.querySelector('.tbx-sheet-grab');
+ const grab = el.querySelector('.tbx-sheet-resize [data-sheet-grip]'); // ui/SheetGrip since 58db817a
const tabs = el.querySelector('.tbx-tabs');
return {
left: Math.round(r.left),
@@ -834,7 +834,7 @@ h.run(async () => {
bottom: Math.round(r.bottom),
top: Math.round(r.top),
gripHidden: !grip || getComputedStyle(grip).display === 'none',
- hasGrabber: !!grab && getComputedStyle(grab.parentElement).display !== 'none',
+ hasGrabber: !!grab && grab.offsetHeight > 0,
bodyScrolls: getComputedStyle(body).overflowY === 'auto',
// tabs must stay OUT of the scrolling area so they are always reachable
tabsPinned: !body.contains(tabs)
@@ -851,7 +851,7 @@ h.run(async () => {
// the grabber resizes and the height persists
const resized = await M.page.evaluate(async () => {
const el = document.querySelector('#mesh-edit-popup');
- const handle = el.querySelector('.tbx-sheet-resize');
+ const handle = el.querySelector('.tbx-sheet-resize [data-sheet-grip]'); // the gesture is sheetDrag on its zone; events bubble to it
const before = Math.round(el.getBoundingClientRect().height);
const send = (type, y) =>
handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 7, clientY: y }));
diff --git a/tests/e2e/model-preview.test.cjs b/tests/e2e/model-preview.test.cjs
index 60dd7c587..b23f74de5 100644
--- a/tests/e2e/model-preview.test.cjs
+++ b/tests/e2e/model-preview.test.cjs
@@ -51,12 +51,14 @@ h.run(async () => {
await A.page.evaluate(() => window.__stores.enable3dPreview.set(false));
await A.page.locator('#explorer-slot').click();
await A.page.waitForTimeout(500);
+ // R22 round 12 (cefc68ee): a library OBJECT double-clicks into the file preview window
+ // (#image-preview-window, target kind 'object'); ModelPreviewWindow is the prefab shelf's now
await A.page.locator('#explorer-list .explorer-card', { hasText: 'testbox' }).first().dblclick();
- await A.page.waitForTimeout(900);
- h.check(
- (await A.page.locator('#model-preview-window').count()) === 1,
- 'double-click opens the popup even with the toggle off (P1)'
- );
+ const opened = await A.page
+ .waitForSelector('#image-preview-window', { timeout: 8000 })
+ .then(() => true, () => false);
+ const kind = await A.page.evaluate(() => new Promise((r) => window.__stores.fileWindows.imagePreviewTarget.subscribe((t) => r(t?.kind ?? null))()));
+ h.check(opened && kind === 'object', `double-click opens the preview even with the toggle off (P1) (window ${opened}, kind ${kind})`);
await h.finish(browser);
});
diff --git a/tests/e2e/net-drop.test.cjs b/tests/e2e/net-drop.test.cjs
new file mode 100644
index 000000000..79958318b
--- /dev/null
+++ b/tests/e2e/net-drop.test.cjs
@@ -0,0 +1,268 @@
+// test-health-1 (plans review #29, done/b5-network-stress.md's device smoke): A 5 s CONNECTION DROP
+// MUST NOT TEAR A TWO-PEER SESSION DOWN — no "disconnected" toast, the peer stays in the roster with
+// its locks, and what was edited during the drop arrives afterwards.
+//
+// What a real wifi drop does to a Chromium tab, and how each part is reproduced headless (tc netem
+// needs root and would hit every lane on this box):
+// * the signaling WebSocket dies (ERR_NETWORK_CHANGED) and nothing new can be fetched until the
+// network is back — `context.setOffline(true)` on both pages, and the socket closed by hand
+// (offline emulation leaves an OPEN socket alone);
+// * the data channels go silent — a page shim HOLDS every message it would deliver and releases
+// them in order when the drop ends (SCTP retransmits what it buffered once the path is back);
+// * ICE reports `disconnected` after its consent checks fail, and `connected` again after —
+// the shim reports both on every RTCPeerConnection.
+// Two shapes, both DROP_MS long (default 5000):
+// 1. QUIET: all of the above. Under ICE_DROP_GRACE_MS the same conn simply carries on; past it
+// (the 5 s default) fix-live F-3 closes the silent link and the reconnect window redials it.
+// 2. HARD (a network CHANGE): the same, plus the peer connection is closed outright at the start, so
+// the B5 reconnect window has to redial — through a signaling server it cannot reach until the
+// drop ends. The window is ~15.5 s (checks at 0.5/1/2/4/8 s), so a 5 s drop must fit inside it.
+// Counterfactual: NETDROP_MS=20000 outlasts the window — phase 2 goes red (teardown + toast; measured
+// e2e/v10cf). Phase 1 stays green at 20 s, by design: a QUIET drop never breaks the data channel, so it
+// proves the conn carries on, not the window's length.
+const h = require('./helpers.cjs');
+
+const DROP_MS = Number(process.env.NETDROP_MS || 5000);
+/** src/lib/peerDrop.js ICE_DROP_GRACE_MS */
+const ICE_DROP_GRACE_MS = 4000;
+
+/** runs in the page before any app code: the hold switch + fake ICE state */
+function dropShim() {
+ const D = (window.__drop = { holding: false, queue: /** @type {Function[]} */ ([]), pcs: /** @type {RTCPeerConnection[]} */ ([]), held: 0 });
+ const wrap = (/** @type {Function} */ fn) =>
+ function (/** @type {any} */ event) {
+ // @ts-ignore
+ const self = this;
+ if (D.holding) {
+ D.held++;
+ D.queue.push(() => fn.call(self, event));
+ return;
+ }
+ return fn.call(self, event);
+ };
+ const proto = RTCDataChannel.prototype;
+ const desc = /** @type {PropertyDescriptor} */ (Object.getOwnPropertyDescriptor(proto, 'onmessage'));
+ Object.defineProperty(proto, 'onmessage', {
+ configurable: true,
+ get() {
+ return this.__onmsg ?? null;
+ },
+ set(fn) {
+ this.__onmsg = fn;
+ desc.set?.call(this, typeof fn === 'function' ? wrap(fn) : fn);
+ }
+ });
+ const add = proto.addEventListener;
+ proto.addEventListener = function (/** @type {string} */ type, /** @type {any} */ fn, /** @type {any} */ opts) {
+ return add.call(this, type, type === 'message' && typeof fn === 'function' ? wrap(fn) : fn, opts);
+ };
+ // the signaling server stays unreachable for the whole drop: offline emulation alone let peerjs's
+ // own reconnect back in mid-drop (A was on the server again at 2.8 s), so a socket opened while
+ // the drop holds goes nowhere
+ const RealWS = window.WebSocket;
+ // @ts-ignore
+ window.WebSocket = function (/** @type {string} */ url, /** @type {any} */ protocols) {
+ return new RealWS(D.holding ? 'wss://127.0.0.1:9/' : url, protocols);
+ };
+ window.WebSocket.prototype = RealWS.prototype;
+ Object.setPrototypeOf(window.WebSocket, RealWS);
+ const Real = window.RTCPeerConnection;
+ // @ts-ignore
+ window.RTCPeerConnection = function (/** @type {any[]} */ ...args) {
+ const pc = new Real(...args);
+ D.pcs.push(pc);
+ return pc;
+ };
+ window.RTCPeerConnection.prototype = Real.prototype;
+ Object.setPrototypeOf(window.RTCPeerConnection, Real);
+ /** @param {'disconnected' | null} state null = the real one again */
+ // @ts-ignore
+ D.ice = (state) => {
+ for (const pc of D.pcs) {
+ if (pc.signalingState === 'closed') continue;
+ if (state) Object.defineProperty(pc, 'iceConnectionState', { configurable: true, get: () => state });
+ // @ts-ignore
+ else delete pc.iceConnectionState;
+ pc.dispatchEvent(new Event('iceconnectionstatechange'));
+ // @ts-ignore
+ pc.oniceconnectionstatechange?.(new Event('iceconnectionstatechange'));
+ }
+ };
+ // @ts-ignore
+ D.release = () => {
+ D.holding = false;
+ for (const run of D.queue.splice(0)) {
+ try {
+ run();
+ } catch (e) {
+ setTimeout(() => {
+ throw e;
+ });
+ }
+ }
+ };
+}
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A', { init: dropShim });
+ const B = await h.setupPage(browser, 'B', { init: dropShim });
+ await h.connect(B, A);
+
+ const store = (/** @type {any} */ p, /** @type {string} */ name) =>
+ p.page.evaluate((/** @type {string} */ n) => new Promise((r) => window.__stores[n].subscribe((/** @type {any} */ v) => r(v))()), name);
+ const inRoster = async (/** @type {any} */ p, /** @type {string} */ id) => (await store(p, 'userdata')).some((/** @type {any} */ u) => u[0] === id);
+ const openTo = (/** @type {any} */ p, /** @type {string} */ id) =>
+ p.page.evaluate((/** @type {string} */ pid) => {
+ /** @type {any} */ let pc;
+ window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))();
+ return !!pc.connections[pid]?.open && pc.openedPeers.has(pid);
+ }, id);
+ const create = (/** @type {any} */ p, /** @type {string} */ shape) =>
+ p.page.evaluate((/** @type {string} */ s) => {
+ window.__stores.commandsHandler.sceneCommand('/create ' + s);
+ /** @type {any} */ let g;
+ window.__stores.objectsGroup.subscribe((/** @type {any} */ v) => (g = v))();
+ return g.children[g.children.length - 1].uuid;
+ }, shape);
+ const has = (/** @type {any} */ p, /** @type {string} */ uuid) =>
+ p.page.evaluate((/** @type {string} */ u) => {
+ /** @type {any} */ let g;
+ window.__stores.objectsGroup.subscribe((/** @type {any} */ v) => (g = v))();
+ return !!g?.getObjectByProperty('uuid', u);
+ }, uuid);
+ const disconnectToasts = async (/** @type {any} */ p, /** @type {number} */ from) =>
+ (await store(p, 'notifications')).slice(from).filter((/** @type {any} */ n) => /disconnected/.test(String(n.text))).length;
+ const signalingUp = (/** @type {any} */ p) =>
+ p.page.evaluate(() => {
+ /** @type {any} */ let pc;
+ window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))();
+ return !!pc.peer && !pc.peer.disconnected && pc.peer.open !== false;
+ });
+
+ await h.eventually(() => openTo(A, B.id), (v) => v, 'premise: A holds an open conn to B');
+ await h.eventually(() => openTo(B, A.id), (v) => v, 'premise: B holds an open conn to A');
+ // a created object is selected = locked by its creator; B always holds one from here on (each
+ // create moves it), and a teardown releases every lock of the departed peer
+ const bLocks = async () => (await store(A, 'lockedObjects')).filter((/** @type {any} */ x) => x[0] === B.id).length;
+ const lockBox = await create(B, 'box');
+ await h.eventually(() => store(A, 'lockedObjects'), (l) => l.some((/** @type {any} */ x) => x[0] === B.id && x[1] === lockBox), "premise: A sees B's lock");
+
+ /** @param {'quiet' | 'hard'} shape */
+ async function drop(shape) {
+ const tag = shape.toUpperCase() + ` ${DROP_MS / 1000}s drop:`;
+ const toastsA = (await store(A, 'notifications')).length;
+ const toastsB = (await store(B, 'notifications')).length;
+ // mark the conns, to tell "the same conn carried on" from "a redial healed it"
+ for (const [p, other] of [[A, B.id], [B, A.id]]) {
+ await p.page.evaluate((/** @type {string} */ id) => {
+ /** @type {any} */ let pc;
+ window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))();
+ pc.connections[id].__th1 = 'before';
+ }, other);
+ }
+ // ---- the network goes away
+ for (const p of [A, B]) await p.page.context().setOffline(true);
+ for (const p of [A, B]) {
+ await p.page.evaluate(() => {
+ /** @type {any} */ let pc;
+ window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))();
+ // @ts-ignore
+ window.__drop.holding = true;
+ // @ts-ignore
+ window.__drop.ice('disconnected');
+ try {
+ pc.peer._socket._socket?.close();
+ } catch {}
+ });
+ }
+ // the premise is read AT THE CUT: whether peerjs's own reconnect later slips past the shim (it did,
+ // now and then) is the app recovering, not the drop failing to happen
+ let cut = false;
+ for (const t = Date.now(); !cut && Date.now() - t < 3000; await A.page.waitForTimeout(150))
+ cut = !(await signalingUp(A)) && !(await signalingUp(B)); // peerjs marks itself down on the socket's close event
+ if (shape === 'hard') {
+ await A.page.evaluate((/** @type {string} */ id) => {
+ /** @type {any} */ let pc;
+ window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))();
+ pc.connections[id].peerConnection?.close();
+ }, B.id);
+ }
+ const t0 = Date.now();
+ /** @type {string[]} */
+ const lost = [];
+ /** what each side's reconnect window is doing, printed if the heal fails @type {string[]} */
+ const timeline = [];
+ const state = (/** @type {any} */ p, /** @type {string} */ id) =>
+ p.page
+ .evaluate((/** @type {string} */ pid) => {
+ /** @type {any} */ let pc;
+ window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))();
+ const c = pc.connections[pid];
+ const age = (/** @type {any} */ x) => (x?.__dialedAt ? Math.round((Date.now() - x.__dialedAt) / 100) / 10 + 's' : '-');
+ return `sig=${pc.peer?.open ? 1 : 0} rec=${pc.reconnecting.get(pid) ?? '-'} conn=${c ? (c.open ? 'open' : 'neg ' + age(c)) : 'none'} in=${pc.inboundDials?.has(pid) ? 'neg ' + age(pc.inboundDials.get(pid)) : '-'} opened=${pc.openedPeers.has(pid) ? 1 : 0}`;
+ }, id)
+ .catch((/** @type {any} */ e) => 'err ' + String(e?.message ?? e).slice(0, 60));
+ const watch = async () => {
+ timeline.push(`${((Date.now() - t0) / 1000).toFixed(1)}s A[${await state(A, B.id)}] B[${await state(B, A.id)}]`);
+ if (!(await inRoster(A, B.id))) lost.push(`A lost B at ${Date.now() - t0}ms`);
+ if (!(await inRoster(B, A.id))) lost.push(`B lost A at ${Date.now() - t0}ms`);
+ if ((await bLocks()) === 0) lost.push(`A dropped B's lock at ${Date.now() - t0}ms`);
+ };
+ // an edit on each side DURING the drop
+ await A.page.waitForTimeout(Math.min(1500, DROP_MS / 3));
+ const duringA = await create(A, 'sphere');
+ const duringB = await create(B, 'cylinder');
+ while (Date.now() - t0 < DROP_MS) {
+ await watch();
+ await A.page.waitForTimeout(500);
+ }
+ h.check(cut, `${tag} premise: the signaling link really was down on both pages (at the cut)`);
+ // ---- the network comes back: lift the shim's hold FIRST, so the app's own `online` retry
+ // (fired by setOffline(false)) reaches the real server rather than the held-down socket
+ for (const p of [A, B]) {
+ await p.page.evaluate(() => {
+ // @ts-ignore
+ window.__drop.ice(null);
+ // @ts-ignore
+ window.__drop.release();
+ });
+ }
+ for (const p of [A, B]) await p.page.context().setOffline(false);
+ // watch the roster through the rest of the reconnect window: ~15.5 s from the drop's start, plus
+ // the up-to-20 s its last check gives a dial still negotiating (VERDICT_GRACE_MS)
+ const healed = { a: false, b: false };
+ while (Date.now() - t0 < Math.max(DROP_MS, 0) + 37000) {
+ await watch();
+ healed.a ||= await openTo(A, B.id);
+ healed.b ||= await openTo(B, A.id);
+ if (healed.a && healed.b && Date.now() - t0 > 16000) break;
+ await A.page.waitForTimeout(500);
+ }
+ h.check(lost.length === 0, `${tag} each peer kept the other in its roster, and B's lock stood, throughout (${lost.slice(0, 2).join(', ') || 'never lost'})`);
+ const healedBoth = (await openTo(A, B.id)) && (await openTo(B, A.id));
+ if (!healedBoth) console.log(` ${tag} timeline (A=${A.id} B=${B.id}; the lower id dials):\n ` + timeline.join('\n '));
+ h.check(healedBoth, `${tag} the conn is open both ways again`);
+ h.check((await disconnectToasts(A, toastsA)) === 0 && (await disconnectToasts(B, toastsB)) === 0, `${tag} no "disconnected" toast on either side`);
+ await h.eventually(() => has(B, duringA), (v) => v, `${tag} A's edit made during the drop reaches B`, 15000);
+ await h.eventually(() => has(A, duringB), (v) => v, `${tag} B's edit made during the drop reaches A`, 15000);
+ const afterB = await create(B, 'cone');
+ await h.eventually(() => has(A, afterB), (v) => v, `${tag} an edit after the drop replicates`, 10000);
+ await h.eventually(async () => (await signalingUp(A)) && (await signalingUp(B)), (v) => v, `${tag} both pages are back on the signaling server`, 20000);
+ const same = await A.page.evaluate((/** @type {string} */ id) => {
+ /** @type {any} */ let pc;
+ window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))();
+ return pc.connections[id]?.__th1 === 'before';
+ }, B.id);
+ // fix-live F-3 closes a link whose ICE sits `disconnected` past ICE_DROP_GRACE_MS (4 s,
+ // src/lib/peerDrop.js) and hands it to the reconnect window, so a quiet drop LONGER than that
+ // heals by a redial; the checks above (roster, locks, no toast, edits both ways) are the promise
+ if (shape === 'quiet' && DROP_MS < ICE_DROP_GRACE_MS) h.check(same, `${tag} the SAME conn carried on (no redial was needed)`);
+ else if (shape === 'quiet') console.log(` ${tag} healed by ${same ? 'the same conn' : 'a redial (F-3: ICE held disconnected past the grace)'}`);
+ else h.check(!same, `${tag} premise: the conn really was replaced by the reconnect window`);
+ }
+
+ await drop('quiet');
+ await drop('hard');
+ await h.finish(browser);
+});
diff --git a/tests/e2e/net-reconnect.test.cjs b/tests/e2e/net-reconnect.test.cjs
index 6781f1c99..72be9ba87 100644
--- a/tests/e2e/net-reconnect.test.cjs
+++ b/tests/e2e/net-reconnect.test.cjs
@@ -1,7 +1,8 @@
// B5 / phase-172 deferred item 1: transient drops heal instead of tearing down.
//
// An OPEN conn dropping without a goodbye now gets a bounded redial window
-// (500/1000/2000/4000ms, netBackoff) before any teardown: the peer's roster
+// (checks at 0.5/1.5/3.5/7.5/15.5 s, netBackoff; the last waits out a dial still
+// negotiating, up to DIAL_GRACE_MS) before any teardown: the peer's roster
// entry, avatar and locks survive a wifi wobble, and no "disconnected" toast
// fires unless they are really gone. Announced leaves (leaveSession broadcasts
// the existing `disconnected` message about itself; pagehide does the same
@@ -31,7 +32,8 @@ h.run(async () => {
return !!pc.connections[pid]?.open && pc.openedPeers.has(pid);
}, id);
- await h.eventually(() => openTo(C, B.id), (v) => v, 'mesh formed: C holds an open conn to B');
+ // a mesh-fill dial negotiates like any other: ~6-12 s on a loaded box, past eventually's 10 s default
+ await h.eventually(() => openTo(C, B.id), (v) => v, 'mesh formed: C holds an open conn to B', 26000);
// --- a relayed 'disconnected' about a live peer is ignored
await C.page.evaluate(([aid, bid]) => {
@@ -69,14 +71,18 @@ h.run(async () => {
(await store(A, 'lockedObjects')).some((x) => x[0] === B.id && x[1] === uuid),
"B's lock survives the blip"
);
- await h.eventually(() => openTo(A, B.id), (v) => v, 'the conn to B is re-opened by the backoff', 12000);
+ // bounded by the product's own window (15.5 s + a negotiating dial's 10 s grace): a 12 s wait was
+ // shorter than the window it tests, and a redial measured 6-9 s to open on a loaded box
+ await h.eventually(() => openTo(A, B.id), (v) => v, 'the conn to B is re-opened by the backoff', 26000);
const notifs = await store(A, 'notifications');
h.check(
!notifs.slice(notifCountBefore).some((n) => String(n.text).includes('disconnected')),
'no disconnect toast fired for a drop that healed'
);
- // messages flow again after the heal
+ // messages flow again after the heal — from B, so B's own side must be healed too (A's open conn
+ // is the check above; B adopts the redial, or dials it, on its own schedule)
+ await h.eventually(() => openTo(B, A.id), (v) => v, "(premise) B's side of the conn is open again", 26000);
await B.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create sphere'));
const countOn = (p) =>
p.page.evaluate(() => {
@@ -85,7 +91,30 @@ h.run(async () => {
return g?.children?.length ?? 0;
});
const bCount = await countOn(B);
- await h.eventually(() => countOn(A), (c) => c >= bCount, "B's new object reaches A after the heal", 10000);
+ let reached = false;
+ await h.eventually(() => countOn(A), (c) => (reached = c >= bCount), "B's new object reaches A after the heal", 10000);
+ // both links are open by now, so a red here is past the transport: say whether a share-or-stash ask
+ // (gateHolds — it drops a peer's room content while open) or a connect decision is holding it
+ if (!reached)
+ for (const [name, p, other] of [['A', A, B], ['B', B, A]])
+ console.log(
+ ` ${name} after the heal: ` +
+ JSON.stringify(
+ await p.page.evaluate((o) => {
+ const s = window.__stores;
+ let decision = null;
+ let notes = [];
+ s.connectionState?.pendingConnectDecision?.subscribe?.((v) => (decision = v))?.();
+ s.notifications?.subscribe?.((v) => (notes = v))?.();
+ return {
+ gateHolds: s.sessions?.gateHolds?.(o) ?? null,
+ decision: decision ? Object.keys(decision) : null,
+ objects: (() => { let g; s.objectsGroup.subscribe((v) => (g = v))(); return g?.children?.map((c) => c.name); })(),
+ lastToasts: notes.slice(-3).map((n) => String(n.text).slice(0, 90))
+ };
+ }, other.id)
+ )
+ );
// --- announced leave tears down promptly, with no redial spam
const t0 = Date.now();
diff --git a/tests/e2e/net-relink.test.cjs b/tests/e2e/net-relink.test.cjs
new file mode 100644
index 000000000..b3d04d91d
--- /dev/null
+++ b/tests/e2e/net-relink.test.cjs
@@ -0,0 +1,87 @@
+// @ts-nocheck
+// 60-int: a ONE-SIDED drop heals both ways (`relink`, peerHandler.adoptRelink).
+//
+// net-drop on the deployed preview (1.44.0 proof) lost ONE direction's edits after a heal: one end
+// closed the link (F-3's ICE grace, or the drop itself) and redialled, while the other end's conn still
+// read `open` — a close never crosses a dead link — so it refused the inbound heal ("the stable
+// outgoing conn stays preferred") and kept sending, and answering the heal's full-state requests,
+// into a conn nobody reads. Locally the close always crosses, so net-drop cannot show it; this suite
+// MAKES the zombie: on the higher id (the end that never redials) the conn keeps reading open, swallows
+// every send and hears no close, and the lower id closes its own end, which runs the reconnect window.
+//
+// Counterfactual: without the relink (sendHandshake's first send / adoptRelink) checks 2-4 go red —
+// Z's edits and Z's full-state reply stay in the zombie.
+const h = require('./helpers.cjs');
+
+h.run(async () => {
+ const browser = await h.launch();
+ const P = await h.setupPage(browser, 'P');
+ const Q = await h.setupPage(browser, 'Q');
+ await h.connect(Q, P);
+ // the LOWER id redials in the reconnect window; the higher keeps the zombie
+ const [L, Z] = P.id < Q.id ? [P, Q] : [Q, P];
+ console.log(` redialer L=${L.id} zombie Z=${Z.id}`);
+
+ const openTo = (p, id) =>
+ p.page.evaluate((pid) => {
+ let pc;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ return !!pc.connections[pid]?.open && pc.openedPeers.has(pid);
+ }, id);
+ const create = (p, shape) =>
+ p.page.evaluate((s) => {
+ window.__stores.commandsHandler.sceneCommand('/create ' + s);
+ let g;
+ window.__stores.objectsGroup.subscribe((v) => (g = v))();
+ return g.children[g.children.length - 1].uuid;
+ }, shape);
+ const has = (p, uuid) =>
+ p.page.evaluate((u) => {
+ let g;
+ window.__stores.objectsGroup.subscribe((v) => (g = v))();
+ return !!g?.getObjectByProperty('uuid', u);
+ }, uuid);
+
+ await h.eventually(() => openTo(L, Z.id), (v) => v, 'premise: L holds an open conn to Z');
+ await h.eventually(() => openTo(Z, L.id), (v) => v, 'premise: Z holds an open conn to L');
+ const warm = await create(Z, 'box');
+ await h.eventually(() => has(L, warm), (v) => v, "premise: Z's edit reaches L before the drop");
+
+ // ---- Z's conn becomes a zombie: reads open, sends go nowhere, no close and no ICE watch reach it
+ await Z.page.evaluate((id) => {
+ let pc;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ const c = pc.connections[id];
+ c.__zombie = true;
+ c.removeAllListeners('close');
+ c.removeAllListeners('iceStateChanged');
+ Object.defineProperty(c, 'open', { configurable: true, get: () => true });
+ c.send = () => {};
+ }, L.id);
+ // an edit Z makes while its link is dead
+ const during = await create(Z, 'sphere');
+ // ---- L's end closes (as F-3's silence close does) -> L's reconnect window redials Z
+ await L.page.evaluate((id) => {
+ let pc;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ pc.connections[id].close();
+ }, Z.id);
+
+ await h.eventually(() => openTo(L, Z.id), (v) => v, 'L redialled Z and the conn is open', 30000);
+ const swapped = () =>
+ Z.page.evaluate((id) => {
+ let pc;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ const c = pc.connections[id];
+ return !!c && !c.__zombie && c.open;
+ }, L.id);
+ await h.eventually(swapped, (v) => v, "Z replaced its zombie with L's new conn (relink)", 15000);
+ await h.eventually(() => has(L, during), (v) => v, "Z's edit made while its link was dead reaches L (the heal's re-sync)", 15000);
+ const after = await create(Z, 'cone');
+ await h.eventually(() => has(L, after), (v) => v, "Z's edit after the heal reaches L", 10000);
+ const back = await create(L, 'cylinder');
+ await h.eventually(() => has(Z, back), (v) => v, "L's edit after the heal reaches Z", 10000);
+ const toasts = async (p) => (await p.page.evaluate(() => new Promise((r) => window.__stores.notifications.subscribe((v) => r(v))()))).filter((n) => /disconnected/.test(String(n.text))).length;
+ h.check((await toasts(L)) === 0 && (await toasts(Z)) === 0, 'no "disconnected" toast on either side');
+ await h.finish(browser);
+});
diff --git a/tests/e2e/net-stress.test.cjs b/tests/e2e/net-stress.test.cjs
index ef68d23bb..a862cb179 100644
--- a/tests/e2e/net-stress.test.cjs
+++ b/tests/e2e/net-stress.test.cjs
@@ -77,6 +77,11 @@ const installProbe = (peer) =>
return ns.allConns().length;
};
ns.hook();
+ // 60 Q2: camera poses ride the FAST channel when both ends have it — the conn 'data' above
+ // only sees the reliable fallback, so count the fast arrivals through the pose tap too
+ w.__stores.fastChannel?.setPoseTap?.((peer, d, via) => {
+ if (via === 'fast' && d.type === 'camera' && d.peerId) ns.cam[d.peerId] = (ns.cam[d.peerId] || 0) + 1;
+ });
if (!ns.auto) ns.auto = setInterval(() => ns.hook(), 250);
ns.send = (uuid, seq) => {
const t = performance.now();
diff --git a/tests/e2e/panels.test.cjs b/tests/e2e/panels.test.cjs
index 40e51f876..626d14b42 100644
--- a/tests/e2e/panels.test.cjs
+++ b/tests/e2e/panels.test.cjs
@@ -57,10 +57,14 @@ h.run(async () => {
h.check(await A.page.getByText('Objects', { exact: false }).first().isVisible(), 'object list header visible');
h.check(await A.page.locator('#object-search').isVisible(), 'search survived the restyle');
- // save dropdown format picker still opens
- await A.page.getByRole('button', { name: /json/i }).first().click();
- await A.page.waitForTimeout(300);
- h.check(await A.page.getByText('GLTF', { exact: true }).isVisible(), 'save format dropdown opens');
+ // the save format picker still opens — Save options (the burger's Save ▾) since 41b replaced the
+ // old .json dropdown; GLTF is behind "Offer GLTF", so assert the two formats it always offers
+ await A.page.evaluate(() => window.__stores.closeMenu.set(false));
+ await A.page.waitForSelector('#save-options-open', { timeout: 5000 });
+ await A.page.locator('#save-options-open').click();
+ await A.page.waitForSelector('#save-options-modal', { state: 'visible', timeout: 5000 });
+ const formats = await A.page.evaluate(() => [...document.querySelectorAll('#save-format .seg-opt')].map((b) => b.textContent?.trim()));
+ h.check(formats.includes('Project') && formats.includes('Scene'), `save format picker opens (${JSON.stringify(formats)})`);
await h.finish(browser);
});
diff --git a/tests/e2e/phone-bar-42.test.cjs b/tests/e2e/phone-bar-42.test.cjs
index c8c0d5f34..26e91d335 100644
--- a/tests/e2e/phone-bar-42.test.cjs
+++ b/tests/e2e/phone-bar-42.test.cjs
@@ -110,7 +110,7 @@ h.run(async () => {
h.check(JSON.stringify(sec('Windows').slice(0, 4)) === JSON.stringify(['add', 'more', 'objects', 'stats']), `More ▸ Windows starts + Add, More, Objects, Statistics (${JSON.stringify(sec('Windows').slice(0, 6))})`);
h.check(!sec('Windows').includes('play'), 'Play is not in More while it is on the bar');
h.check(['inspect', 'move', 'rotate', 'scale', 'undo', 'redo', 'multiselect', 'interact'].every((k) => sec('Edit').includes(k)), `More ▸ Edit holds Inspect, Move, Rotate, Scale + Undo, Redo, Select multiple, Interact (${JSON.stringify(sec('Edit'))})`);
- h.check(JSON.stringify(sec('Tools & View')) === JSON.stringify(['tools', 'focus', 'budget', 'ar']) && (await read(() => document.getElementById('ps-viewport-tools')?.textContent?.trim())) === 'Tools & View', 'More ▸ Tools & View: placeable tiles "Tools & View" + (42 I14) "Focus mode" + (46 B1) the scene budget + (50 R1) the AR table');
+ h.check(JSON.stringify(sec('Tools & View')) === JSON.stringify(['tools', 'focus', 'budget', 'ar', 'playhere']) && (await read(() => document.getElementById('ps-viewport-tools')?.textContent?.trim())) === 'Tools & View', 'More ▸ Tools & View: placeable tiles "Tools & View" + (42 I14) "Focus mode" + (46 B1) the scene budget + (50 R1) the AR table + (60 Q1) Play here');
const foot = await read(() => {
const body = document.getElementById('ps-more-sheet');
const hint = document.getElementById('ps-more-hint');
diff --git a/tests/e2e/physics-discoverability.test.cjs b/tests/e2e/physics-discoverability.test.cjs
index 58405c423..06e4564a0 100644
--- a/tests/e2e/physics-discoverability.test.cjs
+++ b/tests/e2e/physics-discoverability.test.cjs
@@ -29,6 +29,8 @@ h.run(async () => {
b1.name = 'PropsBox';
b2.name = 'FlowBox';
b3.name = 'BareBox';
+ // /create makes every box dynamic since 95df3a66 — this suite needs them bare to start with
+ for (const b of [b1, b2, b3]) delete b.userData.physics;
// spread them out — overlapping colliders would explode the fallback sim
b1.position.set(0, 0.5, 0);
b2.position.set(3, 0.5, 0);
diff --git a/tests/e2e/physics-kinematic.test.cjs b/tests/e2e/physics-kinematic.test.cjs
index 2c3c2eb4b..be6884a7d 100644
--- a/tests/e2e/physics-kinematic.test.cjs
+++ b/tests/e2e/physics-kinematic.test.cjs
@@ -51,6 +51,9 @@ h.run(async () => {
const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)());
const [slab, box] = group.children.slice(-2);
slab.position.set(0, 1, 0);
+ // /create makes every primitive dynamic since 95df3a66 — the slab must be scenery, so the spin
+ // effect is what makes it a KINEMATIC platform (a dynamic slab just falls to the ground)
+ delete slab.userData.physics;
box.position.set(2, 1.75, 0); // resting on the slab top, 2m off-axis
// dynamic via Inspector-style userData.physics (NO flow mass node)
box.userData.physics = { mode: 'dynamic', mass: 1 };
@@ -58,6 +61,7 @@ h.run(async () => {
peer.send({ type: 'move', uuid: slab.uuid, pos: [0, 1, 0], rot: [0, 0, 0], scale: [1, 1, 1] });
peer.send({ type: 'move', uuid: box.uuid, pos: [2, 1.75, 0], rot: [0, 0, 0], scale: [1, 1, 1] });
peer.send({ type: 'objectParameters', parameter: 'physics', uuid: box.uuid, physics: box.userData.physics });
+ peer.send({ type: 'objectParameters', parameter: 'physics', uuid: slab.uuid, physics: null });
// spin effect on the slab -> it becomes a KINEMATIC platform mid-sim
const nodes = [
{ id: 'sp1', type: 'spin', position: { x: 0, y: 0 }, data: { type: 'spin', axis: 'y', speed: 2 }, class: 'w-[150px]' },
diff --git a/tests/e2e/place-drag-cache.test.cjs b/tests/e2e/place-drag-cache.test.cjs
index 5198247c4..4cea135b9 100644
--- a/tests/e2e/place-drag-cache.test.cjs
+++ b/tests/e2e/place-drag-cache.test.cjs
@@ -59,7 +59,8 @@ const cacheFacts = (page) =>
s.packCache.packCacheIndex.subscribe((v) => (index = v))();
const urls = [...index.keys()];
const duck = urls.find((u) => /Duck\.glb$/.test(u)) ?? null;
- const match = duck ? await (await caches.open(s.packCache.PACK_CACHE_NAME)).match(duck) : null;
+ // 60 Q6: the pack cache lives in the local asset cache (OPFS) now — ask it, not CacheStorage
+ const match = duck ? await s.packCache.cachedPackBuffer(duck) : null;
return { urls, duck, bytes: duck ? index.get(duck) : 0, inCache: !!match, wanted: url };
}, DUCK_URL);
diff --git a/tests/e2e/play-here-iwer.test.cjs b/tests/e2e/play-here-iwer.test.cjs
new file mode 100644
index 000000000..de9c4a9e2
--- /dev/null
+++ b/tests/e2e/play-here-iwer.test.cjs
@@ -0,0 +1,147 @@
+// 60 Q1: PLAY HERE IN A HEADSET (IWER — the app's own WebXR path end to end). The radial's
+// Tools ▸ Play here switches to Interact (the headset's Play) with the FEET on the surface under the
+// head, facing where the head looks — not on the scene's spawn — and Tools ▸ Back to edit (or the mode
+// button) puts the viewer, and any world grab they had, back exactly where they were.
+//
+// IWER's offset reference spaces never reach the XR camera it reports (vr-comfort-iwer's note), so
+// the rows read WHAT THE APP APPLIED: every XRRigidTransform the rig moves by is recorded — the
+// horizontal move, the lift and the turn of a spawn — and the restore is read as the reference
+// space OBJECT itself (Back to edit hands back the very space Edit had).
+const h = require('./helpers.cjs');
+const iwer = require('./iwer.cjs');
+
+const near = (a, b, eps = 0.06) => Math.abs(a - b) < eps;
+const mode = (page) => page.evaluate(() => { let v; window.__stores.editorMode.subscribe((x) => (v = x))(); return v; });
+const SPY = () => {
+ if (window.__rigSpy) return;
+ const Orig = window.XRRigidTransform;
+ window.__rigSpy = [];
+ window.XRRigidTransform = class extends Orig {
+ constructor(p, o) {
+ super(p, o);
+ window.__rigSpy.push({ p: p ? [p.x || 0, p.y || 0, p.z || 0] : [0, 0, 0], yaw: o ? 2 * Math.atan2(o.y || 0, o.w ?? 1) : 0 });
+ }
+ };
+};
+/** the offsets applied since the last call: {move (m, horizontal), lift (m), turns: [yaw…]} */
+const applied = (page) =>
+ page.evaluate(() => {
+ const list = window.__rigSpy.splice(0);
+ let x = 0, y = 0, z = 0;
+ const turns = [];
+ for (const r of list) {
+ if (Math.abs(r.yaw) > 1e-4) turns.push(+r.yaw.toFixed(3));
+ else {
+ x += r.p[0];
+ y += r.p[1];
+ z += r.p[2];
+ }
+ }
+ return { move: +Math.hypot(x, z).toFixed(3), lift: +(-y).toFixed(3), turns, n: list.length };
+ });
+/** remember the live reference space as `name`, or ask whether it is still that one */
+const space = (page, name, op) =>
+ page.evaluate(({ name, op }) => {
+ let r;
+ window.__stores.globalRenderer.subscribe((v) => (r = v))();
+ const now = r.xr.getReferenceSpace();
+ if (op === 'keep') return !!(window['__space_' + name] = now);
+ return now === window['__space_' + name];
+ }, { name, op });
+const rigPose = (page) =>
+ page.evaluate(() => { let r; window.__stores.worldRig.subscribe((x) => (r = x))(); return { p: r.position.toArray(), s: r.scale.x }; });
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A', { init: iwer.initScript() });
+ const P = A.page;
+ h.check(await iwer.installed(P), 'premise: IWER owns navigator.xr');
+
+ // a box at (8, _, −6) whose top is 1 m up (below the 1.6 m head)
+ await P.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box'));
+ await P.waitForTimeout(500);
+ const top = await P.evaluate(() => {
+ const s = window.__stores;
+ let g;
+ s.objectsGroup.subscribe((x) => (g = x))();
+ const b = g.children.at(-1);
+ b.scale.set(4, 1, 4);
+ b.position.set(8, 0.5, -6);
+ b.updateMatrixWorld(true);
+ return new s.THREE.Box3().setFromObject(b).max.y;
+ });
+ // a plain scene: the session starts in Edit; the spawn (which makes it a game) comes after
+ await iwer.enter(P);
+ await iwer.frames(P, 4);
+ h.check((await mode(P)) === 'edit', 'premise: the headset is in Edit');
+ await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { spawn: { position: [-10, 0, 10], yaw: 0 } } }));
+ await P.evaluate(SPY);
+
+ // ---- 1. stand over the box looking half-left, Tools ▸ Play here ---------------------------------
+ await iwer.pose(P, 'head', [8, 1.6, -6], { yaw: 0.5 });
+ await iwer.frames(P, 4);
+ await space(P, 'edit', 'keep');
+ await applied(P);
+ const entry = await P.evaluate(() => window.__stores.vrRadialMenu.ringEntries('tools').map((e) => e.id));
+ h.check(entry.includes('playhere'), `the radial's Tools ring offers Play here (${JSON.stringify(entry)})`);
+ await P.evaluate(() => window.__stores.vrControls.executeVRMenuAction('playhere'));
+ await iwer.frames(P, 8);
+ h.check((await mode(P)) === 'interact', 'Play here switches to Interact');
+ const dbg = await P.evaluate(() => window.__stores.playHere.playHereDebug());
+ h.check(
+ dbg.playing === true && dbg.override && near(dbg.override.position[0], 8) && near(dbg.override.position[2], -6) && near(dbg.override.position[1], top) && near(dbg.override.yaw, 0.5, 0.02),
+ `the round's spawn: under the head, on the box top, facing the head (${JSON.stringify(dbg.override)})`
+ );
+ let a = await applied(P);
+ h.check(a.move < 0.05, `...so the player is NOT moved across the world to the spawn (moved ${a.move} m)`);
+ h.check(near(a.lift, top, 0.05), `...the feet are lifted onto the box top (lift ${a.lift} m vs ${top.toFixed(2)})`);
+ h.check(a.turns.every((t) => Math.abs(t) < 0.02), `...and the view keeps its facing (turns ${JSON.stringify(a.turns)})`);
+ h.check(!(await space(P, 'edit', 'same')), 'Interact plays in a moved reference space');
+
+ // ---- 2. Back to edit: the very reference space Edit had --------------------------------------
+ h.check(!!(await P.evaluate(() => window.__stores.vrRadialMenu.ringEntries('tools').find((e) => e.id === 'playhere'))), 'the entry stays while the round runs');
+ await P.evaluate(() => window.__stores.vrControls.executeVRMenuAction('playhere'));
+ await iwer.frames(P, 6);
+ h.check((await mode(P)) === 'edit', 'Back to edit: Edit');
+ h.check(await space(P, 'edit', 'same'), '...in the reference space Edit had (the view is where it was)');
+ const after = await P.evaluate(() => window.__stores.playHere.playHereDebug());
+ h.check(after.override === null && after.playing === false, 'the override is gone');
+
+ // ---- 3. a world grab (miniature) is put back too; Interact played it at 1:1 -------------------
+ await P.evaluate(() => {
+ let r;
+ window.__stores.worldRig.subscribe((x) => (r = x))();
+ r.scale.setScalar(0.5);
+ r.position.set(4, 0, -3);
+ r.updateMatrixWorld(true);
+ });
+ await iwer.frames(P, 3);
+ await applied(P);
+ await P.evaluate(() => window.__stores.vrControls.executeVRMenuAction('playhere'));
+ await iwer.frames(P, 8);
+ let rig = await rigPose(P);
+ h.check((await mode(P)) === 'interact' && rig.s === 1 && rig.p.every((x) => x === 0), `Play here plays at 1:1 (${JSON.stringify(rig)})`);
+ // the content point under the head: (8 − 4) / 0.5 = 8, (−6 + 3) / 0.5 = −6 → over the box again
+ const o3 = (await P.evaluate(() => window.__stores.playHere.playHereDebug())).override;
+ h.check(o3 && near(o3.position[0], 8, 0.1) && near(o3.position[2], -6, 0.1) && near(o3.position[1], top), `...from the content point under the head (${JSON.stringify(o3)})`);
+ // the mode button (left Y) is the other way back
+ await iwer.tap(P, 'left', 5);
+ await iwer.frames(P, 6);
+ rig = await rigPose(P);
+ h.check((await mode(P)) === 'edit', 'left Y: back to Edit');
+ h.check(rig.s === 0.5 && near(rig.p[0], 4) && near(rig.p[2], -3), `...and the world grab is back (${JSON.stringify(rig)})`);
+ h.check(await space(P, 'edit', 'same'), '...in the reference space Edit had');
+
+ // ---- 4. an ordinary Interact afterwards uses the scene's spawn (the override did not leak) -----
+ await applied(P);
+ await iwer.tap(P, 'left', 5);
+ await iwer.frames(P, 8);
+ h.check((await mode(P)) === 'interact', 'left Y: an ordinary Interact');
+ a = await applied(P);
+ const toSpawn = Math.hypot(-10 - 8, 10 + 6);
+ h.check(near(a.move, toSpawn, 0.15), `...moves the player to the scene's spawn (${a.move} m vs ${toSpawn.toFixed(2)})`);
+ h.check(a.turns.some((t) => Math.abs(Math.abs(t) - 0.5) < 0.03), `...and turns them to its facing (turns ${JSON.stringify(a.turns)})`);
+
+ await iwer.exit(P);
+ await h.finish(browser);
+});
diff --git a/tests/e2e/play-here.test.cjs b/tests/e2e/play-here.test.cjs
new file mode 100644
index 000000000..bccffe173
--- /dev/null
+++ b/tests/e2e/play-here.test.cjs
@@ -0,0 +1,235 @@
+// 60 Q1: PLAY HERE (desktop + phone). The player starts where the editor camera is — the feet on the
+// surface straight under it, facing the view's heading — and leaving Play puts the editor view and the
+// selection back. Nothing under the camera → the scene's spawn; no spawn either → the camera's own eye.
+// The override is a ONE-ROUND thing: the next ordinary Play lands on the scene's spawn again.
+// HEIGHT: desktop Play is GROUNDED by default (37 R24 — PointerLockControls pins the eye at the
+// scene's eye height), so the surface height counts where the scene lets the player fly (or a walk
+// controller owns Y); a grounded scene keeps X/Z + facing and its own eye height. Both are covered.
+// Entry points covered: Ctrl+Shift+Enter, the Play button's right-click row, the optional toolbar
+// button, and on a phone the Play long-press row + More ▸ Play here.
+// IWER (the headset) lives in play-here-iwer.test.cjs.
+const h = require('./helpers.cjs');
+
+const store = (name) => `(() => { let v; window.__stores.${name}.subscribe((x) => (v = x))(); return v; })()`;
+const near = (a, b, eps = 0.06) => Math.abs(a - b) < eps;
+const wrap = (a) => Math.atan2(Math.sin(a), Math.cos(a));
+const SPAWN = { position: [3, 0, -4], yaw: Math.PI / 2 };
+/** the toolbar with the optional Play here button placed at the end of the bar */
+const BAR = JSON.stringify({
+ order: ['move', 'rotate', 'scale', 'pivot', 'mode', 'objects', 'flow', 'explorer', 'animation', 'playhere'],
+ hidden: [],
+ spacerIndex: 5,
+ seen: ['move', 'rotate', 'scale', 'pivot', 'mode', 'objects', 'flow', 'explorer', 'animation', 'playhere', 'ai', 'add', 'chat', 'mic', 'notes', 'notifications', 'peers', 'undo', 'redo', 'multiselect']
+});
+
+/** the play camera's world eye + heading, the editor camera's pose + orbit target */
+const cams = (page) =>
+ page.evaluate(() => {
+ const s = window.__stores;
+ const THREE = s.THREE;
+ const get = (st) => { let v; st.subscribe((x) => (v = x))(); return v; };
+ const pc = get(s.playerCam);
+ const ec = get(s.editorCam);
+ const oc = get(s.orbitControls);
+ const f = new THREE.Vector3(0, 0, -1).applyQuaternion(pc.getWorldQuaternion(new THREE.Quaternion()));
+ return {
+ player: pc.getWorldPosition(new THREE.Vector3()).toArray(),
+ playerYaw: Math.atan2(-f.x, -f.z),
+ editor: ec.position.toArray(),
+ editorQ: ec.quaternion.toArray(),
+ target: oc?.target?.toArray() ?? null,
+ locked: get(s.isLocked)
+ };
+ });
+
+/** park the editor camera at `pos` looking at `target` (the OrbitControls own the view, so through them) */
+const aim = (page, pos, target) =>
+ page.evaluate(
+ ({ pos, target }) => {
+ const s = window.__stores;
+ const get = (st) => { let v; st.subscribe((x) => (v = x))(); return v; };
+ const cam = get(s.editorCam);
+ const oc = get(s.orbitControls);
+ oc.target.set(...target);
+ cam.position.set(...pos);
+ cam.lookAt(...target);
+ oc.update();
+ cam.updateMatrixWorld(true);
+ },
+ { pos, target }
+ );
+
+const waitLocked = async (page, want) => {
+ for (let i = 0; i < 40; i++) {
+ if ((await page.evaluate(store('isLocked'))) === want) return true;
+ await page.waitForTimeout(100);
+ }
+ return false;
+};
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1440, height: 900 } }, storage: { controlsLayout: BAR } });
+ const P = A.page;
+
+ // ---- the world: a wide box far from the origin (top at y = 5), the scene's spawn elsewhere ----
+ await P.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box'));
+ await P.waitForTimeout(500);
+ const box = await P.evaluate(() => {
+ const s = window.__stores;
+ let g;
+ s.objectsGroup.subscribe((x) => (g = x))();
+ const b = g.children.at(-1);
+ b.scale.set(10, 5, 10);
+ b.position.set(40, 0, -35);
+ b.updateMatrixWorld(true);
+ const bb = new s.THREE.Box3().setFromObject(b);
+ b.position.y -= bb.min.y; // sit it on the ground: its top is then its height
+ b.updateMatrixWorld(true);
+ const top = new s.THREE.Box3().setFromObject(b).max.y;
+ s.objectActions.applySelectionSet([b.uuid]);
+ return { uuid: b.uuid, top };
+ });
+ h.check(box.top > 4, `premise: a box with its top at ${box.top.toFixed(2)} m, far from the origin`);
+ // flying allowed: the spawn's height is honoured (the grounded case is row 1b)
+ await P.evaluate((spawn) => window.__stores.scenePhysics.setScenePhysics({ play: { spawn, locomotion: { fly: true } } }), SPAWN);
+
+ // ---- 1. Ctrl+Shift+Enter from a far corner: on the box roof, facing the view ------------------
+ const EYE_AT = [40, 14, -32];
+ await aim(P, EYE_AT, [30, 0, -42]);
+ const before = await cams(P);
+ const resolved = await P.evaluate(() => window.__stores.playHere.resolvePlayHere());
+ h.check(resolved?.source === 'surface' && near(resolved.position[1], box.top), `the resolver lands on the roof (${JSON.stringify(resolved)})`);
+ await P.mouse.move(700, 450);
+ await P.keyboard.press('Control+Shift+Enter');
+ h.check(await waitLocked(P, true), 'Ctrl+Shift+Enter enters Play');
+ await P.waitForTimeout(400);
+ let c = await cams(P);
+ h.check(near(c.player[0], 40) && near(c.player[2], -32), `the player stands under the camera, not on the spawn (${c.player.map((v) => v.toFixed(2))})`);
+ h.check(near(c.player[1], box.top + 1.7), `the eye is 1.7 m above the roof (${c.player[1].toFixed(2)} vs ${(box.top + 1.7).toFixed(2)})`);
+ h.check(near(wrap(c.playerYaw - Math.PI / 4), 0, 0.03), `facing the view's heading (${c.playerYaw.toFixed(3)} vs ${(Math.PI / 4).toFixed(3)})`);
+ h.check((await P.evaluate(store('playHere.playingHere'))) === true, 'playingHere is on while the round runs');
+
+ // Stop: the view and the selection come back
+ h.check(await h.leavePlay(A), 'Escape leaves Play');
+ await P.waitForTimeout(300);
+ c = await cams(P);
+ h.check(c.editor.every((v, i) => near(v, before.editor[i], 0.01)), `the editor camera is back where it was (${c.editor.map((v) => v.toFixed(2))})`);
+ h.check(c.target && c.target.every((v, i) => near(v, before.target[i], 0.01)), `...looking at the same orbit target (${JSON.stringify(c.target)})`);
+ h.check(c.editorQ.every((v, i) => near(v, before.editorQ[i], 0.01)), '...with the same orientation');
+ const sel = await P.evaluate(store('selectedObjects'));
+ h.check(sel.map((o) => o?.uuid ?? o).includes(box.uuid), `the selection is back (${JSON.stringify(sel.map((o) => o?.uuid ?? o))})`);
+ const dbg = await P.evaluate(() => window.__stores.playHere.playHereDebug());
+ h.check(dbg.override === null && dbg.playing === false, `the override is gone with the round (${JSON.stringify({ o: dbg.override, p: dbg.playing })})`);
+
+ // ---- 1b. a GROUNDED scene (the default): X/Z and facing from the camera, the scene's eye height --
+ await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { locomotion: { fly: false } } }));
+ await P.keyboard.press('Control+Shift+Enter');
+ h.check(await waitLocked(P, true), 'grounded: Play here');
+ await P.waitForTimeout(400);
+ c = await cams(P);
+ h.check(near(c.player[0], 40) && near(c.player[2], -32) && near(c.player[1], 1.7), `grounded: under the camera at the scene's eye height (${c.player.map((v) => v.toFixed(2))})`);
+ h.check(near(wrap(c.playerYaw - Math.PI / 4), 0, 0.03), `grounded: facing the view (${c.playerYaw.toFixed(3)})`);
+ await h.leavePlay(A);
+ await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { locomotion: { fly: true } } }));
+
+ // ---- 2. the next ORDINARY Play lands on the scene's spawn (the override does not leak) --------
+ await P.keyboard.press('Control+Enter');
+ h.check(await waitLocked(P, true), 'Ctrl+Enter: an ordinary Play');
+ await P.waitForTimeout(400);
+ c = await cams(P);
+ h.check(near(c.player[0], 3) && near(c.player[2], -4) && near(c.player[1], 1.7), `an ordinary Play spawns on the scene's spawn (${c.player.map((v) => v.toFixed(2))})`);
+ await h.leavePlay(A);
+
+ // ---- 3. the Play button's right-click row; nothing under the camera → the scene's spawn ---------
+ await aim(P, [0, -6, 20], [0, -6, 0]); // under the ground plane: nothing below
+ await P.click('#play-button', { button: 'right' });
+ await P.waitForTimeout(300);
+ const rows = await P.evaluate(() => [...document.querySelectorAll('[role="menuitem"]')].map((el) => el.textContent.trim()));
+ h.check(rows.includes('Play here'), `Play's own menu carries "Play here" (${JSON.stringify(rows)})`);
+ await P.getByRole('menuitem', { name: 'Play here' }).click();
+ h.check(await waitLocked(P, true), 'the menu row enters Play');
+ await P.waitForTimeout(400);
+ c = await cams(P);
+ const last = (await P.evaluate(() => window.__stores.playHere.playHereDebug())).last;
+ h.check(last?.source === 'spawn', `nothing under the camera: the scene's spawn (${JSON.stringify(last)})`);
+ h.check(near(c.player[0], 3) && near(c.player[2], -4), `...the player is on it (${c.player.map((v) => v.toFixed(2))})`);
+ h.check(near(wrap(c.playerYaw - SPAWN.yaw), 0, 0.03), `...facing the spawn's own yaw (${c.playerYaw.toFixed(3)})`);
+ await h.leavePlay(A);
+
+ // ---- 4. the toolbar button; no ground, no spawn → the camera's own eye ------------------------
+ await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ ground: { enabled: false }, play: { spawn: null } }));
+ await aim(P, [70, 9, 70], [60, 9, 60]);
+ h.check(await P.locator('#play-here-button').isVisible(), 'the optional toolbar button is on the bar when placed');
+ await P.click('#play-here-button');
+ h.check(await waitLocked(P, true), 'the toolbar button enters Play');
+ await P.waitForTimeout(400);
+ c = await cams(P);
+ h.check(near(c.player[0], 70) && near(c.player[1], 9) && near(c.player[2], 70), `no surface, no spawn: the eye stays at the camera (${c.player.map((v) => v.toFixed(2))})`);
+ h.check(near(wrap(c.playerYaw - Math.PI / 4), 0, 0.03), `...facing the view (${c.playerYaw.toFixed(3)})`);
+ await h.leavePlay(A);
+ await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ ground: { enabled: true } }));
+ await A.page.context().close();
+
+ // ---- 5. a phone (N6 folded): Play's long-press row and More ▸ Play here ------------------------
+ const FOLDED = { width: 390, height: 896 };
+ const B = await h.setupPage(browser, 'phone', {
+ context: { viewport: FOLDED, hasTouch: true, isMobile: true, deviceScaleFactor: 2.9 },
+ storage: { toursSeen: '{"editor-touch":true,"editor":true}' }
+ });
+ const Q = B.page;
+ await Q.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box'));
+ await Q.waitForTimeout(500);
+ await Q.evaluate(() => {
+ const s = window.__stores;
+ let g;
+ s.objectsGroup.subscribe((x) => (g = x))();
+ const b = g.children.at(-1);
+ b.scale.set(6, 2, 6);
+ b.position.set(-20, 1, 15);
+ b.updateMatrixWorld(true);
+ });
+ const top = await Q.evaluate(() => {
+ const s = window.__stores;
+ let g;
+ s.objectsGroup.subscribe((x) => (g = x))();
+ return new s.THREE.Box3().setFromObject(g.children.at(-1)).max.y;
+ });
+ await aim(Q, [-20, 10, 15], [-20, 0, 5]);
+ const phoneBefore = await cams(Q);
+ // long press on Play = its contextmenu
+ await Q.locator('#ps-bar [data-bar-item="play"]').dispatchEvent('contextmenu', { clientX: 195, clientY: 860 });
+ await Q.waitForTimeout(300);
+ const phoneRows = await Q.evaluate(() => [...document.querySelectorAll('[role="menuitem"]')].map((el) => el.textContent.trim()));
+ h.check(phoneRows.includes('Play here'), `the phone Play menu carries "Play here" (${JSON.stringify(phoneRows)})`);
+ await Q.getByRole('menuitem', { name: 'Play here' }).click();
+ h.check(await waitLocked(Q, true), 'phone: the row enters Play');
+ await Q.waitForTimeout(400);
+ c = await cams(Q);
+ // a phone scene is grounded by default: X/Z from the camera, the eye at the scene's height
+ h.check(near(c.player[0], -20) && near(c.player[2], 15) && near(c.player[1], 1.7), `phone: under the camera, grounded (${c.player.map((v) => v.toFixed(2))}, box top ${top.toFixed(2)})`);
+ h.check(near(wrap(c.playerYaw), 0, 0.03), `phone: facing the view (${c.playerYaw.toFixed(3)})`);
+ // the touch ✕ is the phone's way out
+ const exitBtn = Q.locator('#touch-play-exit, [data-touch-exit], button[aria-label="Exit play"]').first();
+ if (await exitBtn.isVisible().catch(() => false)) await exitBtn.click();
+ else await Q.evaluate(() => window.__stores.playMode.exitPlay());
+ h.check(await waitLocked(Q, null), 'phone: Play ends');
+ await Q.waitForTimeout(300);
+ c = await cams(Q);
+ h.check(c.editor.every((v, i) => near(v, phoneBefore.editor[i], 0.01)), `phone: the editor view is back (${c.editor.map((v) => v.toFixed(2))})`);
+
+ // More ▸ Tools & View ▸ Play here
+ await Q.locator('#ps-bar [data-bar-item="more"]').click();
+ await Q.waitForTimeout(400);
+ const tile = Q.locator('#ps-tile-playhere');
+ h.check(await tile.isVisible().catch(() => false), 'phone: More lists Play here');
+ await tile.click();
+ h.check(await waitLocked(Q, true), 'phone: the More tile enters Play');
+ await Q.waitForTimeout(300);
+ c = await cams(Q);
+ h.check(near(c.player[0], -20) && near(c.player[2], 15), `phone (More): under the camera again (${c.player.map((v) => v.toFixed(2))})`);
+ await Q.evaluate(() => window.__stores.playMode.exitPlay());
+ await waitLocked(Q, null);
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/play-reentry.test.cjs b/tests/e2e/play-reentry.test.cjs
index 7995c2cec..df6893586 100644
--- a/tests/e2e/play-reentry.test.cjs
+++ b/tests/e2e/play-reentry.test.cjs
@@ -245,7 +245,8 @@ h.run(async () => {
return Promise.reject(new Error('refused'));
};
window.__stores.isLocked.set(true);
- await new Promise((r) => setTimeout(r, 400));
+ // until the retry has really run once (bounded) — a fixed 400 ms saw 0 attempts under load
+ for (const t0 = Date.now(); calls < 1 && Date.now() - t0 < 3000; ) await new Promise((r) => setTimeout(r, 50));
const during = calls;
window.__stores.isLocked.set(false);
await new Promise((r) => setTimeout(r, 900));
@@ -321,12 +322,40 @@ h.run(async () => {
);
h.check(onCanvas === 'CANVAS', `premise: that pixel is the canvas, not chrome (${onCanvas})`);
+ // test-health-1: record what the right-click reaches — red since 1.41 with no product change in
+ // this path found, so a red says WHICH guard ate it (no event / a slow pair past the 400 ms tap
+ // limit / a blocking store)
+ await X.evaluate(() => {
+ const w = /** @type {any} */ (window);
+ w.__rc = [];
+ const name = (/** @type {any} */ el) => (el ? el.tagName + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\s+/).slice(0, 3).join('.') : '') : null);
+ for (const t of ['pointerdown', 'contextmenu'])
+ window.addEventListener(t, (e) => w.__rc.push([t, name(e.target), name(/** @type {any} */ (e.target)?.parentElement), /** @type {any} */ (e).button, Date.now()]), true);
+ });
+ await X.evaluate((p) => (/** @type {any} */ (window).__rcAt = p), at);
await X.mouse.click(at.x, at.y, { button: 'right' });
+ let menuOpened = false;
await h.eventually(
() => X.evaluate(() => document.querySelectorAll('[role="menuitem"]').length),
- (n) => n > 0,
+ (n) => (menuOpened = n > 0),
'right-click opens the viewport context menu after a denied session'
);
+ if (!menuOpened)
+ console.log(
+ ' right-click reached: ' +
+ JSON.stringify(
+ await X.evaluate(() => {
+ const s = window.__stores;
+ const g = (/** @type {string} */ k) => {
+ let v;
+ s[k]?.subscribe?.((/** @type {any} */ x) => (v = x))?.();
+ return v;
+ };
+ const el = document.elementFromPoint(/** @type {any} */ (window).__rcAt?.x ?? 0, /** @type {any} */ (window).__rcAt?.y ?? 0);
+ return { rc: /** @type {any} */ (window).__rc, underNow: el ? el.tagName + '#' + el.id + '.' + String(el.className).slice(0, 60) : null, ...Object.fromEntries(['isLocked', 'isVRMode', 'specatorMode', 'drawMode', 'editingObject', 'measureMode'].map((k) => [k, g(k)])) };
+ })
+ )
+ );
await X.keyboard.press('Escape');
await X.waitForTimeout(200);
diff --git a/tests/e2e/post-play-mode.test.cjs b/tests/e2e/post-play-mode.test.cjs
index fcd1c2f7b..89721cd26 100644
--- a/tests/e2e/post-play-mode.test.cjs
+++ b/tests/e2e/post-play-mode.test.cjs
@@ -100,7 +100,12 @@ h.run(async () => {
(delta.changed ?? -1) +
' changed pixels)'
);
- const off = await h.framePixelsOffColor(page, after, FILL_RGB);
+ // colour is measured on a CHROME-FREE region (helpers.grabFrame's rule): play mode composites
+ // the glass bar (PlayBanner, 930ecc44) and the reticle over the canvas, and the full frame read a
+ // constant 20780 px "off the fill" — left of centre, clear of the bar, the HUD and the reticle
+ const view = await page.viewportSize();
+ const fillClip = { x: Math.round(view.width * 0.1), y: Math.round(view.height * 0.35), width: Math.round(view.width * 0.25), height: Math.round(view.height * 0.3) };
+ const off = await h.framePixelsOffColor(page, await h.grabFrame(A, fillClip), FILL_RGB);
h.check(
off.fraction < 0.02,
'2.2 ...and it is the effect that painted it (' +
@@ -177,7 +182,7 @@ h.run(async () => {
// and putting the look back brings the composer back
await page.evaluate(() => window.__stores.scenePost.addPostEffect('test-play-fill'));
await page.waitForTimeout(1200);
- const restored = await h.grabFrame(A);
+ const restored = await h.grabFrame(A, fillClip);
const restoredOff = await h.framePixelsOffColor(page, restored, FILL_RGB);
h.check(
restoredOff.fraction < 0.02,
diff --git a/tests/e2e/quarantine.json b/tests/e2e/quarantine.json
new file mode 100644
index 000000000..9749685cf
--- /dev/null
+++ b/tests/e2e/quarantine.json
@@ -0,0 +1,49 @@
+{
+ "comment": "test-health-1 — e2e checks that run but do not gate (scripts/ci/quarantine.cjs; the battery reports them in their own tier). perf/budgets.json \"allow\" one domain over: suite + optionally ONE check (a substring of its FAIL label; absent = the whole suite), why, who owns the fix, since, review. A suite red is non-blocking only when EVERY failed check matches an entry (a crash or timeout never is, unless the whole suite is listed). Past-review and now-green entries print as warnings. Remove an entry in the PR that fixes it.",
+ "quarantine": [
+ {"suite": "sessions", "check": "accepted load replaced the scene on both peers", "reason": "UNRESOLVED, possibly a product bug: after Accept the proposer's load never completes — v5b: a live, uncancelled 'SceneOne' job, both links open, nothing cleared (stuck before applySessionNow's replicated clear: the only awaits there are the 'Backup before' saveSession, the library restore and the physics import — idb.js can leave a promise pending forever); v3b: A cleared and added nothing, B applied nothing. Hidden behind the e3edaee5 strict-mode crash since 2026-10-21, so no passing run exists to bisect; needs a run tracing sessions.js (test-health-1 2026-10-24)", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "lock-ux", "check": "B renders a lock highlight box", "reason": "load-sensitive: the Approve toast click is intercepted by the canvas and the toast detaches; green in a quiet slot (75 s vs 131-136 s red), red on v1.42.0 in the same place (57-union, 65-union)", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "ui-fixes-15lmno", "check": "incoming toasts never shift it", "reason": "the toast burst moves the spectator 8 px under load (a fixed sleep); a different row red each run, pristine v1.41.0 3/3 red (50-int r4)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "ui-fixes-15lmno", "check": "the Roughness readout shows the material", "reason": "read before the Inspector re-renders under load (fixed sleep); red on pristine v1.41.0 (50-int r4)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "ui-fixes-15lmno", "check": "it hides after the grace period", "reason": "fixed-sleep grace window vs load (50-int r3)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "checkpoints", "check": "after Accept the peer has the checkpoint", "reason": "two-peer timing; red on the union AND pristine v1.41.0, green in r4 (50-int, 50-window)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "checkpoints", "check": "and so does the host", "reason": "flips run to run with the row above (green r1, red r3; 50-int)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "checkpoints", "check": "an autosave write cuts an automatic row", "reason": "the automatic row lands ~15.7 s, the check waits 12 s (50-window)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "scene-load", "check": "1.4 no long task over", "reason": "a 204-389 ms long task vs the 200 ms line under load; known in 39/40/41/42 incl. A/B bases (L3)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "scene-load", "check": "6.3 no long task over", "reason": "as 1.4, while restoring; the one L3 row red on preview-1-42 (50-int)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "scene-load", "check": "4.4 a toast says the load was stopped", "reason": "the toast is read after it expired under load (49-int x1, 50-int)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "net-handshake", "check": "C received the scene", "reason": "third peer handshake timing; red on pristine v1.41.0 and on 904599c1 (50-int r3, 55-invite)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "flow-physics-actions", "check": "2.1 mode 'continuous' KEEPS pushing", "reason": "body at 0 m/s under a held key on a slow frame; flaky in every app x driver cell (deps-1 ab3)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "flow-physics-actions", "check": "4.3 read 'speed' reports real motion", "reason": "as 2.1 (deps-1 ab3)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "flow-physics-actions", "check": "8.5 and the very NEXT press throws the box", "reason": "stale-press guard vs frame timing (pcA, deps-1)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "select-41", "check": "G4 (near) a finger on the crate takes it", "reason": "G4 flick timing: 12/12 green on --local, red under load (deps-1 ab3)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "select-41", "check": "G14 (unfolded) a tap selects", "reason": "the unfolded N6 page under load: a tap is a click only if its pointerdown->pointerup handlers run within 400 ms of page time, and a long software-GL frame between them voids it — red in 3 of 4 runs, a different first check each time (base, v3b, v5a; green v4b). The other race in this block (the finger landing on the seated gizmo: axis XYZ, dragging true at pointerup, e2e/v4b) is fixed in Scene.svelte", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "select-41", "check": "G14 (unfolded) a tap on the selected box deselects it", "reason": "a finger tap on the box lands on the seated gizmo (captured in the capture phase at pointerup: axis XYZ, dragging true) and the box stays selected — red in after-battery and v3b, green in v4b. A fix keyed on that state did NOT change it (reverted): Scene's window pointerup runs after TransformControls' own, so it never sees that state. Product race not understood; owner", "owner": "test-health (standing lane)", "since": "2026-10-25", "review": "2026-11-07"},
+ {"suite": "select-41", "check": "G13 (unfolded) Interact deselects", "reason": "the unfolded N6 page under load: a tap is a click only if its pointerdown->pointerup handlers run within 400 ms of page time, and a long software-GL frame between them voids it — red in 3 of 4 runs, a different first check each time (base, v3b, v5a; green v4b). The other race in this block (the finger landing on the seated gizmo: axis XYZ, dragging true at pointerup, e2e/v4b) is fixed in Scene.svelte", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "select-41", "check": "G13 (unfolded) and Edit restores", "reason": "the unfolded N6 page under load: a tap is a click only if its pointerdown->pointerup handlers run within 400 ms of page time, and a long software-GL frame between them voids it — red in 3 of 4 runs, a different first check each time (base, v3b, v5a; green v4b). The other race in this block (the finger landing on the seated gizmo: axis XYZ, dragging true at pointerup, e2e/v4b) is fixed in Scene.svelte", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "avatar-feet", "check": "1.3 standing: B's lowest sole is on the floor", "reason": "red 2/4 d3d12, 3/3 llvmpipe, green on rerun (mux-pc-host)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "avatar-feet", "check": "1.7 the body origin stays on the floor while walking", "reason": "software GL: a 12 cm rise at the start of the walk that settles over ~400 ms (origin y per stride 12.2 3.0 0.7 0.7 0.2 0.2 0.0 0.0 cm, e2e/v4b) — not sinking, and the feet are on the wire every stride since the localFeet staleness fix; green on the GPU PC (pcbat). Source of the 12 cm not found", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "aquarium-reef", "check": "2.1 every fallback group built its PACK model", "reason": "1 red in 3 on each GPU path: pack fetch vs the wait (mux-pc-host)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "aquarium-reef", "check": "2.2 every group DRAWS a pack level", "reason": "as 2.1 (mux-pc-host)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "pack-behavior", "check": "7.2 OPEN: the capsule walks through the doorway", "reason": "a fixed 300 ms wait vs charController's lazy physics import; base 1/3 red (59-replace)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "code-workspace", "check": "Save as file binds the node", "reason": "a 2000 ms wait; flaked on both base and lane (50-yjs, 50-window)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "animation-node", "check": "so the door closes rather than restarting", "reason": "~1.7 fps under load: a 900 ms window holds 1-2 frames; 35/0 on a GPU slot (57-clips)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "dock-strip-drop", "check": "6.3 opening an Explorer image brings the docked editor forward", "reason": "red 1/2 on both 1.41 and 1.40 production, green on base in deps-1 ab1 (53-int)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "toolbar-slots-42", "check": "I9: each corner shows ONE", "reason": "known deployed flake (touch reorder, unfolded dark); same 2 reds on production 1.39 (49-int, 53-int)", "owner": "test-health (standing lane)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "context-menu-redesign", "reason": "carried red since 1.41, red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23) — moved from battery.json \"optional\"", "owner": "test-health-1 (phase 2: fix the test, fix the code, or delete with a reason)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "edit-move-games", "reason": "carried red since 1.41, red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23) — moved from battery.json \"optional\"", "owner": "test-health-1 (phase 2: fix the test, fix the code, or delete with a reason)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "fb-water", "check": "a foam body floats high", "reason": "software GL (~4 fps): rapier keeps real time only down to 7.5 fps (8 substeps of 1/60 s per frame), so the hull lags waves that follow the session clock — green needs a GPU slot; the test cannot fix the frame rate (test-health-1, e2e/v2)", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "fb-water", "check": "...is never swamped", "reason": "software GL (~4 fps): rapier keeps real time only down to 7.5 fps (8 substeps of 1/60 s per frame), so the hull lags waves that follow the session clock — green needs a GPU slot; the test cannot fix the frame rate (test-health-1, e2e/v2)", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "fb-water", "check": "...upright", "reason": "software GL (~4 fps): rapier keeps real time only down to 7.5 fps (8 substeps of 1/60 s per frame), so the hull lags waves that follow the session clock — green needs a GPU slot; the test cannot fix the frame rate (test-health-1, e2e/v2)", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "fb-water", "check": "underwater: aligned", "reason": "flips run to run (green on the base, 68.9 vs 37.4 px in e2e/v2): outline vs object read 1 s after a water-quality switch, while the shaders recompile at ~4 fps", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "kit-instancing", "reason": "carried red since 1.41, red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23) — moved from battery.json \"optional\"", "owner": "test-health-1 (phase 2: fix the test, fix the code, or delete with a reason)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "net-stress", "check": "premise: every peer ran well above the 20/s gate", "reason": "section 5 is a RATE claim against display frames and needs a GPU (its own comment): four pages on software GL orbit at 1 fps, so the 20/s gate cannot be exercised (e2e/v4a; sections 1-4 and the freeze check green)", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "net-stress", "check": "premise: the camera stream flows between every pair while orbiting", "reason": "section 5 is a RATE claim against display frames and needs a GPU (its own comment): four pages on software GL orbit at 1 fps, so the 20/s gate cannot be exercised (e2e/v4a; sections 1-4 and the freeze check green)", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "net-stress", "check": "presence is gated, not per frame", "reason": "section 5 is a RATE claim against display frames and needs a GPU (its own comment): four pages on software GL orbit at 1 fps, so the 20/s gate cannot be exercised (e2e/v4a; sections 1-4 and the freeze check green)", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "phone-sheets", "reason": "carried red since 1.41, red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23) — moved from battery.json \"optional\"", "owner": "test-health-1 (phase 2: fix the test, fix the code, or delete with a reason)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "timeline-export", "reason": "NOT a flake — ENV: the exporter reads build/export-manifest.json, which a dev server has none of, so on the dev-server battery it can only fail (CI skips it in battery.json for the same reason). Green 9/9 against a production build served by scripts/serve-build.cjs (test-health-1, e2e/tlx, 2026-10-24). Run it there: npm run build && node scripts/serve-build.cjs --port , APP_URL at that port", "owner": "test-health (standing lane)", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "touch-actions", "reason": "carried red since 1.41, red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23) — moved from battery.json \"optional\"", "owner": "test-health-1 (phase 2: fix the test, fix the code, or delete with a reason)", "since": "2026-10-23", "review": "2026-11-07"},
+ {"suite": "water-migration", "check": "pool-party (saved on 1.22) looks the same", "reason": "looks the same by eye (golden vs today, e2e/migration/*-now.png, test-health-1 2026-10-24); the measured change is a global tone shift (mean sum-of-channels delta 3.8-9.3, identical run to run with autoQuality pinned) plus the pool rim band — render changes since the goldens (bd143435). OWNER DECISION: re-baseline the goldens on today's build (drops the 'rendered by 1.22' provenance) or a perceptual metric; NOT a threshold raise", "owner": "test-health (standing lane) + owner decision", "since": "2026-10-24", "review": "2026-11-07"},
+ {"suite": "water-migration", "check": "island-ocean (saved on 1.22) looks the same", "reason": "looks the same by eye (golden vs today, e2e/migration/*-now.png, test-health-1 2026-10-24); the measured change is a global tone shift (mean sum-of-channels delta 3.8-9.3, identical run to run with autoQuality pinned) plus the pool rim band — render changes since the goldens (bd143435). OWNER DECISION: re-baseline the goldens on today's build (drops the 'rendered by 1.22' provenance) or a perceptual metric; NOT a threshold raise", "owner": "test-health (standing lane) + owner decision", "since": "2026-10-24", "review": "2026-11-07"}
+ ]
+}
diff --git a/tests/e2e/reload-memory.test.cjs b/tests/e2e/reload-memory.test.cjs
new file mode 100644
index 000000000..6deeb6836
--- /dev/null
+++ b/tests/e2e/reload-memory.test.cjs
@@ -0,0 +1,109 @@
+// test-health-1 (plans review #24, roadmap-42 Follow-ups): RELOADING ONE TAB MUST NOT GROW IT.
+//
+// The report: editor-ui-33 died on its 5th reload in one tab (WebGL context lost / Target crashed)
+// and the heap grew ~29 MB per reload. A reload replaces the document, so anything that grows
+// ACROSS reloads is something the old document left behind: the old document itself (a reference
+// from something that outlives it), or GPU memory the old page's WebGL contexts still held.
+//
+// Per reload, after a forced GC: the JS heap, the number of live Documents (1 = only the current
+// one; more = an old document is retained), and the resident memory of this browser's renderer and
+// GPU processes (ps, so it sees what Chromium's own heap metrics do not: WebGL buffers live in the
+// GPU process). Evidence: EVIDENCE/reload-memory.json.
+const fs = require('fs');
+const path = require('path');
+const h = require('./helpers.cjs');
+
+const RELOADS = Number(process.env.RELOADS || 6);
+const SETTLE_MS = Number(process.env.RELOAD_SETTLE_MS || 4000);
+const EVIDENCE = process.env.EVIDENCE || path.join(process.env.HOME || '.', '.code/lanes-30/after-60/60-test-health-1');
+// thresholds: the growth that killed a tab was ~29 MB/reload of JS heap; a steady state after the
+// first reload (caches warm) should be flat within noise
+const MAX_HEAP_MB_PER_RELOAD = Number(process.env.MAX_HEAP_MB_PER_RELOAD || 5);
+const MAX_GPU_MB_PER_RELOAD = Number(process.env.MAX_GPU_MB_PER_RELOAD || 25);
+
+/** RSS (MB) of the renderer + gpu processes of this browser, by type, from Chromium's own process
+ * list (SystemInfo.getProcessInfo — Playwright's Browser has no process(), only a BrowserServer
+ * does, so the first version never measured these). /proc is local only: on a remote slot the
+ * browser runs elsewhere and this answers null. @param {any} bcdp */
+async function processRss(bcdp) {
+ try {
+ const { processInfo } = await bcdp.send('SystemInfo.getProcessInfo');
+ const rss = (/** @type {number} */ pid) => {
+ const m = /VmRSS:\s+(\d+) kB/.exec(fs.readFileSync(`/proc/${pid}/status`, 'utf8'));
+ return m ? +m[1] / 1024 : 0;
+ };
+ const sum = (/** @type {string} */ type) => Math.round(processInfo.filter((/** @type {any} */ p) => p.type === type).reduce((t, /** @type {any} */ p) => t + rss(p.id), 0));
+ return { renderer: sum('renderer'), gpu: sum('GPU') };
+ } catch {
+ return { renderer: null, gpu: null };
+ }
+}
+
+h.run(async () => {
+ const browser = await h.launch();
+ const bcdp = await browser.newBrowserCDPSession();
+ const A = await h.setupPage(browser, 'A');
+ // Runtime.getHeapUsage + Memory.getDOMCounters are read FRESH per call; the first version read
+ // Performance.getMetrics, which on a reloaded target kept answering the first document's
+ // numbers (seven byte-identical rows), so it could not see growth at all
+ const cdp = await A.page.context().newCDPSession(A.page);
+ await cdp.send('HeapProfiler.enable');
+ const sample = async (/** @type {number} */ i) => {
+ await A.page.waitForTimeout(SETTLE_MS);
+ await cdp.send('HeapProfiler.collectGarbage');
+ await cdp.send('HeapProfiler.collectGarbage');
+ const heap = await cdp.send('Runtime.getHeapUsage');
+ const dom = await cdp.send('Memory.getDOMCounters');
+ const gl = await A.page.evaluate(() => {
+ /** @type {any} */ let r;
+ window.__stores.globalRenderer.subscribe((/** @type {any} */ v) => (r = v))();
+ return r?.info?.memory ?? null;
+ }).catch(() => null);
+ // the premise: every sample is a FRESH document (an unchanged timeOrigin = no reload happened)
+ const origin = await A.page.evaluate(() => performance.timeOrigin);
+ return {
+ reload: i,
+ origin,
+ heapMB: Math.round((heap.usedSize / 1048576) * 10) / 10,
+ documents: dom.documents,
+ nodes: dom.nodes,
+ listeners: dom.jsEventListeners,
+ gl,
+ ...(await processRss(bcdp))
+ };
+ };
+ /** @type {any[]} */
+ const rows = [await sample(0)];
+ console.log(JSON.stringify(rows[0]));
+ let crashed = '';
+ for (let i = 1; i <= RELOADS; i++) {
+ try {
+ await h.freshReload(A);
+ await h.answerRestore(A).catch(() => false);
+ } catch (error) {
+ crashed = `reload ${i}: ${String(/** @type {any} */ (error)?.message ?? error).split('\n')[0]}`;
+ break;
+ }
+ rows.push(await sample(i));
+ console.log(JSON.stringify(rows[rows.length - 1]));
+ }
+ fs.mkdirSync(EVIDENCE, { recursive: true });
+ fs.writeFileSync(path.join(EVIDENCE, 'reload-memory.json'), JSON.stringify({ at: new Date().toISOString(), url: h.URL, rows, crashed }, null, 1));
+
+ h.check(!crashed, `the tab survives ${RELOADS} reloads (${crashed || 'all of them'})`);
+ h.check(new Set(rows.map((r) => r.origin)).size === rows.length, `premise: each sample is a fresh document (${new Set(rows.map((r) => r.origin)).size} distinct of ${rows.length})`);
+ // growth from the FIRST reload on: reload 0 -> 1 warms caches every app has
+ const from = rows[1] ?? rows[0];
+ const last = rows[rows.length - 1];
+ const n = Math.max(1, last.reload - from.reload);
+ const heapPer = (last.heapMB - from.heapMB) / n;
+ h.check(heapPer <= MAX_HEAP_MB_PER_RELOAD, `JS heap after GC grows <= ${MAX_HEAP_MB_PER_RELOAD} MB per reload (${heapPer.toFixed(1)} MB/reload: ${rows.map((r) => r.heapMB).join(' → ')})`);
+ // an absolute count says little (the page legitimately owns more than one: iframes, an inert
+ // template document); a RETAINED old document shows up as the count climbing reload by reload
+ h.check(last.documents <= from.documents, `no old document is retained (live Documents per reload: ${rows.map((r) => r.documents).join(', ')})`);
+ if (last.gpu != null && from.gpu != null) {
+ const gpuPer = (last.gpu - from.gpu) / n;
+ h.check(gpuPer <= MAX_GPU_MB_PER_RELOAD, `GPU process RSS grows <= ${MAX_GPU_MB_PER_RELOAD} MB per reload (${gpuPer.toFixed(1)} MB/reload: ${rows.map((r) => r.gpu).join(' → ')})`);
+ }
+ await h.finish(browser);
+});
diff --git a/tests/e2e/scatter-colliders.test.cjs b/tests/e2e/scatter-colliders.test.cjs
index 9acbc6754..10d24b8d7 100644
--- a/tests/e2e/scatter-colliders.test.cjs
+++ b/tests/e2e/scatter-colliders.test.cjs
@@ -46,14 +46,32 @@ async function parkBall(page, ball, at) {
/** run a simulation on `peer` until the ball is AT REST (sim time is slow on a loaded box: a
* fixed sleep reads a ball still falling), then stop and return its height there and on `other` */
+/** Resolve after `n` rendered frames on that page (bounded). @param {any} page @param {number} n */
+const framesPass = (page, n) =>
+ page.evaluate(
+ (n) =>
+ new Promise((resolve) => {
+ let left = n;
+ const tick = () => (--left <= 0 ? resolve(true) : requestAnimationFrame(tick));
+ requestAnimationFrame(tick);
+ setTimeout(() => resolve(false), 120000);
+ }),
+ n
+ );
+
async function drop(peer, other, ball) {
await peer.page.evaluate(() => window.__stores.physics.toggleSimulation());
await peer.page.waitForTimeout(800);
let last = null;
let still = 0;
- const deadline = Date.now() + 30000;
+ // scatter-sync: SIMULATED time, not wall time. The step accumulator catches up at most 8 substeps a
+ // frame, so on a slow runner (multi-second frames) 30 s of wall clock was ~1 s of simulation and the
+ // ball was still falling. Each sample waits 500 ms AND 8 rendered frames (>= 8 substeps), so "still"
+ // always spans real simulation, and the cap is generous wall time.
+ const deadline = Date.now() + 240000;
while (Date.now() < deadline && still < 3) {
await peer.page.waitForTimeout(500);
+ await framesPass(peer.page, 8);
const y = (await posOf(peer.page, ball))?.[1] ?? null;
still = last !== null && y !== null && Math.abs(y - last) < 0.004 ? still + 1 : 0;
last = y;
@@ -63,6 +81,7 @@ async function drop(peer, other, ball) {
await peer.page.waitForTimeout(300);
const here = (await posOf(peer.page, ball))?.[1] ?? null;
// the watching peer has the stream (~10 Hz + smoothing) and the settling move sent at stop
+ await h.caughtUp(peer, other);
let there = null;
const until = Date.now() + 8000;
while (Date.now() < until) {
@@ -80,11 +99,11 @@ async function pickCollider(peer, terrain, setId, name) {
if (!(await sel.isVisible().catch(() => false))) {
// the Scatter section may start collapsed
const head = peer.page.locator('button', { hasText: /^Scatter$/ }).first();
- if (await head.isVisible().catch(() => false)) await head.click();
+ if (await head.isVisible().catch(() => false)) await head.click({ timeout: h.PATIENT_CLICK_MS });
}
- await sel.scrollIntoViewIfNeeded();
- await sel.click();
- await peer.page.locator('.ts-list [role="option"]', { hasText: new RegExp(`^\\s*${name}\\s*$`) }).click();
+ await sel.scrollIntoViewIfNeeded({ timeout: h.PATIENT_CLICK_MS });
+ await sel.click({ timeout: h.PATIENT_CLICK_MS });
+ await peer.page.locator('.ts-list [role="option"]', { hasText: new RegExp(`^\\s*${name}\\s*$`) }).click({ timeout: h.PATIENT_CLICK_MS });
await peer.page.waitForTimeout(300);
return sel.innerText();
}
@@ -113,7 +132,7 @@ h.run(async () => {
s.objectsGroup.update((v) => v);
return t.uuid;
});
- await h.eventually(() => blockOf(B.page, terrain), (v) => v !== 'missing', 'B has the terrain', 20000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, terrain), (v) => v !== 'missing', 'B has the terrain', 20000);
const scattered = await A.page.evaluate((u) => {
const s = window.__stores;
@@ -160,7 +179,7 @@ h.run(async () => {
o.scale.set(0.4, 0.4, 0.4);
return o.uuid;
});
- await h.eventually(() => posOf(B.page, ball), (p) => !!p, 'B has the ball', 20000);
+ await h.eventuallyAfter(A, B, () => posOf(B.page, ball), (p) => !!p, 'B has the ball', 20000);
// the Terrain is procedural (hills): measure the ground under the rock and drop from above it
const ground = await A.page.evaluate(
([u, x, z]) => {
@@ -209,7 +228,7 @@ h.run(async () => {
if (SHOTS) await A.page.screenshot({ path: `${SHOTS}/01-scatter-collider-select.png` });
const aBlock = await blockOf(A.page, terrain);
h.check(aBlock?.sets?.[0]?.collider === 'box', 'the set field is box on A');
- await h.eventually(() => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.collider === 'box', 'the set field REPLICATED to B', 15000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.collider === 'box', 'the set field REPLICATED to B', 15000);
// ---- A steps the world: the ball stops on the rock, on A and on B ----------------------
await parkBall(A.page, ball, over);
@@ -224,9 +243,9 @@ h.run(async () => {
await A.page.evaluate(() => window.__stores.objectActions.deselectObject());
await A.page.waitForTimeout(500);
await pickCollider(B, terrain, setId, 'Sphere');
- await h.eventually(() => blockOf(A.page, terrain), (v) => v?.sets?.[0]?.collider === 'sphere', 'B\'s pick replicated back to A', 15000);
+ await h.eventuallyAfter(B, A, () => blockOf(A.page, terrain), (v) => v?.sets?.[0]?.collider === 'sphere', 'B\'s pick replicated back to A', 15000);
await B.page.evaluate(() => window.__stores.history.undo());
- await h.eventually(() => blockOf(A.page, terrain), (v) => v?.sets?.[0]?.collider === 'box', 'ONE undo on B puts Box back, on A too', 10000);
+ await h.eventuallyAfter(B, A, () => blockOf(A.page, terrain), (v) => v?.sets?.[0]?.collider === 'box', 'ONE undo on B puts Box back, on A too', 10000);
await B.page.evaluate(() => window.__stores.history.redo());
await h.eventually(() => blockOf(A.page, terrain), (v) => v?.sets?.[0]?.collider === 'sphere', 'redo brings Sphere back on A', 10000);
await B.page.evaluate(() => window.__stores.objectActions.deselectObject());
diff --git a/tests/e2e/scatter-convert.test.cjs b/tests/e2e/scatter-convert.test.cjs
index 36f79b017..426769659 100644
--- a/tests/e2e/scatter-convert.test.cjs
+++ b/tests/e2e/scatter-convert.test.cjs
@@ -79,7 +79,7 @@ h.run(async () => {
const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)());
return group.children[group.children.length - 1].uuid;
});
- await h.eventually(() => scatterOf(B.page, uuid), (v) => v !== 'missing', 'B has the terrain', 20000);
+ await h.eventuallyAfter(A, B, () => scatterOf(B.page, uuid), (v) => v !== 'missing', 'B has the terrain', 20000);
await look(A.page, [0.5, 22, 3], [0, 0, 0]);
const entered = await A.page.evaluate((u) => {
const s = window.__stores;
@@ -128,10 +128,10 @@ h.run(async () => {
h.check((await objectCount(A.page)) === objsA + 5, 'A has five more objects (the template went)');
h.check((await listsOf(A.page, uuid))['p:Cone'].length === lists['p:Cone'].length - 5, 'and five fewer instances');
h.check((await undoDepth(A.page)) === d0 + 1, 'the whole conversion is ONE undo entry');
- await h.eventually(() => objectCount(B.page), (n) => n === objsA + 5, 'B has the five objects', 15000);
+ await h.eventuallyAfter(A, B, () => objectCount(B.page), (n) => n === objsA + 5, 'B has the five objects', 15000);
await A.page.evaluate(() => window.__stores.history.undo());
h.check((await objectCount(A.page)) === objsA && (await listsOf(A.page, uuid))['p:Cone'].length === lists['p:Cone'].length, 'one undo: objects gone, instances back');
- await h.eventually(() => objectCount(B.page), (n) => n === objsA, 'on B too', 15000);
+ await h.eventuallyAfter(A, B, () => objectCount(B.page), (n) => n === objsA, 'on B too', 15000);
// the confirm above 500
const asked = await A.page.evaluate(async (u) => {
@@ -186,8 +186,8 @@ h.run(async () => {
h.check(Math.abs(added[0].x - 3) < 0.02 && Math.abs(added[0].z + 8) < 0.02 && Math.abs(added[0].yaw - 0.3) < 0.01 && Math.abs(added[0].scale - 1) < 0.01, 'at their ground position, turn and size');
h.check((await objectCount(A.page)) === objsB4 - 4, 'the objects are gone');
h.check((await undoDepth(A.page)) === d0 + 1, 'ONE undo entry');
- await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B holds the new instances', 10000);
- await h.eventually(() => objectCount(B.page), (k) => k === objsB4 - 4, 'and lost the objects', 10000);
+ await h.eventuallyAfter(A, B, async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B holds the new instances', 10000);
+ await h.eventuallyAfter(A, B, () => objectCount(B.page), (k) => k === objsB4 - 4, 'and lost the objects', 10000);
const mixed = await A.page.evaluate(async ([a, b]) => window.__stores.scatterConvert.scatterableSelection([a, b], []), [uuid, uuid]);
h.check(mixed.ok === false, 'one object (or none) is refused with a reason');
diff --git a/tests/e2e/scatter-edit.test.cjs b/tests/e2e/scatter-edit.test.cjs
index 6280aa800..556561e49 100644
--- a/tests/e2e/scatter-edit.test.cjs
+++ b/tests/e2e/scatter-edit.test.cjs
@@ -102,7 +102,7 @@ h.run(async () => {
const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)());
return group.children[group.children.length - 1].uuid;
});
- await h.eventually(() => scatterOf(B.page, uuid), (v) => v !== 'missing', 'B has the terrain', 20000);
+ await h.eventuallyAfter(A, B, () => scatterOf(B.page, uuid), (v) => v !== 'missing', 'B has the terrain', 20000);
await look(A.page, [0.5, 22, 3], [0, 0, 0]);
await look(B.page, [-6, 16, -8], [0, 0, 0]);
@@ -124,7 +124,7 @@ h.run(async () => {
await stroke(A.page, uuid, 0, 0, 6); // boxes fill the gaps between the cones in the same circle
let lists = await listsOf(A.page, uuid);
h.check(lists['p:Cone']?.length > 20 && lists['p:Box']?.length > 0, `two models scattered (${lists['p:Cone']?.length} cones, ${lists['p:Box']?.length} boxes)`);
- await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B holds the same scatter', 15000);
+ await h.eventuallyAfter(A, B, async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B holds the same scatter', 15000);
// ---- E4 brushes ---------------------------------------------------------------------------
const near = (l, x, z, r) => l.filter((p) => Math.hypot(p.x - x, p.z - z) < r);
@@ -141,7 +141,7 @@ h.run(async () => {
const yawAfter = near(lists['p:Cone'], 0, 0, 2).map((p) => p.yaw);
h.check(yawAfter.some((y, i) => Math.abs(y - yawBefore[i]) > 0.05), 'the Rotate brush turns the instances under it');
h.check((await undoDepth(A.page)) === d0 + 1, 'one rotate stroke = ONE undo entry');
- await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B sees the rotate', 10000);
+ await h.eventuallyAfter(A, B, async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B sees the rotate', 10000);
await A.page.evaluate(() => window.__stores.scatterEdit.setScatterTool('scale'));
const scaleMean = (l) => l.reduce((a, p) => a + p.scale, 0) / Math.max(1, l.length);
@@ -199,7 +199,7 @@ h.run(async () => {
await A.page.evaluate(() => window.__stores.history.undo());
lists = await listsOf(A.page, uuid);
h.check(lists['p:Box'].length === boxes0, 'one undo brings the boxes back');
- await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B agrees after the undo', 10000);
+ await h.eventuallyAfter(A, B, async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B agrees after the undo', 10000);
// ---- per-terrain splat detail ---------------------------------------------------------------
const res = await A.page.evaluate((u) => {
@@ -209,7 +209,7 @@ h.run(async () => {
return { r0, ok, r1: p.splatResolutionOf(u) };
}, uuid);
h.check(res.r0 === 128 && res.ok && res.r1 === 256, `Detail sets this terrain's splat to 256 (${res.r0} -> ${res.r1})`);
- await h.eventually(() => B.page.evaluate((u) => window.__stores.terrainPaint.splatResolutionOf(u), uuid), (r) => r === 256, 'B paints at 256 too', 10000);
+ await h.eventuallyAfter(A, B, () => B.page.evaluate((u) => window.__stores.terrainPaint.splatResolutionOf(u), uuid), (r) => r === 256, 'B paints at 256 too', 10000);
await A.page.evaluate(() => window.__stores.history.undo());
h.check((await A.page.evaluate((u) => window.__stores.terrainPaint.splatResolutionOf(u), uuid)) === 128, 'one undo puts it back');
diff --git a/tests/e2e/scatter-select.test.cjs b/tests/e2e/scatter-select.test.cjs
index 9f4f26441..a5362fdd1 100644
--- a/tests/e2e/scatter-select.test.cjs
+++ b/tests/e2e/scatter-select.test.cjs
@@ -103,7 +103,7 @@ h.run(async () => {
const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)());
return group.children[group.children.length - 1].uuid;
});
- await h.eventually(() => scatterOf(B.page, uuid), (v) => v !== 'missing', 'B has the terrain', 20000);
+ await h.eventuallyAfter(A, B, () => scatterOf(B.page, uuid), (v) => v !== 'missing', 'B has the terrain', 20000);
await look(A.page, [0.5, 22, 3], [0, 0, 0]);
await look(B.page, [-6, 16, -8], [0, 0, 0]);
@@ -125,7 +125,7 @@ h.run(async () => {
await stroke(A.page, uuid, 0, 0, 6); // boxes fill the gaps between the cones in the same circle
let lists = await listsOf(A.page, uuid);
h.check(lists['p:Cone']?.length > 20 && lists['p:Box']?.length > 0, `two models scattered (${lists['p:Cone']?.length} cones, ${lists['p:Box']?.length} boxes)`);
- await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B holds the same scatter', 15000);
+ await h.eventuallyAfter(A, B, async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B holds the same scatter', 15000);
let d0 = 0;
// ---- E4 select: real click, real box, gizmo -------------------------------------------------
@@ -232,7 +232,7 @@ h.run(async () => {
h.check(turned, 'Turn adds 0.5 rad to each selected yaw');
h.check(sel.every((k) => Math.abs(post[k].scale / pre[k].scale - 2) < 0.02), 'Size doubles each selected instance');
h.check((await undoDepth(A.page)) === d0 + 3, 'three gizmo drags = three undo entries');
- await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B sees the gizmo edits', 10000);
+ await h.eventuallyAfter(A, B, async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B sees the gizmo edits', 10000);
await A.page.evaluate(() => window.__stores.history.undo());
post = (await listsOf(A.page, uuid))['p:Cone'];
h.check(sel.every((k) => Math.abs(post[k].scale - pre[k].scale) < 0.01), 'undo takes the last drag (the size) back');
@@ -243,9 +243,9 @@ h.run(async () => {
[uuid, sel]
);
const before = (await listsOf(A.page, uuid))['p:Cone'];
- await A.page.click('#sculpt-scatter-turn-left');
- await A.page.click('#sculpt-scatter-bigger');
- await A.page.click('#sculpt-scatter-reroll');
+ await A.page.click('#sculpt-scatter-turn-left', { timeout: h.PATIENT_CLICK_MS });
+ await A.page.click('#sculpt-scatter-bigger', { timeout: h.PATIENT_CLICK_MS });
+ await A.page.click('#sculpt-scatter-reroll', { timeout: h.PATIENT_CLICK_MS });
post = (await listsOf(A.page, uuid))['p:Cone'];
h.check(sel.every((k) => post[k].x === before[k].x && post[k].z === before[k].z), 'Turn / Bigger / Re-roll keep the selected positions');
h.check(sel.some((k) => post[k].scale !== before[k].scale || post[k].yaw !== before[k].yaw), 'and change their look');
@@ -260,7 +260,7 @@ h.run(async () => {
post = (await listsOf(A.page, uuid))['p:Cone'];
h.check(post.length === coneN - sel.length, `Delete removes the selected instances (${coneN} -> ${post.length})`);
h.check((await objectCount(A.page)) === objs, 'and NOT the terrain');
- await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B sees the delete', 10000);
+ await h.eventuallyAfter(A, B, async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B sees the delete', 10000);
await A.page.evaluate(() => window.__stores.history.undo());
h.check((await listsOf(A.page, uuid))['p:Cone'].length === coneN, 'one undo brings them back');
diff --git a/tests/e2e/scatter-sources.test.cjs b/tests/e2e/scatter-sources.test.cjs
index e3e9f33ef..f900702db 100644
--- a/tests/e2e/scatter-sources.test.cjs
+++ b/tests/e2e/scatter-sources.test.cjs
@@ -136,7 +136,7 @@ h.run(async () => {
s.objectActions.deselectObject();
return t.uuid;
});
- await h.eventually(() => blockOf(B.page, terrain), (v) => v !== 'missing', 'B has the terrain', 20000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, terrain), (v) => v !== 'missing', 'B has the terrain', 20000);
/** what A sends for the set */
const sent = [];
await A.page.evaluate(() => {
@@ -163,7 +163,7 @@ h.run(async () => {
sent.push(...(await A.page.evaluate(() => window.__sent51)));
h.check(setId === 'rocks', `addScatterSet made the set (${setId})`);
h.check(sent.length === 1 && sent[0] < 600, `ONE message, params + seed only (${sent.join(',')} bytes for 6000 instances)`);
- await h.eventually(() => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.seed === 777, 'B has the set', 15000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.seed === 777, 'B has the set', 15000);
const onA = await instancesOf(A.page, terrain, 'rocks');
const onB = await instancesOf(B.page, terrain, 'rocks');
h.check(onA.length === 6000, `A generates 6000 instances (${onA.length})`);
@@ -228,7 +228,7 @@ h.run(async () => {
s.objectsGroup.update((v) => v);
return b.uuid;
});
- await h.eventually(() => posOf(B.page, ball), (v) => !!v, 'B has the ball', 15000);
+ await h.eventuallyAfter(A, B, () => posOf(B.page, ball), (v) => !!v, 'B has the ball', 15000);
// A made (and so selected) the ball: a peer's selection is a LOCK, and a locked object is
// left out of the simulation — let it go and wait for B to see it unlocked
await A.page.evaluate(() => window.__stores.objectActions.deselectObject());
@@ -253,10 +253,10 @@ h.run(async () => {
// ---- §4 hand edits + re-roll keep the edits --------------------------------------------------
await A.page.evaluate((u) => window.__stores.terrainPaint.editScatterInstances(u, 'rocks', { g5: { x: 1.5, z: -2.5, yaw: 0.5 }, g6: { del: true } }), terrain);
- await h.eventually(() => blockOf(B.page, terrain), (v) => !!v?.sets?.[0]?.ov?.['5'], 'B has the edits', 10000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, terrain), (v) => !!v?.sets?.[0]?.ov?.['5'], 'B has the edits', 10000);
const before = await instancesOf(B.page, terrain, 'rocks');
await A.page.evaluate((u) => window.__stores.terrainPaint.rerollScatterSet(u, 'rocks', 4242), terrain);
- await h.eventually(() => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.seed === 4242, 'B has the re-roll', 10000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.seed === 4242, 'B has the re-roll', 10000);
const after = await instancesOf(B.page, terrain, 'rocks');
const g5 = after.find((i) => i[0] === 'g5');
h.check(g5 && g5[1] === 1.5 && g5[2] === -2.5 && g5[3] === 0.5, `the moved slot stays where it was put after the re-roll (${JSON.stringify(g5)})`);
@@ -265,24 +265,24 @@ h.run(async () => {
h.check(JSON.stringify(after) === JSON.stringify(await instancesOf(A.page, terrain, 'rocks')), 'A and B agree after edits + re-roll');
// one undo puts the seed back
await A.page.evaluate(() => window.__stores.history.undo());
- await h.eventually(() => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.seed === 777, 'one undo restores the seed on B', 10000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.seed === 777, 'one undo restores the seed on B', 10000);
// ---- §5 a mixed set through the real select ------------------------------------------------
await A.page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), terrain);
const add = A.page.locator('[id="terrain-scatter-add-rocks"]');
if (!(await add.isVisible().catch(() => false))) {
const head = A.page.locator('button', { hasText: /^Scatter$/ }).first();
- if (await head.isVisible().catch(() => false)) await head.click();
+ if (await head.isVisible().catch(() => false)) await head.click({ timeout: h.PATIENT_CLICK_MS });
}
- await add.scrollIntoViewIfNeeded();
- await add.click();
- await A.page.locator('.ts-list [role="option"]', { hasText: /^\s*Cone\s*$/ }).click();
- await h.eventually(() => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.sources?.length === 2, 'B has the mix', 10000);
+ await add.scrollIntoViewIfNeeded({ timeout: h.PATIENT_CLICK_MS });
+ await add.click({ timeout: h.PATIENT_CLICK_MS });
+ await A.page.locator('.ts-list [role="option"]', { hasText: /^\s*Cone\s*$/ }).click({ timeout: h.PATIENT_CLICK_MS });
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.sources?.length === 2, 'B has the mix', 10000);
const mixB = await blockOf(B.page, terrain);
h.check(mixB.sets[0].sources[1].source.type === 'Cone', `the select added a Cone to the mix (${JSON.stringify(mixB.sets[0].sources.map((e) => e.source.type))})`);
h.check(await A.page.locator('[data-scatter-sources="rocks"] [data-scatter-source]').count() === 2, 'Properties lists both models');
await A.page.evaluate((u) => window.__stores.terrainPaint.setScatterSourceWeight(u, 'rocks', 1, 3), terrain);
- await h.eventually(() => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.sources?.[1]?.w === 3, 'the weight replicates', 10000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.sources?.[1]?.w === 3, 'the weight replicates', 10000);
const mixed = await instancesOf(B.page, terrain, 'rocks');
const cones = mixed.filter((i) => i[5] === 1).length;
h.check(Math.abs(cones / mixed.length - 0.75) < 0.03, `weights 1:3 → ${(100 * cones / mixed.length).toFixed(1)} % cones`);
@@ -291,7 +291,7 @@ h.run(async () => {
// ---- §6 tint + sink -----------------------------------------------------------------------
await A.page.evaluate((u) => window.__stores.terrainPaint.editScatterSet(u, 'rocks', { tint: 0.6, sink: 0.3 }), terrain);
- await h.eventually(() => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.tint === 0.6 && v?.sets?.[0]?.sink === 0.3, 'tint + sink replicate', 10000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.tint === 0.6 && v?.sets?.[0]?.sink === 0.3, 'tint + sink replicate', 10000);
const pose = await A.page.evaluate((u) => {
let scene = null;
window.__stores.globalScene.subscribe((v) => (scene = v))();
@@ -308,7 +308,7 @@ h.run(async () => {
h.check(pose?.colors && pose.c.some((v) => Math.abs(v - 1) > 0.01), `tinted instance colour (${pose?.c?.map((v) => v.toFixed(2))})`);
h.check((await drawnOf(B.page, terrain)).rocks?.colors, 'B draws the tint too');
await A.page.evaluate(() => window.__stores.history.undo());
- await h.eventually(() => blockOf(B.page, terrain), (v) => !v?.sets?.[0]?.tint && !v?.sets?.[0]?.sink, 'one undo clears tint + sink on B', 10000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, terrain), (v) => !v?.sets?.[0]?.tint && !v?.sets?.[0]?.sink, 'one undo clears tint + sink on B', 10000);
// ---- §7 a prefab with logic scatters as its look only --------------------------------------
const prefab = await A.page.evaluate(async () => {
@@ -324,8 +324,8 @@ h.run(async () => {
});
h.check(prefab.source?.kind === 'library' && /look only/i.test(prefab.source?.note ?? ''), `the prefab became a Library look with a note (${JSON.stringify(prefab.source)})`);
await A.page.evaluate(([u, src]) => window.__stores.terrainPaint.addScatterSet(u, { id: 'crates', source: src, seed: 3, gen: { count: 40, scaleMin: 1, scaleMax: 1 } }), [terrain, prefab.source]);
- await h.eventually(() => drawnOf(A.page, terrain), (v) => v.crates?.ids.length === 40, 'A draws the crates', 20000);
- await h.eventually(() => drawnOf(B.page, terrain), (v) => (v.crates?.ids.length ?? 0) > 0, 'B pulled the look and draws the crates', 30000);
+ await h.eventually(() => drawnOf(A.page, terrain), (v) => v.crates?.ids.length === 40, 'A draws the crates', 90000); // scatter-sync: the look loads + lays out locally; a slow runner needed > 20 s
+ await h.eventuallyAfter(A, B, () => drawnOf(B.page, terrain), (v) => (v.crates?.ids.length ?? 0) > 0, 'B pulled the look and draws the crates', 30000);
const glb = await A.page.evaluate((hash) => {
let items = [];
window.__stores.explorer.explorerItems.subscribe((v) => (items = v))();
@@ -387,7 +387,7 @@ h.run(async () => {
else {
h.check(pack.source.kind === 'pack' && !!pack.source.path, `a pack item resolves to its path (${pack.source.path})`);
await A.page.evaluate(([u, src]) => window.__stores.terrainPaint.addScatterSet(u, { id: 'kit', source: src, seed: 9, gen: { count: 30, scaleMin: 1, scaleMax: 1 } }), [terrain, pack.source]);
- await h.eventually(() => drawnOf(B.page, terrain), (v) => (v.kit?.ids.length ?? 0) > 0, 'B fetches the pack item and draws it', 40000);
+ await h.eventuallyAfter(A, B, () => drawnOf(B.page, terrain), (v) => (v.kit?.ids.length ?? 0) > 0, 'B fetches the pack item and draws it', 40000);
const kitBlock = await blockOf(B.page, terrain);
const kitSet = kitBlock.sets.find((s) => s.id === 'kit');
h.check(!!kitSet && !kitSet.data && JSON.stringify(kitSet).length < 400, `only the path + params travel (${JSON.stringify(kitSet)?.length} bytes)`);
diff --git a/tests/e2e/scatter-surface.test.cjs b/tests/e2e/scatter-surface.test.cjs
index d19c4a80e..526f5c6dc 100644
--- a/tests/e2e/scatter-surface.test.cjs
+++ b/tests/e2e/scatter-surface.test.cjs
@@ -103,7 +103,7 @@ h.run(async () => {
// park it where nothing else is, at rest (a /create stamps a dynamic body; no sim runs here)
return o.uuid;
});
- await h.eventually(() => blockOf(B.page, uuid), (v) => v !== 'missing', 'B has the sphere', 20000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, uuid), (v) => v !== 'missing', 'B has the sphere', 20000);
await moveHost(A.page, uuid, [0, 1, 0], [0, 0, 0], [1, 1, 1]);
const geoBefore = await A.page.evaluate((u) => {
let g;
@@ -116,10 +116,10 @@ h.run(async () => {
// ---- E2: the "Add scatter" row, right after the Water section -------------------------------
await A.page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), uuid);
const add = A.page.locator('#scatter-add');
- await add.waitFor({ state: 'attached', timeout: 15000 });
+ await add.waitFor({ state: 'attached', timeout: h.PATIENT_CLICK_MS });
if (!(await add.isVisible().catch(() => false))) {
const head = A.page.locator('button', { hasText: /^Scatter$/ }).first();
- if (await head.isVisible().catch(() => false)) await head.click();
+ if (await head.isVisible().catch(() => false)) await head.click({ timeout: h.PATIENT_CLICK_MS });
}
const order = await A.page.evaluate(() => {
const pour = document.querySelector('#pour-add');
@@ -131,9 +131,9 @@ h.run(async () => {
});
h.check(order.pour && order.bubbles, 'the Add scatter row comes after Add bubble emitter / Add pour emitter');
h.check(order.label === 'Add scatter', `the row reads "Add scatter" (${order.label})`);
- await add.scrollIntoViewIfNeeded();
+ await add.scrollIntoViewIfNeeded({ timeout: h.PATIENT_CLICK_MS });
const undo0 = await undoDepth(A.page);
- await add.click();
+ await add.click({ timeout: h.PATIENT_CLICK_MS });
await h.eventually(() => firstSet(A.page, uuid), (s) => !!s?.gen, 'Add scatter makes a FILL set on the sphere', 8000);
const set = await firstSet(A.page, uuid);
h.check(set.host === 'mesh' && set.gen.count === 200 && set.seed > 0, `the set lives on the mesh (host ${set.host}, count ${set.gen.count}, seeded)`);
@@ -153,8 +153,8 @@ h.run(async () => {
// ---- replication + determinism -----------------------------------------------------------------
const aBlock = await blockOf(A.page, uuid);
h.check(aBlock.length < 400, `the wire carries params + seed, not instances (${aBlock.length} chars)`);
- await h.eventually(() => blockOf(B.page, uuid), (v) => v === aBlock, 'the block is equal on B', 15000);
- await h.eventually(() => drawn(B.page, uuid), (d) => samePlaces(d, aDrawn), 'B draws the SAME 200 instances (same seed, same places)', 15000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, uuid), (v) => v === aBlock, 'the block is equal on B', 15000);
+ await h.eventuallyAfter(A, B, () => drawn(B.page, uuid), (d) => samePlaces(d, aDrawn), 'B draws the SAME 200 instances (same seed, same places)', 15000);
const reapplied = await A.page.evaluate((u) => {
let g;
window.__stores.objectsGroup.subscribe((v) => (g = v))();
@@ -175,7 +175,7 @@ h.run(async () => {
'A: the instances follow the host (moved, turned 90°, scaled 2x)'
);
h.check(moved.every((p, i) => p.local.every((v, k) => Math.abs(v - aDrawn[i].local[k]) < 1e-6)), 'their LOCAL layout did not change (no reshuffle)');
- await h.eventually(() => drawn(B.page, uuid), (d) => samePlaces(d, moved, 2e-3), 'B: the instances followed the move too', 15000);
+ await h.eventuallyAfter(A, B, () => drawn(B.page, uuid), (d) => samePlaces(d, moved, 2e-3), 'B: the instances followed the move too', 15000);
// ---- E3 fill: density, spacing, masks ------------------------------------------------------------
await editSet(A.page, uuid, set.id, { gen: { ...set.gen, count: undefined, density: 4 } });
@@ -202,19 +202,19 @@ h.run(async () => {
const band = await drawn(A.page, uuid);
h.check(band.length === 120 && band.every((p) => Math.abs(p.local[1]) <= 0.3 + 1e-3), `HEIGHT -0.3..0.3 (local): a band round the middle (${band.length})`);
const bandBlock = await blockOf(A.page, uuid);
- await h.eventually(() => blockOf(B.page, uuid), (v) => v === bandBlock, 'the mask edits replicate', 15000);
- await h.eventually(() => drawn(B.page, uuid), (d) => d.length === 120 && d.every((p, i) => p.id === band[i].id), 'B draws the same masked fill', 15000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, uuid), (v) => v === bandBlock, 'the mask edits replicate', 15000);
+ await h.eventuallyAfter(A, B, () => drawn(B.page, uuid), (d) => d.length === 120 && d.every((p, i) => p.id === band[i].id), 'B draws the same masked fill', 15000);
// the Fill rows in the Inspector: Density switch through the real Segmented control
- await A.page.locator('[id$="-mode-density"]').first().click();
+ await A.page.locator('[id$="-mode-density"]').first().click({ timeout: h.PATIENT_CLICK_MS });
await h.eventually(() => firstSet(A.page, uuid), (s) => s?.gen?.density != null && s.gen.count == null, 'the Fill row switches the set to DENSITY', 8000);
- await A.page.locator('[id$="-mode-count"]').first().click();
+ await A.page.locator('[id$="-mode-count"]').first().click({ timeout: h.PATIENT_CLICK_MS });
await h.eventually(() => firstSet(A.page, uuid), (s) => s?.gen?.count != null, '...and back to COUNT', 8000);
if (SHOTS)
for (const t of ['dark', 'light']) {
await A.page.evaluate((th) => window.__stores.themes.theme.set(th), t);
await A.page.waitForTimeout(400);
- await A.page.locator('[data-scatter-fill]').first().scrollIntoViewIfNeeded();
+ await A.page.locator('[data-scatter-fill]').first().scrollIntoViewIfNeeded({ timeout: h.PATIENT_CLICK_MS });
await A.page.screenshot({ path: `${SHOTS}/02-fill-rows-${t}.png` });
}
await A.page.evaluate(() => window.__stores.themes.theme.set('dark'));
@@ -253,10 +253,10 @@ h.run(async () => {
const boxes = (await drawn(A.page, uuid)).filter((p) => p.set === paint.painted?.id);
h.check(boxes.length === paint.painted?.count && boxes.every((p) => Math.abs(Math.hypot(...p.local) - 1) < 0.03 && p.local[0] > 0.3), 'painted instances sit ON the sphere where the brush went');
const pBlock = await blockOf(A.page, uuid);
- await h.eventually(() => blockOf(B.page, uuid), (v) => v === pBlock, 'the stroke replicates', 15000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, uuid), (v) => v === pBlock, 'the stroke replicates', 15000);
await A.page.evaluate(() => window.__stores.history.undo());
await h.eventually(() => firstSet(A.page, uuid).then(() => blockOf(A.page, uuid)), (v) => !v.includes('"Box"'), 'ONE undo removes the stroke', 8000);
- await h.eventually(() => blockOf(B.page, uuid), (v) => !v.includes('"Box"'), '...on B too', 15000);
+ await h.eventuallyAfter(A, B, () => blockOf(B.page, uuid), (v) => !v.includes('"Box"'), '...on B too', 15000);
await A.page.evaluate(() => window.__stores.terrainSculpt.exitSculpt());
// ---- a terrain fill on a painted layer ------------------------------------------------------------
@@ -309,7 +309,7 @@ h.run(async () => {
gDrawn.length === 60 && gDrawn.every((p) => Math.abs(p.world[1] - 1) < 0.02 && (Math.abs(p.world[0] - 8) <= 0.51 || Math.abs(p.world[0] - 11) <= 0.51)),
`the fill lies on both children's top faces (${gDrawn.length})`
);
- await h.eventually(() => drawn(B.page, grp.uuid), (d) => samePlaces(d, gDrawn, 2e-3), 'B draws the group fill identically', 15000);
+ await h.eventuallyAfter(A, B, () => drawn(B.page, grp.uuid), (d) => samePlaces(d, gDrawn, 2e-3), 'B draws the group fill identically', 15000);
{
const bd = await drawn(B.page, grp.uuid);
if (!samePlaces(bd, gDrawn, 2e-3))
diff --git a/tests/e2e/scene-budget.test.cjs b/tests/e2e/scene-budget.test.cjs
index 2e0f7a916..cca527506 100644
--- a/tests/e2e/scene-budget.test.cjs
+++ b/tests/e2e/scene-budget.test.cjs
@@ -88,9 +88,21 @@ h.run(async () => {
h.check(typeof live.ingestBacklog === 'number', 'a registered source (the ingest backlog) reaches the sample');
// ---- 3. the meter's dot moves with the scene ----------------------------
- await A.page.waitForTimeout(700);
- const greenDot = await A.page.getAttribute('#object-budget-dot', 'data-tier');
- h.check(greenDot === 'green', `24 objects reads green in the status line (${greenDot})`);
+ // the dot is the WORST tier of every budget (Controls worstTier), so on software GL (~3 fps) frame
+ // time alone turns it red whatever the scene holds. Judge the OBJECTS row, and the dot only when the
+ // frame rows leave it to the objects.
+ const tierOf = (/** @type {string} */ key) =>
+ A.page.evaluate((k) => {
+ const b = window.__stores.sceneBudget;
+ let m;
+ b.sceneMetrics.subscribe((v) => (m = v))();
+ return b.budgetRows(m, m?.profile ?? 'desktop').find((r) => r.key === k)?.tier ?? null;
+ }, key);
+ await h.eventually(() => tierOf('objects'), (t) => t === 'green', '24 objects reads green on the objects budget');
+ if ((await tierOf('frameP95')) === 'green') {
+ const greenDot = await A.page.getAttribute('#object-budget-dot', 'data-tier');
+ h.check(greenDot === 'green', `…and in the status line (${greenDot})`);
+ } else console.log(' (frame time is over budget on this renderer — the status-line dot is not the objects reading here)');
await A.page.evaluate(async () => {
const { THREE, objectsGroup, pokeScene } = window.__stores;
@@ -101,15 +113,18 @@ h.run(async () => {
for (let i = 0; i < 3200; i++) group.add(new THREE.Mesh(geo, mat));
pokeScene();
});
+ await h.eventually(() => tierOf('objects'), (t) => t === 'red', 'the objects budget goes RED past the ceiling');
await h.eventually(
() => A.page.getAttribute('#object-budget-dot', 'data-tier'),
(t) => t === 'red',
'the status-line dot goes RED when the object budget is exceeded'
);
- const title = await A.page.getAttribute('#object-count', 'title');
- h.check(
- /over on/.test(String(title)) && /object/i.test(String(title)),
- `…and the tooltip names WHAT is over budget (${title})`
+ // the tooltip follows the NEXT sample (the 3200 objects), not the moment the dot went red — read it
+ // once it says so, instead of the instant after a dot that frame time had already turned red
+ await h.eventually(
+ () => A.page.getAttribute('#object-count', 'title'),
+ (t) => /over on/.test(String(t)) && /object/i.test(String(t)),
+ '…and the tooltip names WHAT is over budget'
);
// ---- 4. the panel, through its real entry point --------------------------
diff --git a/tests/e2e/scene-post-effects.test.cjs b/tests/e2e/scene-post-effects.test.cjs
index fc0c4f700..ae5c1513e 100644
--- a/tests/e2e/scene-post-effects.test.cjs
+++ b/tests/e2e/scene-post-effects.test.cjs
@@ -248,7 +248,13 @@ h.run(async () => {
let original = null;
peers.subscribe((p) => (original = p))();
const sent = [];
- peers.set({ ...original, send: (m) => sent.push(m) });
+ // assetShare sends per OPEN CONNECTION since 9c81d069 (chunked transfers + the ledger), not
+ // through peers.send — and nobody has joined yet in this section, so give it one to send on
+ peers.set({
+ ...original,
+ send: (m) => sent.push(m),
+ connections: { ...(original.connections ?? {}), spy: { open: true, send: (m) => sent.push(m) } }
+ });
document.querySelector('#post-param-' + id + '-lut').click();
await new Promise((r) => setTimeout(r, 300));
const rows = [...document.querySelectorAll('.ts-list [role="option"]')];
diff --git a/tests/e2e/scene-post.test.cjs b/tests/e2e/scene-post.test.cjs
index ea5d6b432..cb30918e6 100644
--- a/tests/e2e/scene-post.test.cjs
+++ b/tests/e2e/scene-post.test.cjs
@@ -1005,7 +1005,9 @@ h.run(async () => {
// one-switch escape hatch if their driver cannot cope. `(pointer: coarse)` is
// emulable with a hasTouch context (unlike the mobile VIEWPORT, which needs
// isMobile too).
- const T = await h.setupPage(browser, 'Touch', { context: { hasTouch: true } });
+ // the quality governor drops AO on slow frames (qualityOverrides.aoOff filters it out of the stack),
+ // and software GL is slow on every page — pin it, or 9.12 measures the governor, not the touch rule
+ const T = await h.setupPage(browser, 'Touch', { context: { hasTouch: true }, storage: { autoQuality: 'false' } });
const touch = await T.page.evaluate(async () => {
window.__stores.viewMode.set('shaded');
window.__stores.scenePost.scenePostRestore({
@@ -1013,20 +1015,30 @@ h.run(async () => {
effects: [{ id: 'a', kind: 'ao' }],
changedAt: 99
});
- await new Promise((r) => setTimeout(r, 1400));
- const on = window.__postDebug();
+ // the stack stays empty until the composer has warmed (postWarm, >10 rendered frames —
+ // 0.6-2.5 s at 4-17 fps), the same wait section 3.2 makes; a fixed 1400 ms read it cold
+ const t0 = performance.now();
+ let on = window.__postDebug();
+ while (!(on.postWarm && on.kinds.includes('ao')) && performance.now() - t0 < 20000) {
+ await new Promise((r) => requestAnimationFrame(r));
+ on = window.__postDebug();
+ }
window.__stores.viewportOverrides.setRenderLayer('post', false);
await new Promise((r) => setTimeout(r, 900));
+ let mode = null;
+ window.__stores.viewMode.subscribe((v) => (mode = v))();
return {
coarse: window.__stores.inputDevice.coarsePointer(),
kinds: on.kinds,
+ // what the composer said, so a red names its reason (warm-up, a skip, an empty plan, the mode)
+ why: { postWarm: on.postWarm, postOk: on.postOk, skipped: on.skipped, plan: (on.plan ?? []).length, stackPasses: on.stackPasses, mode, auto: localStorage.getItem('autoQuality') },
afterOff: window.__postDebug().stackPasses
};
});
h.check(touch.coarse === true, '9.11 premise: the touch context really reports a coarse pointer');
h.check(
touch.kinds.includes('ao'),
- '9.12 a touch device renders the scene look too — no silent second class of viewer'
+ '9.12 a touch device renders the scene look too — no silent second class of viewer (' + JSON.stringify(touch.kinds) + ' ' + JSON.stringify(touch.why) + ')'
);
h.check(touch.afterOff === 0, '9.12b ...and the same one-switch escape hatch works there');
diff --git a/tests/e2e/script-guard.test.cjs b/tests/e2e/script-guard.test.cjs
index 9041ae925..41c66d818 100644
--- a/tests/e2e/script-guard.test.cjs
+++ b/tests/e2e/script-guard.test.cjs
@@ -94,23 +94,26 @@ h.run(async () => {
h.check(clicked !== null, 'and the real UI is still there to be driven');
// the frame loop kept going: rAF still fires
+ const FRAME_WINDOW_MS = 3000;
const frames = await A.page.evaluate(
- () =>
+ (FRAME_WINDOW_MS) =>
new Promise((resolve) => {
let n = 0;
const t0 = performance.now();
const step = () => {
n++;
- if (performance.now() - t0 > 1800) return resolve(n);
+ if (performance.now() - t0 > FRAME_WINDOW_MS) return resolve(n);
requestAnimationFrame(step);
};
requestAnimationFrame(step);
- })
+ }),
+ FRAME_WINDOW_MS
);
// 1800 ms, not 600: headless SwiftShader runs ~5 fps and the runaway costs part of every frame, so 600 ms
// was 2-4 frames on either side of the threshold (1.24.0 and 1.25 both measure 9 frames / 2 s — 36-int-125);
- // a frozen loop reads 0-1
- h.check(frames > 3, `the render loop is still running (${frames} frames in 1800ms)`);
+ // a frozen loop reads 0-1. test-health-1: 3000 ms and >= 3 — a GitHub runner measured exactly 3 in 1800 ms
+ // (ci.yml e2e-smoke, 2026-10-23/24), red at `> 3` with the loop plainly alive.
+ h.check(frames >= 3, `the render loop is still running (${frames} frames in ${FRAME_WINDOW_MS}ms)`);
// ---- 3. a healthy node beside it still works ---------------------------------------
const uuid2 = await makeBox(A);
@@ -135,8 +138,10 @@ h.run(async () => {
// script at a million iterations, so it cannot buy time by looping MORE, it has to do
// more work per iteration. 900k plain additions measured about a millisecond here and
// the check failed for the fixture's sake rather than the feature's.
+ // (the counter on window is test-health-1's: when the badge never comes, it tells "the node did
+ // not run" from "it ran and was never paused")
const SLOW_SRC =
- 'let s = 0; for (let i = 0; i < 500000; i++) { s += Math.sin(i) * Math.cos(i); } data.s = s;';
+ 'let s = 0; for (let i = 0; i < 500000; i++) { s += Math.sin(i) * Math.cos(i); } data.s = s; globalThis.__slowRuns = (globalThis.__slowRuns || 0) + 1;';
const bodyMs = await A.page.evaluate((src) => {
const fn = new Function('data', src);
const t0 = performance.now();
@@ -153,13 +158,32 @@ h.run(async () => {
() => {
let v = {};
window.__stores.scriptErrors.subscribe((m) => (v = m))();
- return /too slow/i.test(String(v['slow'] ?? '')) ? v['slow'] : false;
+ if (/too slow/i.test(String(v['slow'] ?? ''))) return v['slow'];
+ // 40 runs and still no badge is a real red (a run under 8 ms resets the streak, so not 31)
+ return /** @type {any} */ (globalThis).__slowRuns >= 40 ? 'NO BADGE' : false;
},
- { timeout: 30000 }
+ // the guard pauses on the 30th slow RUN in a row (SLOW_FRAMES), so the wait counts runs: a time
+ // estimate from the measured frame rate ended at exactly 29 runs twice on a GitHub runner (this
+ // node slows the frames it is measured by). Badge, or the node has run well past 30 — bounded.
+ { timeout: 180000 }
)
.then((r) => r.jsonValue())
.catch(() => null);
- h.check(!!slowBadge, `a slow node is paused rather than left to eat the frame (${slowBadge})`);
+ const badged = !!slowBadge && slowBadge !== 'NO BADGE';
+ const slowWhy = badged
+ ? slowBadge
+ : JSON.stringify(
+ await A.page
+ .evaluate(() => {
+ /** @type {any} */ let errs = {};
+ /** @type {any} */ let paused = null;
+ window.__stores.scriptErrors.subscribe((/** @type {any} */ m) => (errs = m))();
+ window.__stores.flowPaused.subscribe((/** @type {any} */ p) => (paused = p))();
+ return { runs: /** @type {any} */ (globalThis).__slowRuns ?? 0, errs, paused, visible: document.visibilityState };
+ })
+ .catch((/** @type {any} */ e) => String(e))
+ );
+ h.check(badged, `a slow node is paused rather than left to eat the frame (${slowWhy})`);
h.check(
await A.page.evaluate(() => 1 + 1).then((v) => v === 2),
'and the page is still responsive after it'
diff --git a/tests/e2e/search-menus.test.cjs b/tests/e2e/search-menus.test.cjs
index 0379f9ef7..b85201f98 100644
--- a/tests/e2e/search-menus.test.cjs
+++ b/tests/e2e/search-menus.test.cjs
@@ -79,21 +79,18 @@ h.run(async () => {
h.check((await page.locator('#add-search-input').inputValue()) === '', 'Clear empties the field');
await page.keyboard.press('Escape');
- // node search: open flow, right-click pane, type to search, box right-drags
+ // node search: open flow, right-click pane, type to search. Since 26537bb6 the search runs inside
+ // the pane's context menu (its filter row), so the old pop-up box — and its right-drag — are gone
await page.locator('p[title="Node editor (N)"]').click();
await page.waitForTimeout(600);
await page.locator('.svelte-flow__pane').click({ button: 'right', position: { x: 400, y: 120 } });
- await page.waitForTimeout(300);
+ await page.waitForSelector('.ctx-filter', { state: 'attached', timeout: 5000 });
await page.keyboard.type('s');
- await page.waitForTimeout(300);
- h.check(await page.locator('#node-search-box').isVisible(), 'node search opens');
- const nb = await page.locator('#node-search-box').boundingBox();
- await page.mouse.move(nb.x + 60, nb.y + 70);
- await page.mouse.down({ button: 'right' });
- await page.mouse.move(nb.x + 180, nb.y + 140, { steps: 5 });
- await page.mouse.up({ button: 'right' });
- const nbAfter = await page.locator('#node-search-box').boundingBox();
- h.check(nbAfter.x > nb.x + 80, `node search box right-drags (${nb.x} → ${nbAfter.x})`);
+ const opened = await page
+ .waitForSelector('.ctx-filter.on', { timeout: 5000 })
+ .then(() => true, () => false);
+ h.check(opened, 'node search opens (the menu filter row)');
+ h.check((await page.locator('.ctx-match').count()) > 0, 'typing lists matching nodes');
await ctx.close();
await h.finish(browser);
diff --git a/tests/e2e/select-41.test.cjs b/tests/e2e/select-41.test.cjs
index bd49b7e91..28f2a8827 100644
--- a/tests/e2e/select-41.test.cjs
+++ b/tests/e2e/select-41.test.cjs
@@ -329,8 +329,10 @@ h.run(async () => {
const t0 = await screenOf(P, uuid, dx, dy);
const cam0 = (await store(P, 'globalCamera')) ? await P.evaluate(() => { let c; window.__stores.globalCamera.subscribe((v) => (c = v))(); return c.position.toArray(); }) : null;
await touch.send('touchStart', t0.x, t0.y);
- await P.waitForTimeout(250);
- const taken = await P.evaluate(() => window.__stores.playInteract.playInteractDebug().carrying);
+ // wait for the grab instead of a fixed 250 ms (a frame or two on software GL); the move starts
+ // from a carry that exists, and a carry that never comes still reads as not taken
+ const taken = await P.waitForFunction((u) => window.__stores.playInteract.playInteractDebug().carrying === u, uuid, { timeout: 3000 })
+ .then(() => uuid, () => P.evaluate(() => window.__stores.playInteract.playInteractDebug().carrying));
const t1 = await screenOf(P, uuid);
const grip0 = t0.local ? await screenOfLocal(P, uuid, t0.local) : null;
for (let i = 1; i <= steps; i++) {
@@ -402,7 +404,14 @@ h.run(async () => {
h.check(c.hit && c.taken, 'G4 (far) the finger takes the crate 10 m away');
h.check(c.jump < 8 && Math.abs(dist1 - dist0) < 0.6, `G4 (far) it stays at its distance (${dist0.toFixed(2)} -> ${dist1.toFixed(2)} m, jump ${c.jump.toFixed(1)} px)`);
h.check(c.track < 14, `G4 (far) and follows the finger (off by ${c.track.toFixed(1)} px)`);
- await P.waitForTimeout(1500);
+ // the flick aims at where the crate IS: wait for the far carry's release to come to rest (a fixed
+ // 1.5 s left a light crate still sliding at ~4 fps, so the finger landed beside it)
+ await h.eventually(
+ () => P.evaluate((u) => window.__stores.physics.bodyVelocityOf(u), crate),
+ (v) => !!v && Math.hypot(...v.linvel) < 0.05,
+ '(premise) the crate is at rest before the flick',
+ 15000
+ );
// a FLICK: released mid-move, the throw is bounded
const crateAt2 = (await screenOf(P, crate)).pos;
@@ -419,6 +428,11 @@ h.run(async () => {
const U = await h.setupPage(browser, 'unfolded', { context: N6_UNFOLDED, storage: STORAGE });
const Q = U.page;
const touchU = await touchApi(Q);
+ // a tap within 400 ms of the last one is a DOUBLE-tap (Scene.svelte), which does not deselect — on the
+ // PAGE's clock. A long software-GL frame makes 700 ms of test time less than that in the app, so each
+ // tap below waits until the page itself is 450 ms past the last pointerup
+ await Q.evaluate(() => window.addEventListener('pointerup', () => (window.__lastUp = Date.now()), true));
+ const pastDoubleTap = () => Q.waitForFunction(() => Date.now() - (window.__lastUp ?? 0) > 450);
await look(Q, [0, 3, 6], [0, 0.5, 0]);
const ua = await make(Q, [-1.2, 0.5, 0]);
await rest(Q);
@@ -427,9 +441,24 @@ h.run(async () => {
await touchU.tap(at.x, at.y);
await Q.waitForTimeout(700);
h.check(JSON.stringify(await sel(Q)) === JSON.stringify([ua]), 'G14 (unfolded) a tap selects');
+ await pastDoubleTap();
+ // what the finger lands on: the folded run passes with the same code, and on this larger screen the
+ // seated gizmo may cover the box — a red says whether the tap reached a gizmo handle (axis/dragging)
+ await Q.evaluate(() => {
+ const w = /** @type {any} */ (window);
+ w.__gz = [];
+ const note = (/** @type {string} */ t) => {
+ let tc;
+ w.__stores.TControls?.subscribe?.((/** @type {any} */ v) => (tc = v))?.();
+ w.__gz.push([t, tc?.axis ?? null, !!tc?.dragging]);
+ };
+ for (const t of ['pointerdown', 'pointerup']) window.addEventListener(t, () => note(t), { capture: true, once: true });
+ });
await touchU.tap(at.x, at.y);
await Q.waitForTimeout(700);
- h.check((await sel(Q)).length === 0, `G14 (unfolded) a tap on the selected box deselects it (${JSON.stringify(await sel(Q))})`);
+ const gizmo = await Q.evaluate(() => /** @type {any} */ (window).__gz);
+ h.check((await sel(Q)).length === 0, `G14 (unfolded) a tap on the selected box deselects it (${JSON.stringify(await sel(Q))}; gizmo at the tap ${JSON.stringify(gizmo)})`);
+ await pastDoubleTap();
await touchU.tap(at.x, at.y);
await Q.waitForTimeout(700);
const modeCell = '#editor-mode-toggle';
diff --git a/tests/e2e/sessions.test.cjs b/tests/e2e/sessions.test.cjs
index 97c2302ab..0bba9ae95 100644
--- a/tests/e2e/sessions.test.cjs
+++ b/tests/e2e/sessions.test.cjs
@@ -77,30 +77,57 @@ h.run(async () => {
await A.page.evaluate((id) => window.__stores.sessions.requestLoadSession(id), target.id);
await B.page.waitForTimeout(800);
h.check(
- await B.page.getByText(/wants to load session/).isVisible(),
+ // the TOAST, not its copy in the screen-reader live region (e3edaee5) — getByText matched both
+ await B.page.locator('.tp-toast-text', { hasText: /wants to load session/ }).isVisible(),
'B sees the proposal toast'
);
- await B.page.getByRole('button', { name: 'Decline', exact: true }).click();
+ // the proposal's OWN toast's button: another toast's action could share the name
+ await B.page.locator('.tp-toast', { hasText: /wants to load session/ }).getByRole('button', { name: 'Decline', exact: true }).click();
await A.page.waitForTimeout(800);
h.check(
- await A.page.getByText(/declined the session load/).isVisible(),
+ await A.page.locator('.tp-toast-text', { hasText: /declined the session load/ }).isVisible(),
'A is told about the decline'
);
// then B accepts: the load applies and replicates
await A.page.evaluate((id) => window.__stores.sessions.requestLoadSession(id), target.id);
await B.page.waitForTimeout(800);
- await B.page.getByRole('button', { name: 'Accept', exact: true }).click();
+ await B.page.locator('.tp-toast', { hasText: /wants to load session/ }).getByRole('button', { name: 'Accept', exact: true }).click();
+ let replaced = false;
await h.eventually(
() => Promise.all([objectNames(A.page), objectNames(B.page)]),
([a, b]) =>
- a.filter((n) => n === 'Box').length === 1 &&
- a.includes('Sphere') &&
- b.filter((n) => n === 'Box').length === 1 &&
- b.includes('Sphere'),
+ (replaced =
+ a.filter((n) => n === 'Box').length === 1 &&
+ a.includes('Sphere') &&
+ b.filter((n) => n === 'Box').length === 1 &&
+ b.includes('Sphere')),
'accepted load replaced the scene on both peers',
15000
);
+ // test-health-1: red since the toast check stopped masking it (e3edaee5) and not explained from the
+ // code (A clears and adds nothing, B applies nothing) — a red prints each side's link, load job and
+ // recent net/session diagnostics, so the next run says which path stopped
+ if (!replaced)
+ for (const [name, p, other] of [['A', A, B], ['B', B, A]])
+ console.log(
+ ` ${name} after Accept: ` +
+ JSON.stringify(
+ await p.page.evaluate((otherId) => {
+ const s = window.__stores;
+ let pc;
+ s.peers.subscribe((v) => (pc = v))();
+ const job = s.sceneLoader.currentJob?.();
+ return {
+ id: pc?.peer?.id,
+ openTo: !!pc?.connections?.[otherId]?.open,
+ reconnecting: pc?.reconnecting?.get?.(otherId) ?? null,
+ job: job ? { name: job.name, cancelled: !!job.cancelled, interrupted: !!job.interrupted } : null,
+ log: (s.diagnostics.lines?.() ?? []).filter((l) => /net|session|load|clear|wire/i.test(JSON.stringify(l))).slice(-12)
+ };
+ }, other.id)
+ )
+ );
await h.finish(browser);
});
diff --git a/tests/e2e/settings-layout.test.cjs b/tests/e2e/settings-layout.test.cjs
index 046f9b205..622a77a1e 100644
--- a/tests/e2e/settings-layout.test.cjs
+++ b/tests/e2e/settings-layout.test.cjs
@@ -220,13 +220,15 @@ h.run(async () => {
await open(P.page, key);
const t = await P.page.evaluate(() => {
// a description squeezed beside its control (Density's two long options left it a
- // one-word column) — on a phone the text keeps at least half the row
+ // one-word column). The floor is the product's own: rowFit keeps a control beside its
+ // label only while the text column keeps TEXT_MIN (150 px, 437423c8) — the old "half the
+ // row" rule contradicted that on a 390 px phone (settings-row-fit asserts the product's)
const squeezed = [];
for (const row of document.querySelectorAll('#settings-sections .setting-row')) {
const el = /** @type {HTMLElement} */ (row);
const d = el.querySelector('.sr-desc');
if (!el.offsetParent || !d || !(d.textContent || '').trim()) continue;
- if (d.getBoundingClientRect().width < el.getBoundingClientRect().width * 0.5) squeezed.push((el.querySelector('.sr-name')?.textContent || '').trim());
+ if (d.getBoundingClientRect().width < 149) squeezed.push((el.querySelector('.sr-name')?.textContent || '').trim());
}
const small = [];
for (const el of document.querySelectorAll('#settings-sections .seg-opt, #settings-sections .tp-ui.nr, #settings-sections button.btn, #settings-sections .settings-num, #settings-sections .settings-text')) {
diff --git a/tests/e2e/settings-shell.test.cjs b/tests/e2e/settings-shell.test.cjs
index 28f2899f3..2011b7057 100644
--- a/tests/e2e/settings-shell.test.cjs
+++ b/tests/e2e/settings-shell.test.cjs
@@ -50,8 +50,8 @@ h.run(async () => {
const byGroup = (g) => s.rows.filter((r) => r.group === g).map((r) => r.label);
// 53 A1: Accessibility sits right under Interface
h.check(JSON.stringify(byGroup('general')) === JSON.stringify(['Interface', 'Accessibility', 'Controls', 'Input', 'Touch controls', 'Shortcuts']), 'General: Interface, Accessibility, Controls, Input, Touch controls, Shortcuts (' + byGroup('general').join(', ') + ')');
- // 46 P2: Settings ▸ Import sits beside Export
- h.check(JSON.stringify(byGroup('workspace')) === JSON.stringify(['Scene', 'Explorer', 'Node types', 'Import', 'Export']), 'Workspace: Scene, Explorer, Node types, Import, Export');
+ // 46 P2: Settings ▸ Import sits beside Export; 60 Q6: Storage (downloads kept on this device) after Explorer
+ h.check(JSON.stringify(byGroup('workspace')) === JSON.stringify(['Scene', 'Explorer', 'Storage', 'Node types', 'Import', 'Export']), 'Workspace: Scene, Explorer, Storage, Node types, Import, Export (' + byGroup('workspace').join(', ') + ')');
// 48 O1: Privacy (the error-report switch) closes the group
h.check(JSON.stringify(byGroup('devices')) === JSON.stringify(['VR', 'AI', 'Connection', 'Privacy']), 'Devices & services: VR, AI, Connection, Privacy (' + byGroup('devices').join(', ') + ')');
h.check(labels[labels.length - 1] === 'About & what’s new' && s.rows[s.rows.length - 1].group === 'about', '"About & what’s new" is pinned last');
diff --git a/tests/e2e/shader-sync.test.cjs b/tests/e2e/shader-sync.test.cjs
index 572976e52..593959dbb 100644
--- a/tests/e2e/shader-sync.test.cjs
+++ b/tests/e2e/shader-sync.test.cjs
@@ -189,11 +189,36 @@ h.run(async () => {
(n) => n === 2,
'a LATE JOINER receives the graph through the handshake'
);
+ // the compile needs the OBJECT as well as the document (shaderGraph's reconcile retries when it lands):
+ // a red below says which of the two never arrived on the third page
+ await h.eventually(
+ () => C.page.evaluate((u) => new Promise((r) => window.__stores.objectsGroup.subscribe((g) => r(!!g?.getObjectByProperty('uuid', u)))()), uuid),
+ (has) => has === true,
+ '(premise) the late joiner has the cube the graph drives',
+ 20000
+ );
+ let cDriven = false;
await h.eventually(
() => C.page.evaluate((u) => window.__stores.shaderGraph.isShaderDriven(u), uuid),
- (driven) => driven === true,
+ (driven) => (cDriven = driven === true),
'and compiles it locally too'
);
+ // a red says WHY: the compile's own errors, and whether a reconcile run by hand compiles it — if it
+ // does, the object arrived without poking objectsGroup and the late-joiner retry never ran
+ if (!cDriven)
+ console.log(
+ ' C compile: ' +
+ JSON.stringify(
+ await C.page.evaluate(async (u) => {
+ const sg = window.__stores.shaderGraph;
+ let errs = null;
+ sg.shaderErrors?.subscribe?.((e) => (errs = e?.[u] ?? null))?.();
+ sg.reconcileShaderGraphs();
+ await new Promise((r) => setTimeout(r, 1500));
+ return { errors: errs, drivenAfterManualReconcile: sg.isShaderDriven(u) };
+ }, uuid)
+ )
+ );
// ---- 7. delete replicates ----------------------------------------------
await A.page.evaluate((u) => window.__stores.shaderGraph.setShaderGraphFor(u, null), uuid);
diff --git a/tests/e2e/sound-node.test.cjs b/tests/e2e/sound-node.test.cjs
index 467e377f8..0c2a4445f 100644
--- a/tests/e2e/sound-node.test.cjs
+++ b/tests/e2e/sound-node.test.cjs
@@ -88,7 +88,9 @@ h.run(async () => {
}),
hash
);
- h.check(bShared === 'Shared', `pulled bytes land in the Shared folder (${bShared})`);
+ // no stray 'Shared' folder since 186248ba (R22 round 9): pulled bytes take the shared row's folder,
+ // and A imported this one at the library root
+ h.check(bShared === null, `pulled bytes land where the shared row says — the root here, no 'Shared' folder (${bShared})`);
await h.eventually(
() => entriesOn(B.page),
(entries) => entries.length === 1 && entries[0].buffered,
@@ -96,9 +98,11 @@ h.run(async () => {
);
// Play toggles through the REAL node button and replicates as node data
+ // the editor shows the SELECTED object's graph since flow v2 H1 (b2350b75), and /create selected
+ // the box — deselect so it opens on the scene graph that holds the sound node
+ await A.page.evaluate(() => window.__stores.objectActions.deselectObject());
await A.page.locator('p[title="Node editor (N)"]').click();
- await A.page.waitForTimeout(900);
- await A.page.locator('.node-card button', { hasText: '▶ Play' }).click();
+ await A.page.locator('.node-card button', { hasText: '▶ Play' }).click(); // the locator waits for it
await A.page.waitForTimeout(900);
aEntries = await entriesOn(A.page);
h.check(aEntries[0]?.playing === true, 'Play starts the source on A');
diff --git a/tests/e2e/themes.test.cjs b/tests/e2e/themes.test.cjs
index 28ca4c9eb..fd9b32170 100644
--- a/tests/e2e/themes.test.cjs
+++ b/tests/e2e/themes.test.cjs
@@ -45,7 +45,8 @@ h.run(async () => {
const dark = await probe('dark');
h.check(
- dark.dataTheme === 'dark' && dark.dark && dark.surface === 'rgb(31, 41, 55)',
+ // tailwind 4's gray-800 (f3c8ef62, tailwind 3.4 -> 4.3); dark mode does not remap .bg-gray-800
+ dark.dataTheme === 'dark' && dark.dark && dark.surface === 'oklch(0.278 0.033 256.848)',
`dark is the authored look (surface ${dark.surface})`
);
diff --git a/tests/e2e/toolbar-edit-positions.test.cjs b/tests/e2e/toolbar-edit-positions.test.cjs
index 06ae01be3..599b30f63 100644
--- a/tests/e2e/toolbar-edit-positions.test.cjs
+++ b/tests/e2e/toolbar-edit-positions.test.cjs
@@ -134,7 +134,7 @@ h.run(async () => {
await clickAt(P, P.locator('#hud-stack-right .hud-edit-add').first());
await P.waitForTimeout(300);
const offered = await P.evaluate(() => [...document.querySelectorAll('[role="menuitem"]')].map((el) => el.textContent.trim()));
- h.check(offered.some((l) => l.startsWith('Chat')) && offered.includes('Rotate (2)') && !offered.some((l) => /^play/i.test(l)), T + `"+" offers every unplaced Customize button, never Play (${offered.join(' | ')})`);
+ h.check(offered.some((l) => l.startsWith('Chat')) && offered.includes('Rotate (2)') && !offered.some((l) => /^play(?! here)/i.test(l)), T + `"+" offers every unplaced Customize button, never Play (${offered.join(' | ')})`);
await P.getByRole('menuitem', { name: 'UV editor', exact: true }).click();
await P.waitForTimeout(300);
d = await draft(P);
diff --git a/tests/e2e/underlay.test.cjs b/tests/e2e/underlay.test.cjs
new file mode 100644
index 000000000..8e4113001
--- /dev/null
+++ b/tests/e2e/underlay.test.cjs
@@ -0,0 +1,242 @@
+// 60 Q5: REFERENCE IMAGE UNDERLAYS. Drop an image on the viewport → "Use as reference" → a locked,
+// unpickable, see-through plane on the ground (or facing the view), with its own opacity. A working
+// aid, not scene content: hidden in Play, absent from the GLTF export, the .tpscene payload (which
+// Export and Publish build from) and the viewport/room thumbnail; LOCAL by default, and "Share with
+// session" shows it read-only to the peers in your room until the owner turns the switch off.
+// Persisted per scene on this device: a reload keeps it.
+const h = require('./helpers.cjs');
+
+const store = (name) => `(() => { let v; window.__stores.${name}.subscribe((x) => (v = x))(); return v; })()`;
+const RED = [230, 20, 20];
+
+/** park the editor camera at `pos` looking at `target` (through the OrbitControls, which own the view) */
+const aim = (page, pos, target) =>
+ page.evaluate(
+ ({ pos, target }) => {
+ const s = window.__stores;
+ const get = (st) => { let v; st.subscribe((x) => (v = x))(); return v; };
+ const cam = get(s.editorCam);
+ const oc = get(s.orbitControls);
+ oc.target.set(...target);
+ cam.position.set(...pos);
+ cam.lookAt(...target);
+ oc.update();
+ cam.updateMatrixWorld(true);
+ },
+ { pos, target }
+ );
+
+/** drop a solid-red PNG (made on a canvas) on the viewport centre, the way a file drag does */
+const dropImage = (page, name = 'floor-plan.png') =>
+ page.evaluate(
+ async ({ name, rgb }) => {
+ const c = document.createElement('canvas');
+ c.width = 64;
+ c.height = 32;
+ const ctx = c.getContext('2d');
+ ctx.fillStyle = `rgb(${rgb.join(',')})`;
+ ctx.fillRect(0, 0, 64, 32);
+ const blob = await new Promise((r) => c.toBlob(r, 'image/png'));
+ const dt = new DataTransfer();
+ dt.items.add(new File([blob], name, { type: 'image/png' }));
+ let canvas;
+ window.__stores.globalRenderer.subscribe((r) => (canvas = r.domElement))();
+ const box = canvas.getBoundingClientRect();
+ const at = { clientX: box.x + box.width / 2, clientY: box.y + box.height / 2 };
+ canvas.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt, ...at }));
+ canvas.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt, ...at }));
+ },
+ { name, rgb: RED }
+ );
+
+/** the layer's meshes, and whether any of them sits under sceneObjects */
+const layer = (page) =>
+ page.evaluate(() => {
+ const s = window.__stores;
+ const get = (st) => { let v; st.subscribe((x) => (v = x))(); return v; };
+ const root = get(s.globalScene)?.getObjectByName('reference-underlays');
+ let inObjects = false;
+ get(s.objectsGroup).traverse((o) => {
+ if (o.userData?.underlay) inObjects = true;
+ });
+ return {
+ found: !!root,
+ meshes: (root?.children ?? []).map((m) => ({ id: m.userData.underlay, visible: m.visible, opacity: m.material.opacity, owner: m.userData.owner, layer: m.layers.mask })),
+ inObjects
+ };
+ });
+
+/** the centre pixel of a fresh editor render (helper layer as the camera has it), and of the
+ * viewport thumbnail (sessions.viewportThumbnail, which rooms and the session list use) */
+const centrePixels = (page) =>
+ page.evaluate(async () => {
+ const s = window.__stores;
+ const get = (st) => { let v; st.subscribe((x) => (v = x))(); return v; };
+ const renderer = get(s.globalRenderer);
+ const scene = get(s.globalScene);
+ const camera = get(s.globalCamera);
+ const read = (source) => {
+ const c = document.createElement('canvas');
+ c.width = source.width;
+ c.height = source.height;
+ const ctx = c.getContext('2d');
+ ctx.drawImage(source, 0, 0);
+ return [...ctx.getImageData(Math.floor(c.width / 2), Math.floor(c.height / 2), 1, 1).data.slice(0, 3)];
+ };
+ renderer.render(scene, camera);
+ const live = read(renderer.domElement);
+ const url = s.sessions.viewportThumbnail(256);
+ const img = new Image();
+ await new Promise((r) => ((img.onload = r), (img.onerror = r), (img.src = url)));
+ return { live, thumb: read(img) };
+ });
+const reddish = (p) => p[0] > 150 && p[0] > p[1] + 80 && p[0] > p[2] + 80;
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1440, height: 900 } } });
+ const P = A.page;
+
+ // ---- 1. drop an image → "Use as reference — on the ground" -------------------------------------
+ await aim(P, [0, 12, 6], [0, 0, 0]);
+ await dropImage(P);
+ const row = P.getByRole('menuitem', { name: 'Use as reference — on the ground' });
+ h.check(await row.waitFor({ state: 'visible', timeout: 5000 }).then(() => true, () => false), 'an image-only drop asks what the image is for');
+ const items = await P.evaluate(() => [...document.querySelectorAll('[role="menuitem"]')].map((el) => el.textContent.trim()));
+ h.check(items.some((t) => t.includes('Add to the Explorer')), `...the Explorer stays an answer (${JSON.stringify(items)})`);
+ await row.click();
+ await h.eventually(() => P.evaluate(() => window.__stores.underlays.underlaysDebug().mine.length), (n) => n === 1, 'the image becomes one reference image of mine');
+ const mine = (await P.evaluate(() => window.__stores.underlays.underlaysDebug())).mine[0];
+ h.check(mine.mode === 'ground' && Math.abs(mine.position[1]) < 0.01 && mine.shared === false && mine.name === 'floor-plan', `on the ground, local, named after the file (${JSON.stringify({ m: mine.mode, p: mine.position, s: mine.shared, n: mine.name })})`);
+ h.check(Math.abs(mine.position[0]) < 0.01 && Math.abs(mine.position[2]) < 0.01, '...under the point the view orbits around');
+ h.check(mine.px[0] === 64 && mine.px[1] === 32, `...keeping the image's aspect (${mine.px})`);
+ h.check(await P.locator('#underlay-window').isVisible(), 'the Reference images window opens');
+ await P.waitForTimeout(400);
+ let L = await layer(P);
+ h.check(L.found && L.meshes.length === 1 && L.meshes[0].visible, `one plane in the reference layer (${JSON.stringify(L)})`);
+ h.check(!L.inObjects, 'nothing of it under sceneObjects (never synced, saved or exported)');
+ h.check(L.meshes[0].layer === 1 << 30, `...on the editor helper layer only (mask ${L.meshes[0].layer})`);
+
+ // ---- 2. locked and unpickable: a ray THROUGH it finds nothing, a click selects nothing ---------
+ const ray = await P.evaluate(() => {
+ const s = window.__stores;
+ const get = (st) => { let v; st.subscribe((x) => (v = x))(); return v; };
+ const mesh = get(s.globalScene).getObjectByName('reference-underlays').children[0];
+ const rc = new s.THREE.Raycaster(new s.THREE.Vector3(0, 5, 0), new s.THREE.Vector3(0, -1, 0));
+ const twin = new s.THREE.Mesh(mesh.geometry, mesh.material);
+ twin.position.copy(mesh.position);
+ twin.quaternion.copy(mesh.quaternion);
+ twin.scale.copy(mesh.scale);
+ twin.updateMatrixWorld(true);
+ return { real: rc.intersectObject(mesh).length, twin: rc.intersectObject(twin).length };
+ });
+ h.check(ray.twin === 1 && ray.real === 0, `a ray through it hits an ordinary plane of the same shape, not the underlay (${JSON.stringify(ray)})`);
+ await P.evaluate(() => window.__stores.objectActions.applySelectionSet([]));
+ const canvasBox = await P.evaluate(() => {
+ let el;
+ window.__stores.globalRenderer.subscribe((r) => (el = r.domElement))();
+ const b = el.getBoundingClientRect();
+ return { x: b.x, y: b.y, width: b.width, height: b.height };
+ });
+ await P.mouse.click(canvasBox.x + canvasBox.width / 2, canvasBox.y + canvasBox.height / 2);
+ await P.waitForTimeout(400);
+ h.check((await P.evaluate(store('selectedObjects'))).length === 0, 'a click on it selects nothing');
+
+ // ---- 3. the opacity slider drives the material --------------------------------------------------
+ await P.locator('#underlay-window [data-ul="opacity"] input[type="range"]').fill('1');
+ await P.waitForTimeout(300);
+ L = await layer(P);
+ h.check(Math.abs(L.meshes[0].opacity - 1) < 0.01, `opacity 100% reaches the plane (${L.meshes[0].opacity})`);
+ await P.locator('#underlay-window [data-ul="opacity"] input[type="range"]').fill('0.3');
+ await P.waitForTimeout(300);
+ L = await layer(P);
+ h.check(Math.abs(L.meshes[0].opacity - 0.3) < 0.01, `opacity 30% reaches the plane (${L.meshes[0].opacity})`);
+ await P.locator('#underlay-window [data-ul="opacity"] input[type="range"]').fill('1');
+ await P.waitForTimeout(300);
+
+ // ---- 4. the editor sees it; the thumbnail does not (counterfactual on one frame) ---------------
+ const px = await centrePixels(P);
+ h.check(reddish(px.live), `the live editor render shows the image at the centre (${px.live})`);
+ h.check(!reddish(px.thumb), `the viewport thumbnail leaves it out (${px.thumb})`);
+
+ // ---- 5. hidden in Play ---------------------------------------------------------------------------
+ await P.mouse.move(700, 450);
+ await P.keyboard.press('Control+Enter');
+ for (let i = 0; i < 40 && (await P.evaluate(store('isLocked'))) !== true; i++) await P.waitForTimeout(100);
+ h.check((await P.evaluate(store('isLocked'))) === true, 'Ctrl+Enter enters Play');
+ await P.waitForTimeout(300);
+ L = await layer(P);
+ h.check(L.meshes.length === 1 && !L.meshes[0].visible, 'in Play the plane is hidden');
+ h.check(!(await P.locator('#underlay-window').isVisible()), '...and so is its window');
+ await h.leavePlay(A);
+ await P.waitForTimeout(300);
+ L = await layer(P);
+ h.check(L.meshes[0].visible, 'back in the editor it shows again');
+
+ // ---- 6. never exported: the GLTF bytes and the .tpscene payload (Export/Publish) carry nothing --
+ const exp = await P.evaluate(async (id) => {
+ const s = window.__stores;
+ const get = (st) => { let v; st.subscribe((x) => (v = x))(); return v; };
+ const bytes = await s.fileHandler.gltfBytesFor([get(s.objectsGroup)], { binary: false });
+ const gltf = typeof bytes === 'string' ? bytes : bytes instanceof ArrayBuffer || ArrayBuffer.isView(bytes) ? new TextDecoder().decode(bytes) : JSON.stringify(bytes);
+ const payload = JSON.stringify(await s.sessions.buildSessionPayload('underlay-check'));
+ return { gltf: gltf.length, gltfHit: gltf.includes('underlay') || gltf.includes(id), payload: payload.length, payloadHit: payload.includes(id) || payload.includes('floor-plan') || payload.includes('reference-underlays') };
+ }, mine.id);
+ h.check(exp.payload > 0 && !exp.payloadHit, `the .tpscene payload holds no reference image (${exp.payload} chars)`);
+ h.check(!exp.gltfHit, `the GLTF export holds none (${exp.gltf} chars)`);
+
+ // ---- 7. a peer: nothing until Share; then read-only, following the owner; unshare takes it back --
+ const B = await h.setupPageQuiet(browser, 'B', [A], { context: { viewport: { width: 1280, height: 800 } } });
+ const Q = B.page;
+ await h.connect(B, A);
+ const theirs = () => Q.evaluate(() => Object.values(window.__stores.underlays.underlaysDebug().theirs).flat());
+ h.check((await theirs()).length === 0 && (await layer(Q)).meshes.length === 0, 'B sees nothing of A\'s local reference image');
+ await P.locator('#underlay-window [data-ul="share"]').check();
+ await h.eventually(theirs, (l) => l.length === 1 && l[0].id === mine.id, 'A ticks Share with session: B has the row', 15000);
+ await h.eventually(() => layer(Q), (l) => l.meshes.length === 1 && l.meshes[0].visible && l.meshes[0].owner === A.id, 'B draws the plane, owned by A');
+ h.check(!(await layer(Q)).inObjects, '...outside B\'s sceneObjects too');
+ await Q.evaluate(() => window.__stores.underlays.underlayWindowOpen.set(true));
+ await Q.waitForTimeout(400);
+ const bRow = Q.locator(`#underlay-window [data-underlay="${mine.id}"]`);
+ h.check((await bRow.locator('[data-ul="from"]').count()) === 1 && (await bRow.locator('[data-ul="opacity"]').count()) === 0, 'B\'s window lists it read-only (no controls)');
+ await P.locator('#underlay-window [data-ul="opacity"] input[type="range"]').fill('0.4');
+ await h.eventually(() => layer(Q), (l) => l.meshes.length === 1 && Math.abs(l.meshes[0].opacity - 0.4) < 0.01, 'A moves the opacity: B follows');
+ // one writer per row: B's own edit call on A's row is a no-op (it is not B's), nothing reaches A
+ await Q.evaluate((id) => window.__stores.underlays.updateUnderlay(id, { opacity: 0.9 }), mine.id);
+ await P.waitForTimeout(600);
+ h.check(Math.abs((await layer(P)).meshes[0].opacity - 0.4) < 0.01, 'B cannot edit A\'s row (owner-only writer)');
+ await P.locator('#underlay-window [data-ul="share"]').uncheck();
+ await h.eventually(theirs, (l) => l.length === 0, 'A unticks Share: B drops the row');
+ await h.eventually(() => layer(Q), (l) => l.meshes.length === 0, '...and the plane');
+ h.check((await layer(P)).meshes.length === 1, 'A keeps it');
+
+ // ---- 8. share again (A's reload below re-shares it: a reload is not an unshare), then B leaves ---
+ await P.locator('#underlay-window [data-ul="share"]').check();
+ await h.eventually(theirs, (l) => l.length === 1, 'shared again: B has it');
+ await h.closePeer(B);
+ await P.waitForTimeout(1500);
+
+ // ---- 9. A reloads: the image is kept on this device, per scene ---------------------------------
+ await P.waitForTimeout(500); // the idb write is debounced
+ await h.freshReload(A);
+ await h.answerRestore(A);
+ // a reload is a new peer: the id the joiner below dials is the new one
+ await P.waitForFunction(() => new Promise((r) => window.__stores.peers.subscribe((p) => r(!!p?.peer?.id))()), null, { timeout: 30000 });
+ A.id = await P.evaluate(() => new Promise((r) => window.__stores.peers.subscribe((p) => r(p?.peer?.id))()));
+ await h.eventually(() => P.evaluate(() => window.__stores.underlays.underlaysDebug()), (d) => d.loaded && d.mine.length === 1 && d.mine[0].id === mine.id && Math.abs(d.mine[0].opacity - 0.4) < 0.01, 'after a reload A still has the image (opacity kept)');
+ await h.eventually(() => layer(P), (l) => l.meshes.length === 1 && l.meshes[0].visible, '...drawn again');
+
+ // ---- 10. a joiner arriving after the share gets it from the full-state reply ------------------
+ const C = await h.setupPageQuiet(browser, 'C', [A], { context: { viewport: { width: 1280, height: 800 } } });
+ await h.connect(C, A);
+ await h.eventually(() => C.page.evaluate(() => Object.values(window.__stores.underlays.underlaysDebug().theirs).flat().length), (n) => n === 1, 'a joiner gets the shared image from the full-state reply', 15000);
+ await h.eventually(() => layer(C.page), (l) => l.meshes.length === 1, '...and draws it');
+ // removing it on A takes it away from the joiner
+ await P.evaluate(() => window.__stores.underlays.underlayWindowOpen.set(true));
+ await P.waitForTimeout(400);
+ await P.locator('#underlay-window [data-ul="remove"]').click();
+ await h.eventually(() => layer(C.page), (l) => l.meshes.length === 0, 'A removes it: gone for the joiner');
+ h.check((await layer(P)).meshes.length === 0, '...and for A');
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/vr-passthrough.test.cjs b/tests/e2e/vr-passthrough.test.cjs
index b938580bd..d5178ee5f 100644
--- a/tests/e2e/vr-passthrough.test.cjs
+++ b/tests/e2e/vr-passthrough.test.cjs
@@ -65,7 +65,15 @@ h.run(async () => {
})();
})
);
- h.check(restored === '3b4048', `exiting restores the preset sky (#${restored})`);
+ // the preset's OWN background, not a copy of it (c2005ce2 retuned the palette and this went stale)
+ const presetSky = await A.page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ const env = window.__stores.environment;
+ env.environment.subscribe((e) => resolve(String(env.ENVIRONMENT_PRESETS[e?.preset ?? 'studio']?.background ?? '').replace('#', '').toLowerCase()))();
+ })
+ );
+ h.check(restored === presetSky, `exiting restores the preset sky (#${restored}, preset #${presetSky})`);
const envAfter = await A.page.evaluate(
() =>
new Promise((resolve) => {
diff --git a/tests/e2e/vr-radial-menu.test.cjs b/tests/e2e/vr-radial-menu.test.cjs
index 4c0c348fa..1ec415368 100644
--- a/tests/e2e/vr-radial-menu.test.cjs
+++ b/tests/e2e/vr-radial-menu.test.cjs
@@ -100,8 +100,8 @@ h.run(async () => {
`base ring: Settings ▸ replaces System ▸ (${registry.root.join(',')})`
);
h.check(
- registry.tools.join(',') === 'tool:select,tool:box,tool:draw,ping,physics,nav:profile,ai',
- `Tools: Select / Box select / Draw mode / Ping / Simulate physics / Profile ▸ / AI (1.24 plan F) (${registry.tools.join(',')})`
+ registry.tools.join(',') === 'tool:select,tool:box,tool:draw,ping,physics,nav:profile,playhere,ai',
+ `Tools: Select / Box select / Draw mode / Ping / Simulate physics / Profile ▸ / Play here (60 Q1) / AI (1.24 plan F) (${registry.tools.join(',')})`
);
h.check(
registry.settings.slice(0, 5).join(',') === 'nav:settings:comfort,nav:settings:body,nav:settings:controls,nav:settings:display,nav:settings:editing' &&
diff --git a/tests/e2e/water-migration.test.cjs b/tests/e2e/water-migration.test.cjs
index 550124302..60f300197 100644
--- a/tests/e2e/water-migration.test.cjs
+++ b/tests/e2e/water-migration.test.cjs
@@ -23,10 +23,11 @@ const GATED = ['aquarium', 'pool-party', 'island-ocean'];
const REPORT = ['jelly-room', 'fluid-tank-toy'];
const T0 = 1791200000000; // a fixed wall clock: the same water phase on every build
const CLIP = { x: 190, y: 110, width: 900, height: 500 };
+const GOLDEN_CHROME_ROWS = 28;
h.run(async () => {
const browser = await h.launch({ args: h.GPU_ARGS });
- const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } }, storage: { 'water:quality': 'high' } });
+ const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } }, storage: { 'water:quality': 'high', autoQuality: 'false' } }); // test-health-1: the quality governor lowers dpr / shadows / AO on slow frames — a pixel comparison needs it pinned
await A.ctx.addInitScript((t) => {
Date.now = () => t;
}, T0);
@@ -62,7 +63,9 @@ h.run(async () => {
h.check(false, `${slug}: no golden (capture with GOLDEN_WRITE=1 on v1.22.0)`);
continue;
}
- const d = await h.frameDelta(A.page, fs.readFileSync(goldFile), png, 24);
+ // the 1.22 goldens carry a strip of UI chrome along their top edge (the connect pill was inside
+ // CLIP on that build): compare the scene below it
+ const d = await h.frameDelta(A.page, fs.readFileSync(goldFile), png, 24, GOLDEN_CHROME_ROWS);
rows.push(`| ${slug} | ${(d.fraction * 100).toFixed(2)}% | ${d.mean.toFixed(2)} | ${GATED.includes(slug) ? (d.fraction <= MAX_CHANGED ? 'same' : '**DIFFERENT**') : 'reported'} |`);
if (GATED.includes(slug))
h.check(d.fraction <= MAX_CHANGED, `${slug} (saved on 1.22) looks the same: ${(d.fraction * 100).toFixed(2)}% of the frame changed (max ${(MAX_CHANGED * 100).toFixed(0)}%)`);
diff --git a/tests/unit/admitNotice.test.js b/tests/unit/admitNotice.test.js
new file mode 100644
index 000000000..f1b3f5f5a
--- /dev/null
+++ b/tests/unit/admitNotice.test.js
@@ -0,0 +1,64 @@
+import { describe, it, expect } from 'vitest';
+import { get } from 'svelte/store';
+import { composeAdmitNotice, shortPeer } from '../../src/lib/admitNotice.js';
+import { connectBadge, setConnectBadge, peerMenuItems, setPeerMenuItems } from '../../src/lib/cloudHooks.js';
+
+// F-5: an automatic admission is never silent, and a standing "guests join" setting is visible.
+describe('F-5 auto-admit notice', () => {
+ it('uses the provider text and its Turn off', () => {
+ let off = 0;
+ const n = composeAdmitNotice('e6f54', {
+ text: 'E6F54 joined as a guest (view-only) because Allow guests to join automatically is on.',
+ turnOff: () => off++
+ });
+ expect(n.text).toMatch(/joined as a guest/);
+ expect(n.turnOffLabel).toBe('Turn off');
+ n.turnOff?.();
+ expect(off).toBe(1);
+ });
+ it('falls back to a generic line with no Turn off (no provider, a throwing one, junk)', () => {
+ for (const provided of [null, undefined, 'x', 42, { text: ' ' }, { turnOff: 'nope' }]) {
+ const n = composeAdmitNotice('a1b2c', provided);
+ expect(n.text).toBe('A1B2C joined automatically — your session settings let them in without approval.');
+ expect(n.turnOff).toBeNull();
+ }
+ });
+ it('caps what the provider can put on screen', () => {
+ const n = composeAdmitNotice('a1b2c', { text: 'x'.repeat(500), turnOffLabel: 'y'.repeat(80), turnOff() {} });
+ expect(n.text.length).toBe(200);
+ expect(n.turnOffLabel.length).toBe(24);
+ });
+ it('shortPeer is the six-char upper-case id the other join toasts use', () => {
+ expect(shortPeer('e6f54')).toBe('E6F54');
+ expect(shortPeer(/** @type {any} */ (undefined))).toBe('');
+ });
+});
+
+describe('F-5 Connect pill badge seam', () => {
+ it('normalises, caps and clears', () => {
+ setConnectBadge({ text: ' Public · guests join ', title: 'Anyone can join', onClick: () => {} });
+ const b = get(connectBadge);
+ expect(b?.text).toBe('Public · guests join');
+ expect(b?.title).toBe('Anyone can join');
+ expect(typeof b?.onClick).toBe('function');
+ setConnectBadge({ text: 'z'.repeat(99) });
+ expect(get(connectBadge)?.text.length).toBe(32);
+ expect(get(connectBadge)?.onClick).toBeNull();
+ for (const v of [null, {}, { text: '' }, 'Public']) {
+ setConnectBadge(v);
+ expect(get(connectBadge)).toBeNull();
+ }
+ });
+});
+
+describe('reports: Users peer menu seam', () => {
+ it('keeps only runnable, labelled items, capped', () => {
+ const run = () => {};
+ setPeerMenuItems([{ id: 'report', label: ' Report… ', run }, { label: 'no run' }, { label: '', run }, null]);
+ expect(get(peerMenuItems)).toEqual([{ id: 'report', label: 'Report…', run }]);
+ setPeerMenuItems(Array.from({ length: 12 }, (_, n) => ({ label: 'x' + n, run })));
+ expect(get(peerMenuItems).length).toBe(8);
+ setPeerMenuItems('junk');
+ expect(get(peerMenuItems)).toEqual([]);
+ });
+});
diff --git a/tests/unit/aiPhysicsDefault.test.js b/tests/unit/aiPhysicsDefault.test.js
new file mode 100644
index 000000000..df2fbbf68
--- /dev/null
+++ b/tests/unit/aiPhysicsDefault.test.js
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest';
+import { aiCreatePhysics } from '../../src/lib/ai/aiPhysicsDefault.js';
+
+// fix-live F-4 (test flight T8): AI-made objects stay put in Play unless asked for physics
+describe('F-4 AI create physics default', () => {
+ const stamped = { mode: 'dynamic', mass: 1 }; // what the Add-menu builder gives a Box
+ it('turns the builder\'s dynamic default to static when nothing was asked', () => {
+ expect(aiCreatePhysics({ kind: 'primitive', primitive: 'Box' }, stamped)).toEqual({ mode: 'static' });
+ expect(aiCreatePhysics({ physics: 'static' }, stamped)).toEqual({ mode: 'static' });
+ });
+ it('leaves objects the builder did not make dynamic alone (lights, groups, terrain)', () => {
+ expect(aiCreatePhysics({ kind: 'light' }, undefined)).toBeNull();
+ expect(aiCreatePhysics({}, { mode: 'auto' })).toBeNull();
+ });
+ it('keeps dynamic when asked, with the asked mass', () => {
+ expect(aiCreatePhysics({ physics: 'dynamic' }, stamped)).toBeNull(); // already dynamic, mass 1
+ expect(aiCreatePhysics({ physics: 'dynamic', mass: 4 }, stamped)).toEqual({ mode: 'dynamic', mass: 4 });
+ expect(aiCreatePhysics({ mass: 2.5 }, stamped)).toEqual({ mode: 'dynamic', mass: 2.5 });
+ expect(aiCreatePhysics({ physics: 'dynamic' }, undefined)).toEqual({ mode: 'dynamic', mass: 1 });
+ });
+ it('ignores a junk mass and caps a huge one', () => {
+ expect(aiCreatePhysics({ mass: -1 }, stamped)).toEqual({ mode: 'static' });
+ expect(aiCreatePhysics({ mass: 'heavy' }, stamped)).toEqual({ mode: 'static' });
+ expect(aiCreatePhysics({ mass: 1e9 }, stamped)).toEqual({ mode: 'dynamic', mass: 10000 });
+ });
+});
diff --git a/tests/unit/assetCacheCore.test.js b/tests/unit/assetCacheCore.test.js
new file mode 100644
index 000000000..41df5fda8
--- /dev/null
+++ b/tests/unit/assetCacheCore.test.js
@@ -0,0 +1,182 @@
+// @ts-nocheck — fixture indexes as plain literals; the module itself is typed
+import { describe, it, expect } from 'vitest';
+import {
+ stableStringify,
+ urlKey,
+ urlOfKey,
+ derivedKey,
+ emptyIndex,
+ normalizeIndex,
+ blobsOf,
+ usageOf,
+ planEviction,
+ orphanedBy,
+ DEFAULT_CAP_MB,
+ CAP_CHOICES_MB
+} from '../../src/lib/assetCacheCore.js';
+
+// 60 Q6: the local asset cache's pure half — cache keys, the index, the LRU plan.
+
+const H = (c) => c.repeat(64); // a sha-256-shaped hex
+const entry = (hash, bytes, lastUsed, kind = 'pack') => ({ hash, bytes, kind, lastUsed, at: 0 });
+
+describe('cache keys', () => {
+ it('url keys: absolute http(s) only, fragment dropped, optional version tag', () => {
+ expect(urlKey('https://cdn.example/p/a.glb')).toBe('url:https://cdn.example/p/a.glb');
+ expect(urlKey('https://cdn.example/p/a.glb#frag')).toBe('url:https://cdn.example/p/a.glb');
+ expect(urlKey('https://x.test/hdri/sky.hdr', '1.41.0')).toBe(
+ 'url:https://x.test/hdri/sky.hdr#v=1.41.0'
+ );
+ expect(urlKey('blob:https://x/1')).toBeNull();
+ expect(urlKey('data:,x')).toBeNull();
+ expect(urlKey('hdri/sky.hdr')).toBeNull();
+ expect(urlKey(undefined)).toBeNull();
+ });
+ it('urlOfKey inverts a url key (without its version)', () => {
+ expect(urlOfKey(urlKey('https://a/b.glb'))).toBe('https://a/b.glb');
+ expect(urlOfKey(urlKey('https://a/b.hdr', '9'))).toBe('https://a/b.hdr');
+ expect(urlOfKey('derived:texture:abc:{}')).toBeNull();
+ });
+ it('derived keys: content hash + kind + settings, field order irrelevant', () => {
+ const a = derivedKey('texture', H('a'), { maxTextureSize: 2048, textures: 'ktx2+fallback' });
+ const b = derivedKey('texture', H('A'), { textures: 'ktx2+fallback', maxTextureSize: 2048 });
+ expect(a).toBe(b);
+ expect(a.startsWith('derived:texture:' + H('a') + ':')).toBe(true);
+ expect(
+ derivedKey('texture', H('a'), { maxTextureSize: 1024, textures: 'ktx2+fallback' })
+ ).not.toBe(a);
+ expect(
+ derivedKey('texture', H('b'), { maxTextureSize: 2048, textures: 'ktx2+fallback' })
+ ).not.toBe(a);
+ expect(derivedKey('texture', 'not-a-hash', {})).toBeNull();
+ expect(derivedKey('', H('a'), {})).toBeNull();
+ });
+ it('stableStringify sorts keys at every depth and drops undefined fields', () => {
+ expect(stableStringify({ b: 1, a: { d: [2, { z: 1, y: 2 }], c: undefined } })).toBe(
+ '{"a":{"d":[2,{"y":2,"z":1}]},"b":1}'
+ );
+ expect(stableStringify(null)).toBe('null');
+ expect(stableStringify(undefined)).toBe('null');
+ });
+});
+
+describe('normalizeIndex', () => {
+ it('keeps good rows, drops malformed ones and unknown versions', () => {
+ const raw = {
+ v: 1,
+ entries: {
+ ok: {
+ hash: H('a'),
+ bytes: 10,
+ kind: 'sky',
+ lastUsed: 5,
+ at: 1,
+ type: 'image/vnd.radiance',
+ meta: { r: 1 }
+ },
+ badHash: { hash: 'zz', bytes: 1, kind: 'pack' },
+ badBytes: { hash: H('b'), bytes: -1, kind: 'pack' },
+ oddKind: { hash: H('c'), bytes: 3, kind: 'weird' }
+ }
+ };
+ const idx = normalizeIndex(raw);
+ expect(Object.keys(idx.entries).sort()).toEqual(['oddKind', 'ok']);
+ expect(idx.entries.ok).toEqual({
+ hash: H('a'),
+ bytes: 10,
+ kind: 'sky',
+ type: 'image/vnd.radiance',
+ lastUsed: 5,
+ at: 1,
+ meta: { r: 1 }
+ });
+ expect(idx.entries.oddKind.kind).toBe('import');
+ expect(normalizeIndex({ v: 2, entries: raw.entries })).toEqual(emptyIndex());
+ expect(normalizeIndex(null)).toEqual(emptyIndex());
+ expect(normalizeIndex('junk')).toEqual(emptyIndex());
+ });
+});
+
+describe('usage', () => {
+ it('counts a blob shared by two keys ONCE', () => {
+ const idx = {
+ v: 1,
+ entries: {
+ k1: entry(H('a'), 100, 1),
+ k2: entry(H('a'), 100, 2, 'import'),
+ k3: entry(H('b'), 50, 3, 'sky')
+ }
+ };
+ expect(blobsOf(idx).get(H('a'))).toEqual({ bytes: 100, refs: 2 });
+ const u = usageOf(idx);
+ expect(u).toEqual({
+ bytes: 150,
+ keys: 3,
+ blobs: 2,
+ byKind: { pack: 100, sky: 50, texture: 0, import: 0 }
+ });
+ });
+});
+
+describe('planEviction (LRU)', () => {
+ const idx = () => ({
+ v: 1,
+ entries: {
+ old: entry(H('a'), 400, 10),
+ mid: entry(H('b'), 300, 20),
+ fresh: entry(H('c'), 300, 30)
+ }
+ });
+ it('does nothing under the cap', () => {
+ expect(planEviction(idx(), 1000)).toEqual({ keys: [], hashes: [], bytesAfter: 1000 });
+ });
+ it('drops the least recently used first until it fits', () => {
+ expect(planEviction(idx(), 700)).toEqual({ keys: ['old'], hashes: [H('a')], bytesAfter: 600 });
+ expect(planEviction(idx(), 350)).toEqual({
+ keys: ['old', 'mid'],
+ hashes: [H('a'), H('b')],
+ bytesAfter: 300
+ });
+ });
+ it('never drops a protected key (the one just written), even when it alone is over the cap', () => {
+ expect(planEviction(idx(), 100, ['fresh'])).toEqual({
+ keys: ['old', 'mid'],
+ hashes: [H('a'), H('b')],
+ bytesAfter: 300
+ });
+ });
+ it('a shared blob is freed only when its LAST key goes', () => {
+ const shared = {
+ v: 1,
+ entries: { a1: entry(H('a'), 500, 1), a2: entry(H('a'), 500, 50), b: entry(H('b'), 100, 10) }
+ };
+ // a1 goes first but frees nothing (a2 still names the blob); b goes next and frees 100
+ expect(planEviction(shared, 550)).toEqual({
+ keys: ['a1', 'b'],
+ hashes: [H('b')],
+ bytesAfter: 500
+ });
+ });
+ it('is deterministic on equal timestamps (key order)', () => {
+ const tie = { v: 1, entries: { z: entry(H('a'), 10, 1), y: entry(H('b'), 10, 1) } };
+ expect(planEviction(tie, 10).keys).toEqual(['y']);
+ });
+});
+
+describe('orphanedBy', () => {
+ it('names only the blobs no remaining key refers to', () => {
+ const idx = {
+ v: 1,
+ entries: { a1: entry(H('a'), 5, 1), a2: entry(H('a'), 5, 1), b: entry(H('b'), 5, 1) }
+ };
+ expect(orphanedBy(idx, ['a1'])).toEqual([]);
+ expect(orphanedBy(idx, ['a1', 'a2', 'b']).sort()).toEqual([H('a'), H('b')]);
+ expect(orphanedBy(idx, ['missing'])).toEqual([]);
+ });
+});
+
+describe('the cap choices', () => {
+ it('offer the default', () => {
+ expect(CAP_CHOICES_MB).toContain(DEFAULT_CAP_MB);
+ });
+});
diff --git a/tests/unit/batteryDesk.test.js b/tests/unit/batteryDesk.test.js
new file mode 100644
index 000000000..2ac0d0324
--- /dev/null
+++ b/tests/unit/batteryDesk.test.js
@@ -0,0 +1,100 @@
+// 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([]);
+ });
+});
+
+describe('battery-desk import: an old battery.sh run', () => {
+ it('takes the FAIL lines written under each row, and a rerun as the retry', () => {
+ const fs = require('node:fs');
+ const os = require('node:os');
+ const path = require('node:path');
+ const { execFileSync } = require('node:child_process');
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-'));
+ fs.writeFileSync(path.join(dir, 'u1.txt'), 'snapshot: x\nok-suite rc=0 pass~3 fail~0 9s :: ALL PASS\nred rc=1 pass~2 fail~1 9s :: 1 FAILURES\n FAIL the toast hides after 1200 ms\nwobbly rc=1 pass~1 fail~1 5s :: 1 FAILURES\n FAIL a flaky check\nBATTERY-DONE\n');
+ fs.writeFileSync(path.join(dir, 'r2.txt'), 'snapshot: x\nwobbly rc=0 pass~2 fail~0 5s :: ALL PASS\n');
+ // a later rerun's green log in the same folder must not hide the red row's own lines
+ fs.writeFileSync(path.join(dir, 'red.log'), 'PASS everything\nALL PASS\n');
+ const cli = path.resolve('scripts/ci/battery-desk.cjs');
+ execFileSync('node', [cli, 'import', path.join(dir, 'u1.txt'), '--logs', dir, '--retry', path.join(dir, 'r2.txt'), '--name', 'u1', '--out', dir]);
+ const run = JSON.parse(fs.readFileSync(path.join(dir, 'u1.json'), 'utf8'));
+ const by = Object.fromEntries(run.results.map((/** @type {{name: string}} */ r) => [r.name, r]));
+ expect(by['ok-suite'].status).toBe('pass');
+ expect(by.red.status).toBe('red');
+ expect(by.red.fps).toEqual(['red :: the toast hides after # ms']);
+ expect(by.wobbly.status).toBe('flaky');
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+});
diff --git a/tests/unit/claudeIndex.test.js b/tests/unit/claudeIndex.test.js
new file mode 100644
index 000000000..0234fab10
--- /dev/null
+++ b/tests/unit/claudeIndex.test.js
@@ -0,0 +1,20 @@
+// pipeline-1: CLAUDE-index.md (the line index of CLAUDE.md) is what scripts/claude-index.cjs writes.
+import { describe, it, expect } from 'vitest';
+import { createRequire } from 'node:module';
+import fs from 'node:fs';
+
+const require = createRequire(import.meta.url);
+const { build, title } = require('../../scripts/claude-index.cjs');
+const root = new URL('../../', import.meta.url).pathname;
+
+describe('claude-index', () => {
+ it('CLAUDE-index.md is current', () => {
+ const fresh = build(fs.readFileSync(root + 'CLAUDE.md', 'utf8')) === fs.readFileSync(root + 'CLAUDE-index.md', 'utf8');
+ expect(fresh, 'CLAUDE-index.md is stale: run `node scripts/claude-index.cjs` (after editing CLAUDE.md, or on a merge conflict in the index)').toBe(true);
+ });
+ it('names an entry by its bold lead, else its first words', () => {
+ expect(title('**`git checkout --theirs`** discards the ours side')).toBe('`git checkout --theirs`');
+ expect(title('`src/lib/sim/` (36-sim): buoyancy')).toBe('`src/lib/sim/` (36-sim): buoyancy');
+ expect(build('# T\n\n## S\n- **A** b\n · **C** d\n')).toContain(' 4 - A\n 5 · C');
+ });
+});
diff --git a/tests/unit/cloudHooksFloor.test.js b/tests/unit/cloudHooksFloor.test.js
index 6b95c1920..bd9c1951a 100644
--- a/tests/unit/cloudHooksFloor.test.js
+++ b/tests/unit/cloudHooksFloor.test.js
@@ -41,6 +41,26 @@ describe('cloudHooks ALWAYS_ALLOWED floor', () => {
for (const t of sent) expect(canApply('v', t), t).toBe(true);
});
+ it('lets a viewer request the project manifest, an asset and a material preset (fix-live)', () => {
+ setCapabilityProvider(() => false);
+ for (const t of ['getproject', 'getasset', 'getmatpreset']) expect(canApply('v', t), t).toBe(true);
+ });
+
+ it('keeps those REPLIES gateable (fix-live)', () => {
+ setCapabilityProvider(() => false);
+ for (const t of ['manifest', 'assetfile', 'assetstart', 'assetchunk', 'assetmissing', 'matpresetpart']) expect(canApply('v', t), t).toBe(false);
+ });
+
+ it('has every get* REQUEST the dispatcher answers on the floor, bar the recorded few', () => {
+ const src = readFileSync(new URL('../../src/lib/peerHandler.svelte.js', import.meta.url), 'utf8');
+ const answered = [...new Set([...src.matchAll(/data\.type == '(get[a-z]+)'/g)].map((m) => m[1]))];
+ expect(answered.length).toBeGreaterThan(10);
+ // still off the floor, recorded for the orchestrator (fix-live STATUS): each needs its own look
+ const KNOWN_OFF = ['gettransport', 'getgame', 'getkitentities', 'getpatch'];
+ setCapabilityProvider(() => false);
+ for (const t of answered) expect(canApply('v', t), t).toBe(!KNOWN_OFF.includes(t));
+ });
+
it('allows everything with no provider (the OSS build)', () => {
expect(canApply('v', 'seqs')).toBe(true);
});
diff --git a/tests/unit/depsCi.test.js b/tests/unit/depsCi.test.js
index 985ea1a54..64af4f546 100644
--- a/tests/unit/depsCi.test.js
+++ b/tests/unit/depsCi.test.js
@@ -39,6 +39,15 @@ describe('deps-policy: one class per PR, the most careful part wins', () => {
expect(classify([dep('ws', 'direct:production', 'patch', '/tools/agent')])).toMatchObject({ cls: 'auto', needsPc: false });
expect(classify([]).cls).toBe('ready');
});
+ it('runs the battery only for a non-major change to the app root', () => {
+ expect(classify([dep('three', 'direct:production', 'patch')]).battery).toBe(true);
+ expect(classify([dep('prettier', 'direct:development', 'minor')]).battery).toBe(true);
+ expect(classify([dep('@sveltejs/kit', 'direct:development', 'major')]).battery).toBe(false);
+ expect(classify([dep('actions/checkout', 'direct:production', 'minor', '/', 'github_actions')]).battery).toBe(false);
+ expect(classify([dep('ws', 'direct:production', 'minor', '/tools/agent')]).battery).toBe(false);
+ expect(classify([dep('ws', 'direct:production', 'patch', '/tools/agent'), dep('three', 'direct:production', 'patch')]).battery).toBe(true);
+ expect(classify([]).battery).toBe(true);
+ });
});
/** @param {Record} suites @param {Record} [skip] */
diff --git a/tests/unit/dialBackQueue.test.js b/tests/unit/dialBackQueue.test.js
new file mode 100644
index 000000000..6bc5e3672
--- /dev/null
+++ b/tests/unit/dialBackQueue.test.js
@@ -0,0 +1,47 @@
+import { describe, it, expect } from 'vitest';
+import { createDialBackQueue, DIAL_BACK_QUEUE_CAP } from '../../src/lib/dialBackQueue.js';
+
+// scatter-sync: the host's room broadcasts held for a joiner whose approval dial-back has not
+// opened yet, flushed in order after its handshake.
+
+describe('dialBackQueue', () => {
+ it('flushes in order, once, per peer', () => {
+ const q = createDialBackQueue();
+ q.hold('b', { type: 'create', uuid: '1' });
+ q.hold('c', { type: 'create', uuid: 'x' });
+ q.hold('b', { type: 'objectParameters', uuid: '1' });
+ expect(q.size('b')).toBe(2);
+ expect(q.take('b').map((p) => p.type)).toEqual(['create', 'objectParameters']);
+ expect(q.take('b')).toEqual([]);
+ expect(q.take('c')).toEqual([{ type: 'create', uuid: 'x' }]);
+ });
+
+ it('abandons the WHOLE queue past the cap, and holds nothing more for that peer', () => {
+ const q = createDialBackQueue({ cap: 3 });
+ for (let i = 0; i < 3; i++) expect(q.hold('b', { i })).toBe(true);
+ expect(q.hold('b', { i: 3 })).toBe(false);
+ expect(q.overflowed('b')).toBe(true);
+ expect(q.hold('b', { i: 4 })).toBe(false);
+ expect(q.take('b')).toEqual([]);
+ // a later dial-back starts clean
+ expect(q.hold('b', { i: 5 })).toBe(true);
+ expect(q.take('b')).toEqual([{ i: 5 }]);
+ });
+
+ it('does not flush what outlived the ttl', () => {
+ let t = 1000;
+ const q = createDialBackQueue({ ttlMs: 100, now: () => t });
+ q.hold('b', { n: 'old' });
+ t = 1150;
+ q.hold('b', { n: 'new' });
+ expect(q.take('b')).toEqual([{ n: 'new' }]);
+ });
+
+ it('drop forgets a peer', () => {
+ const q = createDialBackQueue();
+ q.hold('b', { n: 1 });
+ q.drop('b');
+ expect(q.take('b')).toEqual([]);
+ expect(DIAL_BACK_QUEUE_CAP).toBeGreaterThan(100);
+ });
+});
diff --git a/tests/unit/fastChannel.test.js b/tests/unit/fastChannel.test.js
new file mode 100644
index 000000000..8c7146cb3
--- /dev/null
+++ b/tests/unit/fastChannel.test.js
@@ -0,0 +1,242 @@
+// @ts-nocheck — fake RTCPeerConnection / timer fixtures; the module itself is typed
+import { describe, it, expect } from 'vitest';
+import {
+ poseKey,
+ stampPose,
+ nextPoseSeq,
+ createStaleFilter,
+ createKeyframer,
+ createFastLinks,
+ FAST_CHANNEL_ID,
+ FAST_MAX_BYTES,
+ FAST_MAX_BUFFERED
+} from '../../src/lib/fastChannel.js';
+
+// 60 Q2: the fast movement channel's pure parts — keys, sequence numbers, the stale filter,
+// the trailing keyframe and the channel bookkeeping (with a fake RTCPeerConnection).
+
+describe('poseKey', () => {
+ it('keys camera/hands per sender stream and moves per object', () => {
+ expect(poseKey({ type: 'camera', peerId: 'a' })).toBe('camera');
+ expect(poseKey({ type: 'vrhands' })).toBe('vrhands');
+ expect(poseKey({ type: 'move', uuid: 'u1' })).toBe('move:u1');
+ });
+ it('answers null for everything that is not a latest-wins pose', () => {
+ expect(poseKey({ type: 'object' })).toBeNull();
+ expect(poseKey({ type: 'move' })).toBeNull();
+ expect(poseKey(null)).toBeNull();
+ });
+});
+
+describe('stampPose', () => {
+ it('stamps poses with a monotonic ps, once', () => {
+ const a = stampPose({ type: 'move', uuid: 'x' });
+ const b = stampPose({ type: 'camera' });
+ expect(b.ps).toBeGreaterThan(a.ps);
+ const keep = a.ps;
+ stampPose(a); // a keyframe re-send keeps its original number
+ expect(a.ps).toBe(keep);
+ });
+ it('leaves non-pose payloads untouched', () => {
+ const edit = { type: 'object', uuid: 'x' };
+ stampPose(edit);
+ expect('ps' in edit).toBe(false);
+ });
+ it('starts above the clock x100 so a reloaded sender outruns its previous page', () => {
+ expect(nextPoseSeq()).toBeGreaterThan((Date.now() - 60_000) * 100);
+ });
+});
+
+describe('createStaleFilter', () => {
+ it('drops a pose older than (or equal to) the newest applied for that sender + key', () => {
+ const f = createStaleFilter();
+ expect(f.accept('A', { type: 'camera', ps: 10 })).toBe(true);
+ expect(f.accept('A', { type: 'camera', ps: 9 })).toBe(false); // overtaken datagram
+ expect(f.accept('A', { type: 'camera', ps: 10 })).toBe(false); // keyframe duplicate
+ expect(f.accept('A', { type: 'camera', ps: 11 })).toBe(true);
+ expect(f.stats).toEqual({ accepted: 2, stale: 2 });
+ });
+ it('keeps keys apart: per object, per sender, per type', () => {
+ const f = createStaleFilter();
+ expect(f.accept('A', { type: 'move', uuid: 'u1', ps: 50 })).toBe(true);
+ expect(f.accept('A', { type: 'move', uuid: 'u2', ps: 5 })).toBe(true);
+ expect(f.accept('B', { type: 'move', uuid: 'u1', ps: 5 })).toBe(true);
+ expect(f.accept('A', { type: 'camera', ps: 5 })).toBe(true);
+ });
+ it('a late streamed move loses to the committed move that ended the drag', () => {
+ const f = createStaleFilter();
+ const streamed = { type: 'move', uuid: 'u', ps: 100 };
+ const committed = { type: 'move', uuid: 'u', ps: 101 };
+ expect(f.accept('A', committed)).toBe(true);
+ expect(f.accept('A', streamed)).toBe(false);
+ });
+ it('always accepts an unstamped message (an older build) and records nothing', () => {
+ const f = createStaleFilter();
+ expect(f.accept('A', { type: 'move', uuid: 'u' })).toBe(true);
+ expect(f.accept('A', { type: 'move', uuid: 'u' })).toBe(true);
+ expect(f.size()).toBe(0);
+ expect(f.accept('A', { type: 'object', ps: 1 })).toBe(true); // not a pose
+ });
+ it('forgets a peer on disconnect (a fresh page may restart lower)', () => {
+ const f = createStaleFilter();
+ f.accept('A', { type: 'camera', ps: 100 });
+ f.accept('B', { type: 'camera', ps: 100 });
+ f.dropPeer('A');
+ expect(f.accept('A', { type: 'camera', ps: 1 })).toBe(true);
+ expect(f.accept('B', { type: 'camera', ps: 1 })).toBe(false);
+ });
+});
+
+/** a manual clock for the keyframer */
+function fakeTimers() {
+ let now = 0;
+ const timers = new Map();
+ let id = 0;
+ return {
+ setTimer: (fn, ms) => (timers.set(++id, { fn, at: now + ms }), id),
+ clearTimer: (t) => timers.delete(t),
+ advance(ms) {
+ now += ms;
+ for (const [t, v] of [...timers]) if (v.at <= now) (timers.delete(t), v.fn());
+ },
+ pending: () => timers.size
+ };
+}
+
+describe('createKeyframer', () => {
+ it('re-sends the LAST fast pose once the key goes quiet, to the peers that got it fast', () => {
+ const t = fakeTimers();
+ const sent = [];
+ const k = createKeyframer((p, peers) => sent.push([p.ps, peers]), { delay: 250, ...t });
+ k.noteFast('camera', { ps: 1 }, ['B']);
+ t.advance(100);
+ k.noteFast('camera', { ps: 2 }, ['B', 'C']);
+ t.advance(200);
+ expect(sent).toEqual([]); // still streaming
+ t.advance(60);
+ expect(sent).toEqual([[2, ['B', 'C']]]);
+ t.advance(1000);
+ expect(sent.length).toBe(1); // once
+ });
+ it('a reliable send of the same key supersedes the keyframe', () => {
+ const t = fakeTimers();
+ const sent = [];
+ const k = createKeyframer((p) => sent.push(p), { delay: 250, ...t });
+ k.noteFast('move:u', { ps: 1 }, ['B']);
+ k.noteReliable('move:u');
+ t.advance(1000);
+ expect(sent).toEqual([]);
+ expect(t.pending()).toBe(0);
+ });
+ it('keys are independent; a departed peer is owed nothing; clear disarms all', () => {
+ const t = fakeTimers();
+ const sent = [];
+ const k = createKeyframer((p, peers) => sent.push([p.k, peers]), { delay: 10, ...t });
+ k.noteFast('move:a', { k: 'a' }, ['B', 'C']);
+ k.noteFast('move:b', { k: 'b' }, ['B']);
+ k.dropPeer('C');
+ t.advance(20);
+ expect(sent).toEqual([
+ ['a', ['B']],
+ ['b', ['B']]
+ ]);
+ k.noteFast('move:c', { k: 'c' }, ['B']);
+ k.clear();
+ t.advance(20);
+ expect(sent.length).toBe(2);
+ });
+});
+
+/** a fake RTCPeerConnection whose channels open on demand */
+function fakePc() {
+ const made = [];
+ return {
+ made,
+ createDataChannel(label, init) {
+ const dc = {
+ label,
+ init,
+ readyState: 'connecting',
+ sent: [],
+ send(t) {
+ this.sent.push(t);
+ },
+ close() {
+ this.readyState = 'closed';
+ this.onclose?.();
+ }
+ };
+ made.push(dc);
+ return dc;
+ }
+ };
+}
+
+describe('createFastLinks', () => {
+ it('creates ONE negotiated unordered, no-retransmit channel per RTCPeerConnection', () => {
+ const links = createFastLinks();
+ const pc = fakePc();
+ const conn = { peerConnection: pc };
+ expect(links.open('B', conn, () => {})).toBe(true);
+ expect(links.open('B', conn, () => {})).toBe(true); // idempotent
+ expect(pc.made.length).toBe(1);
+ expect(pc.made[0].init).toEqual({
+ ordered: false,
+ maxRetransmits: 0,
+ negotiated: true,
+ id: FAST_CHANNEL_ID
+ });
+ });
+ it('falls back (false) until the channel is open, and for an oversized message', () => {
+ const links = createFastLinks();
+ const pc = fakePc();
+ links.open('B', { peerConnection: pc }, () => {});
+ expect(links.trySend('B', '{}')).toBe(false); // connecting
+ pc.made[0].readyState = 'open';
+ pc.made[0].onopen?.();
+ expect(links.trySend('B', '{"type":"camera"}')).toBe(true);
+ expect(links.trySend('B', 'x'.repeat(FAST_MAX_BYTES + 1))).toBe(false);
+ expect(links.trySend('Z', '{}')).toBe(false); // no channel to that peer
+ expect(links.stats.tooBig).toBe(1);
+ });
+ it('skips (true, not sent) behind a send backlog — a stale pose must not queue', () => {
+ const links = createFastLinks();
+ const pc = fakePc();
+ links.open('B', { peerConnection: pc }, () => {});
+ pc.made[0].readyState = 'open';
+ pc.made[0].bufferedAmount = FAST_MAX_BUFFERED + 1;
+ expect(links.trySend('B', '{}')).toBe(true);
+ expect(pc.made[0].sent).toEqual([]);
+ expect(links.stats.congested).toBe(1);
+ });
+ it('a newer connection replaces the old channel; a conn without an RTCPeerConnection is refused', () => {
+ const links = createFastLinks();
+ const pc1 = fakePc();
+ const pc2 = fakePc();
+ links.open('B', { peerConnection: pc1 }, () => {});
+ links.open('B', { peerConnection: pc2 }, () => {});
+ expect(pc1.made[0].readyState).toBe('closed');
+ expect(links.peers()).toEqual(['B']);
+ expect(links.open('C', {}, () => {})).toBe(false);
+ });
+ it('parses JSON text into the receive path and ignores junk', () => {
+ const links = createFastLinks();
+ const pc = fakePc();
+ const got = [];
+ links.open('B', { peerConnection: pc }, (d) => got.push(d));
+ pc.made[0].onmessage({ data: '{"type":"move","uuid":"u","ps":3}' });
+ pc.made[0].onmessage({ data: 'not json' });
+ pc.made[0].onmessage({ data: new ArrayBuffer(2) });
+ expect(got).toEqual([{ type: 'move', uuid: 'u', ps: 3 }]);
+ expect(links.stats.parseErrors).toBe(1);
+ });
+ it('close / closeAll drop the links', () => {
+ const links = createFastLinks();
+ links.open('B', { peerConnection: fakePc() }, () => {});
+ links.open('C', { peerConnection: fakePc() }, () => {});
+ links.close('B');
+ expect(links.peers()).toEqual(['C']);
+ links.closeAll();
+ expect(links.peers()).toEqual([]);
+ });
+});
diff --git a/tests/unit/lastingToast.test.js b/tests/unit/lastingToast.test.js
new file mode 100644
index 000000000..13a11e3fb
--- /dev/null
+++ b/tests/unit/lastingToast.test.js
@@ -0,0 +1,35 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { get } from 'svelte/store';
+import { showToast, showLastingToast, toastStore, notifications, notificationActions } from '../../src/stores/appStore.js';
+
+// fix-live F-2 + F-5: a decision toast whose buttons survive in the notification history
+describe('lasting toasts', () => {
+ beforeEach(() => {
+ toastStore.set([]);
+ notifications.set([]);
+ notificationActions.set({});
+ });
+ it('showToast(…, {keep}) keeps working buttons on the history entry (the role offer)', () => {
+ let accepted = 0;
+ showToast('You have been offered the ADMIN role by AB12C. Accept?', [{ label: 'Accept', action: () => accepted++ }], { keep: true });
+ const entry = get(notifications).at(-1);
+ const acts = get(notificationActions)[entry.id];
+ expect(acts?.map((a) => a.label)).toEqual(['Accept']);
+ acts[0].action();
+ expect(accepted).toBe(1);
+ expect(get(notificationActions)[entry.id]).toBeUndefined(); // answered: the buttons go
+ });
+ it('pressing the TOAST button also clears the history buttons', () => {
+ let n = 0;
+ const id = showLastingToast('X joined automatically', [{ label: 'Remove', action: () => n++ }]);
+ const card = /** @type {any} */ (get(toastStore).at(-1));
+ card.actions[0].action();
+ expect(n).toBe(1);
+ expect(get(notificationActions)[id]).toBeUndefined();
+ });
+ it('a plain action toast (no keep) is unchanged: history text only', () => {
+ showToast('Undo?', [{ label: 'Undo', action: () => {} }]);
+ expect(Object.keys(get(notificationActions))).toEqual([]);
+ expect(get(notifications).at(-1).kind).toBe('action');
+ });
+});
diff --git a/tests/unit/launchInbox.test.js b/tests/unit/launchInbox.test.js
new file mode 100644
index 000000000..73cf989fc
--- /dev/null
+++ b/tests/unit/launchInbox.test.js
@@ -0,0 +1,243 @@
+// 60 Q4 — share target / file handlers: the manifest keys, the service worker's share
+// inbox (static/sw.js run in a fake worker scope), the app's inbox reader and the router's
+// classification. The browser halves (an installed PWA receiving a share, launchQueue) are
+// the e2e suite `launch-inbox` and the owner rows.
+import { describe, expect, it } from 'vitest';
+import { readFileSync } from 'node:fs';
+import {
+ SHARE_INBOX,
+ SHARE_PARAM,
+ classifyLaunchFiles,
+ readShareInbox
+} from '../../src/lib/launchInbox.js';
+
+const manifest = JSON.parse(
+ readFileSync(new URL('../../static/manifest.webmanifest', import.meta.url), 'utf8')
+);
+const swSource = readFileSync(new URL('../../static/sw.js', import.meta.url), 'utf8');
+
+/** a CacheStorage good enough for both halves */
+function fakeCaches() {
+ /** @type {Map>} */
+ const stores = new Map();
+ const open = async (/** @type {string} */ name) => {
+ if (!stores.has(name)) stores.set(name, new Map());
+ const m = /** @type {Map} */ (stores.get(name));
+ return {
+ put: async (/** @type {Request} */ req, /** @type {Response} */ res) =>
+ void m.set(new URL(req.url, 'https://app.test').pathname, res),
+ keys: async () => [...m.keys()].map((p) => new Request('https://app.test' + p)),
+ match: async (/** @type {Request} */ req) => m.get(new URL(req.url).pathname)?.clone()
+ };
+ };
+ return { stores, open, delete: async (/** @type {string} */ name) => stores.delete(name) };
+}
+
+/** run static/sw.js in a fake ServiceWorkerGlobalScope and hand back its fetch listener
+ * @param {any} caches */
+function loadWorker(caches) {
+ /** @type {Record} */
+ const listeners = {};
+ const self = {
+ location: new URL('https://app.test/sw.js'),
+ addEventListener: (/** @type {string} */ t, /** @type {Function} */ fn) => (listeners[t] = fn),
+ skipWaiting() {},
+ clients: { claim() {} }
+ };
+ let fetched = 0;
+ const fetchStub = async () => (fetched++, new Response('net'));
+ new Function('self', 'caches', 'fetch', swSource)(self, caches, fetchStub);
+ return { fetch: listeners.fetch, fetched: () => fetched };
+}
+
+/** @param {any} listener @param {Request} request */
+async function dispatch(listener, request) {
+ /** @type {Promise | null} */
+ let answer = null;
+ listener({
+ request,
+ respondWith: (/** @type {Promise} */ p) => (answer = Promise.resolve(p))
+ });
+ return /** @type {any} */ (answer);
+}
+
+describe('manifest (60 Q4)', () => {
+ it('share_target POSTs multipart files to the path the worker takes', () => {
+ const st = manifest.share_target;
+ expect(st.action).toBe('/share-target');
+ expect(st.method).toBe('POST');
+ expect(st.enctype).toBe('multipart/form-data');
+ expect(swSource).toContain(`const SHARE_PATH = '${st.action}'`);
+ const files = st.params.files;
+ expect(files).toHaveLength(1);
+ expect(files[0].name).toBe('files');
+ expect(swSource).toMatch(/form\s*\.getAll\('files'\)/);
+ for (const a of [
+ 'image/*',
+ '.glb',
+ '.gltf',
+ '.tpscene',
+ 'model/gltf-binary',
+ 'model/gltf+json'
+ ])
+ expect(files[0].accept).toContain(a);
+ });
+ it('file_handlers open .tpscene, .glb and .gltf in the app', () => {
+ const exts = manifest.file_handlers.flatMap((/** @type {any} */ h) =>
+ Object.values(h.accept).flat()
+ );
+ expect(exts.sort()).toEqual(['.glb', '.gltf', '.tpscene']);
+ for (const h of manifest.file_handlers) expect(h.action.startsWith('/')).toBe(true);
+ });
+ it('launch_handler focuses the window already open', () => {
+ expect(manifest.launch_handler.client_mode[0]).toBe('focus-existing');
+ });
+ it('the worker and the app agree on the inbox name and the param', () => {
+ expect(swSource).toContain(`const SHARE_INBOX = '${SHARE_INBOX}'`);
+ expect(swSource).toContain(`const SHARE_PARAM = '${SHARE_PARAM}'`);
+ });
+});
+
+describe('sw.js share inbox', () => {
+ const share = (/** @type {FormData} */ form) =>
+ new Request('https://app.test/share-target', { method: 'POST', body: form });
+
+ it('parks every shared file and redirects to /?shared=', async () => {
+ const caches = fakeCaches();
+ const sw = loadWorker(caches);
+ const form = new FormData();
+ form.append('title', 'look');
+ form.append(
+ 'files',
+ new File([new Uint8Array([1, 2, 3])], 'chair model.glb', { type: 'model/gltf-binary' })
+ );
+ form.append('files', new File(['png'], 'ref.png', { type: 'image/png' }));
+ const res = await dispatch(sw.fetch, share(form));
+ expect(res.status).toBe(303);
+ expect(
+ new URL(/** @type {string} */ (res.headers.get('Location'))).pathname +
+ new URL(/** @type {string} */ (res.headers.get('Location'))).search
+ ).toBe('/?shared=2');
+ expect(sw.fetched()).toBe(0);
+ const inbox = /** @type {Map} */ (caches.stores.get(SHARE_INBOX));
+ expect([...inbox.keys()]).toEqual(['/share-inbox/0', '/share-inbox/1']);
+ const first = /** @type {Response} */ (inbox.get('/share-inbox/0'));
+ expect(decodeURIComponent(/** @type {string} */ (first.headers.get('X-File-Name')))).toBe(
+ 'chair model.glb'
+ );
+ expect(new Uint8Array(await first.clone().arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]));
+ });
+
+ it('a text-only share opens the app and parks nothing', async () => {
+ const caches = fakeCaches();
+ const sw = loadWorker(caches);
+ const form = new FormData();
+ form.append('url', 'https://example.com');
+ const res = await dispatch(sw.fetch, share(form));
+ expect(res.status).toBe(303);
+ expect(new URL(/** @type {string} */ (res.headers.get('Location'))).search).toBe('');
+ expect(caches.stores.has(SHARE_INBOX)).toBe(false);
+ });
+
+ it('a new share replaces an uncollected one', async () => {
+ const caches = fakeCaches();
+ const sw = loadWorker(caches);
+ const a = new FormData();
+ a.append('files', new File(['a'], 'a.png'));
+ a.append('files', new File(['b'], 'b.png'));
+ await dispatch(sw.fetch, share(a));
+ const b = new FormData();
+ b.append('files', new File(['c'], 'c.glb'));
+ await dispatch(sw.fetch, share(b));
+ expect([
+ .../** @type {Map} */ (caches.stores.get(SHARE_INBOX)).keys()
+ ]).toEqual(['/share-inbox/0']);
+ });
+
+ it('everything else still goes straight to the network (no shell cache)', async () => {
+ const caches = fakeCaches();
+ const sw = loadWorker(caches);
+ await dispatch(sw.fetch, new Request('https://app.test/'));
+ await dispatch(sw.fetch, new Request('https://app.test/other', { method: 'POST', body: 'x' }));
+ expect(sw.fetched()).toBe(2);
+ expect(caches.stores.size).toBe(0);
+ });
+
+ it('the round trip: what the worker parks, the app reads back once', async () => {
+ const caches = fakeCaches();
+ const sw = loadWorker(caches);
+ const form = new FormData();
+ form.append('files', new File(['scene-bytes'], 'Yard.tpscene'));
+ form.append('files', new File(['img'], 'photo 1.jpg', { type: 'image/jpeg' }));
+ const res = await dispatch(sw.fetch, share(form));
+ const loc = new URL(/** @type {string} */ (res.headers.get('Location')));
+ /** @type {string[]} */
+ const replaced = [];
+ const env = {
+ caches: /** @type {any} */ (caches),
+ location: /** @type {any} */ ({ href: loc.href + '#peer', search: loc.search }),
+ history: /** @type {any} */ ({
+ state: null,
+ replaceState: (
+ /** @type {any} */ _s,
+ /** @type {string} */ _t,
+ /** @type {string} */ url
+ ) => replaced.push(url)
+ })
+ };
+ const files = await readShareInbox(env);
+ expect(files.map((f) => f.name)).toEqual(['Yard.tpscene', 'photo 1.jpg']);
+ expect(await files[0].text()).toBe('scene-bytes');
+ expect(files[1].type).toBe('image/jpeg');
+ expect(replaced).toEqual(['/#peer']);
+ expect(caches.stores.has(SHARE_INBOX)).toBe(false);
+ // a second read (a reload with the param still there) finds nothing
+ expect(await readShareInbox(env)).toEqual([]);
+ });
+
+ it('no ?shared= param = the inbox is not touched', async () => {
+ const caches = fakeCaches();
+ await (
+ await caches.open(SHARE_INBOX)
+ ).put(new Request('https://app.test/share-inbox/0'), new Response('x'));
+ const files = await readShareInbox({
+ caches: /** @type {any} */ (caches),
+ location: /** @type {any} */ ({ href: 'https://app.test/', search: '' })
+ });
+ expect(files).toEqual([]);
+ expect(caches.stores.has(SHARE_INBOX)).toBe(true);
+ });
+});
+
+describe('classifyLaunchFiles', () => {
+ const f = (/** @type {string} */ name, type = '') => ({ name, type });
+ const names = (/** @type {any[]} */ xs) => xs.map((x) => x.name);
+
+ it('scenes open, models import, a lone image goes to the library', () => {
+ const c = classifyLaunchFiles([
+ f('Yard.tpscene'),
+ f('chair.GLB'),
+ f('tree.gltf'),
+ f('notes.txt')
+ ]);
+ expect(names(c.scenes)).toEqual(['Yard.tpscene']);
+ expect(names(c.models)).toEqual(['chair.GLB', 'tree.gltf']);
+ expect(names(c.skipped)).toEqual(['notes.txt']);
+ const img = classifyLaunchFiles([f('photo.jpg'), f('IMG_0001', 'image/heic')]);
+ expect(names(img.images)).toEqual(['photo.jpg', 'IMG_0001']);
+ expect(img.companions).toEqual([]);
+ });
+ it('images WITH a model are its companions (an .obj and its textures)', () => {
+ const c = classifyLaunchFiles([f('house.obj'), f('house.mtl'), f('bricks.png')]);
+ expect(names(c.models)).toEqual(['house.obj']);
+ expect(names(c.companions)).toEqual(['house.mtl', 'bricks.png']);
+ expect(c.images).toEqual([]);
+ });
+ it('a lone .mtl is not an image and is skipped', () => {
+ expect(names(classifyLaunchFiles([f('house.mtl')]).skipped)).toEqual(['house.mtl']);
+ });
+ it('nothing in, nothing out', () => {
+ const c = classifyLaunchFiles([]);
+ expect(c.scenes.length + c.models.length + c.images.length + c.skipped.length).toBe(0);
+ });
+});
diff --git a/tests/unit/localOnlyDeleted.test.js b/tests/unit/localOnlyDeleted.test.js
new file mode 100644
index 000000000..ff9592345
--- /dev/null
+++ b/tests/unit/localOnlyDeleted.test.js
@@ -0,0 +1,42 @@
+import { describe, it, expect } from 'vitest';
+import { withoutLocalOnlyDeleted, keepLocalOnlyDeleted, mergeManifests, outboundManifest } from '../../src/lib/projectManifest.js';
+
+// fix-live (plans review #6): a never-shared file's deletion row never leaves this machine
+const priv = { hash: 'h-private', id: 'r1', name: 'diary.png', kind: 'image', at: 100, localOnly: true, folderId: 'f1', path: ['Secret'], thumb: 'data:x' };
+const pub = { hash: 'h-shared', id: 'r2', name: 'tree.glb', kind: 'model', at: 200 };
+
+describe('localOnly deletion rows', () => {
+ it('are withheld from what is sent', () => {
+ expect(withoutLocalOnlyDeleted({ changedAt: 1, deleted: [priv, pub] }).deleted).toEqual([pub]);
+ expect('deleted' in withoutLocalOnlyDeleted({ changedAt: 1, deleted: [priv] })).toBe(false);
+ const doc = { changedAt: 1, deleted: [pub] };
+ expect(withoutLocalOnlyDeleted(doc)).toBe(doc); // nothing private: untouched
+ });
+ it('outboundManifest (the one send boundary) carries no private row, name, path or thumbnail', () => {
+ const out = outboundManifest({ changedAt: 5, scenes: {}, assets: [], deleted: [priv, pub] });
+ const wire = JSON.stringify(out);
+ expect(wire).not.toContain('diary.png');
+ expect(wire).not.toContain('Secret');
+ expect(wire).not.toContain('h-private');
+ expect(wire).toContain('tree.glb');
+ });
+ it('survive a NEWER peer document, which can never carry them (the bin is not stranded)', () => {
+ const theirs = { hash: 'h-theirs', id: 'r3', name: 'rock.glb', kind: 'model', at: 300 };
+ const { doc } = mergeManifests(
+ { changedAt: 10, scenes: {}, assets: [], deleted: [priv, pub] },
+ { changedAt: 20, scenes: {}, assets: [], deleted: [pub, theirs] }
+ );
+ expect(doc.deleted?.map((r) => r.hash)).toEqual(['h-private', 'h-shared', 'h-theirs']);
+ });
+ it('a SHARED row the newer side dropped stays dropped (latest-wins is unchanged for shared rows)', () => {
+ const { doc } = mergeManifests(
+ { changedAt: 10, scenes: {}, assets: [], deleted: [pub] },
+ { changedAt: 20, scenes: {}, assets: [], deleted: [] }
+ );
+ expect(doc.deleted ?? []).toEqual([]);
+ });
+ it('keepLocalOnlyDeleted lets the winning side keep a key it already has', () => {
+ const shared = { ...priv, localOnly: undefined, at: 150 };
+ expect(keepLocalOnlyDeleted([shared], [priv]).map((r) => r.at)).toEqual([150]);
+ });
+});
diff --git a/tests/unit/peerDrop.test.js b/tests/unit/peerDrop.test.js
new file mode 100644
index 000000000..781e6b478
--- /dev/null
+++ b/tests/unit/peerDrop.test.js
@@ -0,0 +1,74 @@
+import { describe, it, expect } from 'vitest';
+import {
+ quietPeerError,
+ unavailablePeerId,
+ LEAVE_QUIET_MS,
+ ICE_DROP_GRACE_MS,
+ UNAVAILABLE_STRIKES,
+ strikesNeeded,
+ consentCounters,
+ linkSilence,
+ LINK_SILENT_MS
+} from '../../src/lib/peerDrop.js';
+
+// F-3: a dropped peer leaves Users within seconds and a leave never toasts "Connection error".
+describe('F-3 peer drop policy', () => {
+ const now = 1_000_000;
+ it('keeps leave/drop symptoms off the screen while a leave or redial is in flight', () => {
+ for (const type of ['webrtc', 'disconnected', 'negotiation-failed', 'connection-closed']) {
+ expect(quietPeerError(type, { lastLeaveAt: now - 1000, reconnecting: 0, now })).toBe(true);
+ expect(quietPeerError(type, { lastLeaveAt: 0, reconnecting: 1, now })).toBe(true);
+ }
+ });
+ it('still shows them when nothing is leaving (a real failure is news)', () => {
+ expect(quietPeerError('webrtc', { lastLeaveAt: now - LEAVE_QUIET_MS - 1, reconnecting: 0, now })).toBe(false);
+ expect(quietPeerError('webrtc', { lastLeaveAt: 0, reconnecting: 0, now })).toBe(false);
+ });
+ it('never hides errors that are not leave symptoms', () => {
+ for (const type of ['browser-incompatible', 'invalid-id', 'ssl-unavailable', 'unavailable-id'])
+ expect(quietPeerError(type, { lastLeaveAt: now, reconnecting: 3, now })).toBe(false);
+ });
+ it('reads the id from the END of the peerjs message (the old regex answered "Could")', () => {
+ expect(unavailablePeerId('Could not connect to peer e6f54')).toBe('e6f54');
+ expect(unavailablePeerId('Could not connect to peer a1b2c ')).toBe('a1b2c');
+ expect(unavailablePeerId('something else')).toBe('');
+ expect(unavailablePeerId(undefined)).toBe('');
+ });
+ it('a link closed for silence needs one "not on the server" answer, a closed channel three', () => {
+ expect(strikesNeeded(true)).toBe(1);
+ expect(strikesNeeded(false)).toBe(UNAVAILABLE_STRIKES);
+ expect(UNAVAILABLE_STRIKES).toBeGreaterThan(1);
+ });
+ it('drops a silent peer well inside the reconnect window', () => {
+ // grace + the first redial (0.5 s) + one EXPIRE answer (~5 s) < the 15.5 s window
+ expect(ICE_DROP_GRACE_MS + 500 + 5000).toBeLessThan(15500);
+ });
+
+ it('reads the SELECTED pair\'s consent counters (transport first, then a selected/nominated pair)', () => {
+ const viaTransport = new Map([
+ ['T', { type: 'transport', selectedCandidatePairId: 'P2' }],
+ ['P1', { id: 'P1', type: 'candidate-pair', requestsSent: 1, responsesReceived: 1 }],
+ ['P2', { id: 'P2', type: 'candidate-pair', requestsSent: 9, responsesReceived: 8 }]
+ ]);
+ expect(consentCounters(viaTransport)).toEqual({ requestsSent: 9, responsesReceived: 8 });
+ const nominated = [{ id: 'P', type: 'candidate-pair', nominated: true, state: 'succeeded', requestsSent: 3, responsesReceived: 3 }];
+ expect(consentCounters(nominated)).toEqual({ requestsSent: 3, responsesReceived: 3 });
+ expect(consentCounters([{ type: 'candidate-pair', state: 'waiting' }])).toBeNull();
+ expect(consentCounters(null)).toBeNull();
+ });
+ it('a link is silent only when we kept asking and nothing answered for LINK_SILENT_MS', () => {
+ let v = linkSilence(null, { requestsSent: 10, responsesReceived: 10 }, 0);
+ expect(v.silent).toBe(false);
+ v = linkSilence(v.state, { requestsSent: 12, responsesReceived: 10 }, LINK_SILENT_MS - 1);
+ expect(v.silent).toBe(false);
+ const dead = linkSilence(v.state, { requestsSent: 13, responsesReceived: 10 }, LINK_SILENT_MS);
+ expect(dead.silent).toBe(true);
+ // an answer resets the clock
+ const alive = linkSilence(v.state, { requestsSent: 13, responsesReceived: 11 }, LINK_SILENT_MS + 5000);
+ expect(alive.silent).toBe(false);
+ // no requests going out (an idle pair libwebrtc stopped pinging) is never "silent"
+ expect(linkSilence({ responses: 5, requests: 5, at: 0 }, { requestsSent: 5, responsesReceived: 5 }, 60000).silent).toBe(false);
+ // no stats = never silent
+ expect(linkSilence({ responses: 5, requests: 5, at: 0 }, null, 60000).silent).toBe(false);
+ });
+});
diff --git a/tests/unit/phoneBar.test.js b/tests/unit/phoneBar.test.js
index 1df7b3950..6181d2abd 100644
--- a/tests/unit/phoneBar.test.js
+++ b/tests/unit/phoneBar.test.js
@@ -28,7 +28,7 @@ describe('phoneBar', () => {
it('the catalog: More ▸ Windows / Edit / Tools & View, every one placeable', () => {
expect(WINDOW_ITEMS.slice(0, 5)).toEqual(['play', 'add', 'more', 'objects', 'stats']);
expect(EDIT_ITEMS).toEqual(['inspect', 'move', 'rotate', 'scale', 'undo', 'redo', 'multiselect', 'interact']);
- expect(VIEW_ITEMS).toEqual(['tools', 'focus', 'budget', 'ar']);
+ expect(VIEW_ITEMS).toEqual(['tools', 'focus', 'budget', 'ar', 'playhere']); // 60 Q1: + Play here
expect(new Set(BAR_CATALOG).size).toBe(BAR_CATALOG.length);
});
diff --git a/tests/unit/playHere.test.js b/tests/unit/playHere.test.js
new file mode 100644
index 000000000..f15cead1b
--- /dev/null
+++ b/tests/unit/playHere.test.js
@@ -0,0 +1,130 @@
+// @ts-nocheck — plain fixture scenes
+// 60 Q1: Play here — where the player lands. The ray straight down from the editor camera through
+// a real three scene (boxes, a hidden box, a sensor, water, a reference image, the physics ground
+// plane), the heading of the view, and the fallbacks: the scene's spawn, then the camera's own eye.
+import { describe, it, expect } from 'vitest';
+import * as THREE from 'three';
+import { playHereSpawnFrom, headingOf, surfaceBelowIn, notGround } from '../../src/lib/playHereCore.js';
+
+const EYE = 1.7;
+const GROUND_ON = { enabled: true, height: 0 };
+const GROUND_OFF = { enabled: false, height: 0 };
+
+/** a box whose top face sits at `top`, centred on (x, z), `size` wide */
+function box(x, z, top, size = 2, userData = {}) {
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(size, 1, size), new THREE.MeshBasicMaterial());
+ mesh.position.set(x, top - 0.5, z);
+ Object.assign(mesh.userData, userData);
+ return mesh;
+}
+function scene(...children) {
+ const group = new THREE.Group();
+ group.name = 'sceneObjects';
+ for (const c of children) group.add(c);
+ return group;
+}
+const below = (root, ground) => (eye) => surfaceBelowIn(root, eye, ground);
+
+describe('surfaceBelowIn (the spawn raycast)', () => {
+ it('lands on the first surface straight under the eye — the roof over the floor', () => {
+ const root = scene(box(0, 0, 1), box(0, 0, 4, 1));
+ expect(surfaceBelowIn(root, [0, 10, 0], GROUND_ON)).toBeCloseTo(4);
+ // beside the tall box, the low one
+ expect(surfaceBelowIn(root, [0.8, 10, 0.8], GROUND_ON)).toBeCloseTo(1);
+ });
+ it('falls through to the physics ground plane, and not when it is off', () => {
+ const root = scene(box(0, 0, 1));
+ expect(surfaceBelowIn(root, [20, 10, 20], GROUND_ON)).toBe(0);
+ expect(surfaceBelowIn(root, [20, 10, 20], GROUND_OFF)).toBeNull();
+ expect(surfaceBelowIn(null, [0, 3, 0], { enabled: true, height: 2 })).toBe(2);
+ });
+ it('a ground plane ABOVE the eye is not under it', () => {
+ expect(surfaceBelowIn(scene(), [0, 1, 0], { enabled: true, height: 2 })).toBeNull();
+ });
+ it('a surface above the eye is not under it (inside a building you stand on its floor)', () => {
+ const root = scene(box(0, 0, 0.5, 6), box(0, 0, 5, 6));
+ expect(surfaceBelowIn(root, [0, 3, 0], GROUND_ON)).toBeCloseTo(0.5);
+ });
+ it('skips what a player cannot stand on: hidden, sensors, water, reference images', () => {
+ const hidden = box(0, 0, 3);
+ hidden.visible = false;
+ const hiddenParent = new THREE.Group();
+ hiddenParent.visible = false;
+ hiddenParent.add(box(0, 0, 3.5));
+ const root = scene(
+ box(0, 0, 1),
+ hidden,
+ hiddenParent,
+ box(0, 0, 4, 2, { physics: { sensor: true } }),
+ box(0, 0, 5, 2, { water: { kind: 'surface' } }),
+ box(0, 0, 6, 2, { underlay: true })
+ );
+ expect(surfaceBelowIn(root, [0, 10, 0], GROUND_ON)).toBeCloseTo(1);
+ expect(notGround(hiddenParent.children[0], root)).toBe(true);
+ expect(notGround(root.children[0], root)).toBe(false);
+ });
+ it('only meshes count (a line or points under the camera is not a floor)', () => {
+ const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(-1, 3, 0), new THREE.Vector3(1, 3, 0)]));
+ const root = scene(line);
+ expect(surfaceBelowIn(root, [0, 10, 0], GROUND_ON)).toBe(0);
+ });
+ it('reads world positions through parents (a group moved up carries its floor)', () => {
+ const parent = new THREE.Group();
+ parent.position.set(0, 3, 0);
+ parent.add(box(0, 0, 1));
+ expect(surfaceBelowIn(scene(parent), [0, 10, 0], GROUND_ON)).toBeCloseTo(4);
+ });
+});
+
+describe('playHereSpawnFrom', () => {
+ const spawn = { position: [5, 0, -5], yaw: 1.2 };
+ it('stands the feet on the surface under the eye, facing the view', () => {
+ const root = scene(box(30, -40, 2));
+ const s = playHereSpawnFrom({ eye: [30, 12, -40], yaw: 0.5 }, below(root, GROUND_ON), spawn, EYE);
+ expect(s.source).toBe('surface');
+ expect(s.position[0]).toBe(30);
+ expect(s.position[1]).toBeCloseTo(2);
+ expect(s.position[2]).toBe(-40);
+ expect(s.yaw).toBe(0.5);
+ });
+ it('nothing under the camera → the scene spawn, with its own facing', () => {
+ const s = playHereSpawnFrom({ eye: [30, 12, -40], yaw: 0.5 }, below(scene(), GROUND_OFF), spawn, EYE);
+ expect(s).toEqual({ position: [5, 0, -5], yaw: 1.2, source: 'spawn' });
+ });
+ it('below the ground plane → the scene spawn too', () => {
+ const s = playHereSpawnFrom({ eye: [0, -3, 0], yaw: 0 }, below(scene(), GROUND_ON), spawn, EYE);
+ expect(s.source).toBe('spawn');
+ });
+ it('no surface and no spawn → the eye stays where the camera is', () => {
+ const s = playHereSpawnFrom({ eye: [1, 4, 2], yaw: -0.3 }, below(scene(), GROUND_OFF), null, EYE);
+ expect(s.source).toBe('camera');
+ expect(s.position[1] + EYE).toBeCloseTo(4);
+ expect(s.yaw).toBe(-0.3);
+ });
+ it('a VR head height is the eye height there', () => {
+ const s = playHereSpawnFrom({ eye: [0, 5, 0], yaw: 0 }, () => null, null, 1.4);
+ expect(s.position[1]).toBeCloseTo(3.6);
+ });
+ it('a non-finite heading faces −Z rather than poisoning the camera', () => {
+ const s = playHereSpawnFrom({ eye: [0, 5, 0], yaw: NaN }, () => 0, null, EYE);
+ expect(s.yaw).toBe(0);
+ });
+});
+
+describe('headingOf', () => {
+ it('is rotation.y for a level view', () => {
+ const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0.8, 0, 'YXZ'));
+ expect(headingOf(q)).toBeCloseTo(0.8);
+ });
+ it('keeps the heading of an editor camera tilted down (the usual three-quarter view)', () => {
+ const cam = new THREE.PerspectiveCamera();
+ cam.position.set(-10, 10, 10);
+ cam.lookAt(0, 0, 0);
+ // looking toward +X/−Z from (-10, _, 10): the heading is −45°
+ expect(headingOf(cam.quaternion)).toBeCloseTo(-Math.PI / 4);
+ });
+ it('stays defined looking straight down', () => {
+ const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0.6, 0, 'YXZ'));
+ expect(headingOf(q)).toBeCloseTo(0.6, 5);
+ });
+});
diff --git a/tests/unit/quarantine.test.js b/tests/unit/quarantine.test.js
new file mode 100644
index 000000000..66324113f
--- /dev/null
+++ b/tests/unit/quarantine.test.js
@@ -0,0 +1,73 @@
+// test-health-1: the e2e quarantine (scripts/ci/quarantine.cjs) decides which battery reds gate a PR,
+// without a person — so the per-check rule, the crash rule and the file's shape are pinned here.
+import { describe, it, expect } from 'vitest';
+import { createRequire } from 'node:module';
+
+const require = createRequire(import.meta.url);
+const { load, validate, failedChecks, classify, warnings } = require('../../scripts/ci/quarantine.cjs');
+
+const entry = (/** @type {Record} */ over = {}) => ({
+ suite: 'ui-fixes',
+ check: 'toast burst',
+ reason: 'a fixed sleep',
+ owner: 'test-health',
+ since: '2026-10-24',
+ review: '2026-11-07',
+ ...over
+});
+const q = (/** @type {any[]} */ ...list) => ({ quarantine: list });
+const log = (/** @type {string[]} */ ...lines) => [...lines, lines.some((l) => l.startsWith('FAIL')) ? `${lines.filter((l) => l.startsWith('FAIL')).length} FAILURES` : 'ALL PASS'].join('\n');
+
+describe('quarantine: which reds do not gate', () => {
+ it('reads the FAIL labels helpers.check prints', () => {
+ expect(failedChecks('PASS a\nFAIL the toast burst moved 8 px\n ✗ two\nnot ok three\n')).toEqual(['the toast burst moved 8 px', 'two', 'three']);
+ });
+ it('a red whose every failed check is quarantined is non-blocking', () => {
+ const v = classify(q(entry()), 'ui-fixes', log('PASS x', 'FAIL the toast burst moved 8 px'));
+ expect(v.quarantined).toBe(true);
+ expect(v.entries).toHaveLength(1);
+ });
+ it('a quarantined row cannot hide a NEW red in the same suite', () => {
+ const v = classify(q(entry()), 'ui-fixes', log('FAIL the toast burst moved 8 px', 'FAIL Roughness row'));
+ expect(v.quarantined).toBe(false);
+ expect(v.unmatched).toEqual(['Roughness row']);
+ });
+ it('a check entry never covers a crash or a timeout', () => {
+ const crash = 'PASS a\nFAIL the toast burst moved 8 px\nSCRIPT FAILED: Timeout 30000ms exceeded';
+ expect(classify(q(entry()), 'ui-fixes', crash).quarantined).toBe(false);
+ expect(classify(q(entry()), 'ui-fixes', 'PASS a\nFAIL the toast burst\n').quarantined).toBe(false); // no verdict line
+ expect(classify(q(entry()), 'ui-fixes', log('FAIL the toast burst'), { timedOut: true }).quarantined).toBe(false);
+ });
+ it('an entry is per suite: the same label in another suite still gates', () => {
+ expect(classify(q(entry()), 'other-suite', log('FAIL the toast burst')).quarantined).toBe(false);
+ });
+ it('a whole-suite entry (no check) covers any red, crash included', () => {
+ const { check, ...whole } = entry();
+ expect(classify(q(whole), 'ui-fixes', 'SCRIPT FAILED: boom').quarantined).toBe(true);
+ });
+});
+
+describe('quarantine.json: the file', () => {
+ it('the committed file validates', () => {
+ expect(validate(load())).toEqual([]);
+ });
+ it('every committed entry names a suite that exists', async () => {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ for (const e of load().quarantine) expect(fs.existsSync(path.resolve('tests/e2e', e.suite + '.test.cjs')), e.suite).toBe(true);
+ });
+ it('refuses a missing field, a bad date, an unknown key and a duplicate', () => {
+ const { owner, ...noOwner } = entry();
+ expect(validate(q(noOwner))).toEqual([expect.stringContaining('.owner is required')]);
+ expect(validate(q(entry({ review: '7 Nov' })))[0]).toContain('review must be YYYY-MM-DD');
+ expect(validate(q(entry({ review: '2026-10-01' })))[0]).toContain('review must be after since');
+ expect(validate(q({ ...entry(), note: 'x' }))[0]).toContain('unknown key note');
+ expect(validate(q(entry(), entry()))[0]).toContain('duplicate');
+ });
+ it('warns (never fails) on a past review date and on a suite that ran green', () => {
+ const w = warnings(q(entry()), [{ name: 'ui-fixes', status: 'pass' }], ['ui-fixes'], '2026-12-01');
+ expect(w.some((x) => x.includes('review date 2026-11-07 has passed'))).toBe(true);
+ expect(w.some((x) => x.includes('ran green'))).toBe(true);
+ expect(warnings(q(entry()), [], ['elsewhere'], '2026-10-25')).toEqual([expect.stringContaining('stale entry')]);
+ });
+});
diff --git a/tests/unit/underlays.test.js b/tests/unit/underlays.test.js
new file mode 100644
index 000000000..16fa0415d
--- /dev/null
+++ b/tests/unit/underlays.test.js
@@ -0,0 +1,57 @@
+// @ts-nocheck — plain records
+// 60 Q5: the reference-image record's ONE normalizer (idb read, the wire, every edit) and the plane's
+// height from the image's aspect.
+import { describe, it, expect } from 'vitest';
+import { normalizeUnderlay, underlayHeight, DEFAULT_OPACITY } from '../../src/lib/underlays.js';
+
+describe('normalizeUnderlay', () => {
+ it('refuses what is not an underlay', () => {
+ expect(normalizeUnderlay(null)).toBe(null);
+ expect(normalizeUnderlay({})).toBe(null);
+ expect(normalizeUnderlay({ id: '' })).toBe(null);
+ expect(normalizeUnderlay({ id: 'x'.repeat(65) })).toBe(null);
+ });
+ it('fills defaults and drops unknown fields', () => {
+ const u = normalizeUnderlay({ id: 'a', evil: 1, owner: 'someone' });
+ expect(u).toEqual({
+ id: 'a',
+ name: 'Reference',
+ px: [1, 1],
+ mode: 'ground',
+ position: [0, 0, 0],
+ quaternion: [0, 0, 0, 1],
+ width: 10,
+ opacity: DEFAULT_OPACITY,
+ visible: true,
+ shared: false,
+ scene: '',
+ at: 0
+ });
+ });
+ it('clamps every number and rejects malformed vectors', () => {
+ const u = normalizeUnderlay({ id: 'a', width: 1e9, opacity: 5, position: [1, 'x', 2], quaternion: [0, 0, 0], px: [0, -3] });
+ expect(u.width).toBe(10000);
+ expect(u.opacity).toBe(1);
+ expect(u.position).toEqual([0, 0, 0]);
+ expect(u.quaternion).toEqual([0, 0, 0, 1]);
+ expect(u.px).toEqual([1, 1]);
+ expect(normalizeUnderlay({ id: 'a', opacity: 0 }).opacity).toBe(0.02);
+ expect(normalizeUnderlay({ id: 'a', width: -4 }).width).toBe(0.01);
+ });
+ it('keeps only an image data URL as the source', () => {
+ expect(normalizeUnderlay({ id: 'a', src: 'data:image/png;base64,AAAA' }).src).toBe('data:image/png;base64,AAAA');
+ expect('src' in normalizeUnderlay({ id: 'a', src: 'javascript:alert(1)' })).toBe(false);
+ expect('src' in normalizeUnderlay({ id: 'a', src: 'https://example.org/x.png' })).toBe(false);
+ });
+ it('only "view" and "ground" placements exist', () => {
+ expect(normalizeUnderlay({ id: 'a', mode: 'view' }).mode).toBe('view');
+ expect(normalizeUnderlay({ id: 'a', mode: 'ceiling' }).mode).toBe('ground');
+ });
+});
+
+describe('underlayHeight', () => {
+ it('keeps the image aspect', () => {
+ expect(underlayHeight({ width: 10, px: [64, 32] })).toBe(5);
+ expect(underlayHeight({ width: 4, px: [100, 300] })).toBe(12);
+ });
+});
diff --git a/tests/unit/viewerGate.test.js b/tests/unit/viewerGate.test.js
new file mode 100644
index 000000000..30b51187c
--- /dev/null
+++ b/tests/unit/viewerGate.test.js
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest';
+import { viewerToolRefusal, VIEWER_REFUSAL } from '../../src/lib/ai/viewerGate.js';
+
+describe('F-1 viewer AI tool gate', () => {
+ it('lets editors (and the no-roles OSS build) run every tool', () => {
+ for (const name of ['create_objects', 'update_objects', 'clear_scene', 'set_physics'])
+ expect(viewerToolRefusal(name, false)).toBeNull();
+ });
+ it('refuses every scene-changing tool for a viewer, never a fake success', () => {
+ for (const name of ['create_objects', 'update_objects', 'delete_objects', 'group_objects', 'clear_scene',
+ 'create_flow_nodes', 'update_flow_nodes', 'set_physics', 'create_joints', 'create_behaviour',
+ 'edit_behaviour', 'generate_mesh', 'some_future_tool']) {
+ const r = viewerToolRefusal(name, true);
+ expect(r).toEqual({ error: VIEWER_REFUSAL, viewOnly: true });
+ expect(r).not.toHaveProperty('created');
+ }
+ });
+ it('keeps reading the scene and local playback open to viewers', () => {
+ expect(viewerToolRefusal('list_scene', true)).toBeNull();
+ expect(viewerToolRefusal('control_simulation', true)).toBeNull();
+ });
+ it('says why and what to do', () => {
+ expect(VIEWER_REFUSAL).toMatch(/view-only/);
+ expect(VIEWER_REFUSAL).toMatch(/edit access/);
+ });
+});