// Field-host ownership under cancellation and timeout. Browser tests cover the actual renderer; this fixture runs // the production orchestration with controlled imports and timers, without a DOM or GPU dependency. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { getEventListeners } from 'node:events'; import vm from 'node:vm'; const sourcePath = new URL('../../public/js/ui/fieldHost.js', import.meta.url); const source = readFileSync(sourcePath, 'utf8') .replace(/^import .*;$/gm, '').replace(/^export /gm, '') .replace("import('../assets.js')", 'importAssets()') .replace("import('../render/app.js')", 'importApp()'); const METHODS = ['setStage', 'setCamera', 'setPrep', 'enterBattle', 'pushSnapshot', 'pushEvents', 'highlightTiles', 'on', 'resize']; const flush = async () => { for (let i = 0; i < 16; i++) await Promise.resolve(); }; function fixture() { const timers = new Map(), requests = [], fallbacks = [], warnings = []; let timerId = 0; const unrelated = { name: 'unrelated' }; const host = { children: [unrelated], get firstChild() { return this.children[0] || null; }, appendChild(node) { this.children.push(node); }, removeChild(node) { const i = this.children.indexOf(node); if (i >= 0) this.children.splice(i, 1); }, }; function view(name, valid = true) { const node = { name }; host.appendChild(node); return { ...(valid ? Object.fromEntries(METHODS.map((k) => [k, () => {}])) : {}), node, destroyCalls: 0, destroy() { this.destroyCalls++; host.removeChild(node); }, }; } const context = vm.createContext({ AbortController, DOMException, URLSearchParams, Promise, console: { warn: (...args) => warnings.push(args) }, audio: {}, data: { get() { return null; } }, settingsStore: { get() { return {}; } }, setTimeout(fn) { timers.set(++timerId, fn); return timerId; }, clearTimeout(id) { timers.delete(id); }, importAssets: async () => null, importApp: async () => ({ createFieldView(_host, opts) { // Deliberately ignore cancellation: the host must still dispose a factory's late return value. return new Promise((resolve, reject) => requests.push({ resolve, reject, signal: opts.signal })); }, }), createFallbackView() { const fallback = view('fallback'); fallbacks.push({ view: fallback, rendererAborted: requests.at(-1)?.signal?.aborted }); return fallback; }, }); vm.runInContext(source + '\nglobalThis.mount = mountFieldView;', context, { filename: sourcePath.pathname }); return { host, unrelated, timers, requests, fallbacks, warnings, view, mount: (signal) => context.mount(host, { signal }), timeout() { const fn = [...timers.values()].at(-1); assert.ok(fn, 'initialization has a deadline'); fn(); }, }; } function settled(promise) { const result = { state: 'pending', value: null }; promise.then((value) => { result.state = 'fulfilled'; result.value = value; }, (error) => { result.state = 'rejected'; result.value = error; }); return result; } test('successful initialization releases deadlines and cancellation listeners', async () => { const f = fixture(), controller = new AbortController(); const pending = f.mount(controller.signal); await flush(); const raw = f.view('engine'); f.requests[0].resolve(raw); const mounted = await pending; assert.equal(mounted.kind, 'engine'); assert.equal(f.timers.size, 0); assert.equal(getEventListeners(controller.signal, 'abort').length, 0); assert.equal(getEventListeners(f.requests[0].signal, 'abort').length, 0); mounted.destroy(); assert.equal(raw.destroyCalls, 1); }); test('cancelling a pending mount rejects without fallback and disposes a late result', async () => { const f = fixture(), controller = new AbortController(); const result = settled(f.mount(controller.signal)); await flush(); controller.abort(); await flush(); assert.equal(result.state, 'rejected'); assert.equal(result.value.name, 'AbortError'); assert.equal(f.fallbacks.length, 0); assert.equal(f.warnings.length, 0); assert.equal(f.timers.size, 0); assert.equal(getEventListeners(controller.signal, 'abort').length, 0); const late = f.view('late'); f.requests[0].resolve(late); await flush(); assert.equal(late.destroyCalls, 1); assert.deepEqual(f.host.children, [f.unrelated]); }); test('cancellation between factory resolution and mount continuation disposes the resolved view', async () => { const f = fixture(), controller = new AbortController(); const result = settled(f.mount(controller.signal)); await flush(); const raw = f.view('settling'); f.requests[0].resolve(raw); await Promise.resolve(); controller.abort(); await flush(); assert.equal(result.state, 'rejected'); assert.equal(result.value.name, 'AbortError'); assert.equal(raw.destroyCalls, 1); assert.equal(f.fallbacks.length, 0); assert.equal(f.timers.size, 0); assert.deepEqual(f.host.children, [f.unrelated]); }); test('an invalid renderer API is disposed and aborted before fallback mounts', async () => { const f = fixture(); const pending = f.mount(); await flush(); const invalid = f.view('invalid', false); f.requests[0].resolve(invalid); const fallback = await pending; assert.equal(fallback.kind, 'fallback'); assert.equal(invalid.destroyCalls, 1); assert.equal(f.fallbacks[0].rendererAborted, true); assert.equal(f.timers.size, 0); assert.deepEqual(f.host.children, [f.unrelated, f.fallbacks[0].view.node]); fallback.destroy(); }); test('a timed-out factory returning late cannot remove a replacement view or unrelated children', async () => { const f = fixture(); const first = f.mount(); await flush(); f.timeout(); const fallback = await first; assert.equal(fallback.kind, 'fallback'); fallback.destroy(); const replacementPending = f.mount(); await flush(); const replacement = f.view('replacement'); f.requests[1].resolve(replacement); const mounted = await replacementPending; const late = f.view('late'); f.requests[0].resolve(late); await flush(); assert.equal(late.destroyCalls, 1); assert.equal(replacement.destroyCalls, 0); assert.equal(f.fallbacks[0].rendererAborted, true); assert.equal(f.timers.size, 0); assert.deepEqual(f.host.children, [f.unrelated, replacement.node]); mounted.destroy(); });