import { expect, test } from '@playwright/test'; import { readFile } from 'node:fs/promises'; test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' }); async function probeAsset(name: string) { return readFile(new URL(`../../test-assets/wip-wasm/${name}`, import.meta.url)); } test('real WASM enforces manifest byte, node and depth bounds', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; await page.goto(new URL('worldspace.json', base).href); const result = await page.evaluate(async (base) => { const module = await import(new URL('wip-wasm/wip_browser.js', base).href); await module.default(); const root = { path: '/', name: '', displayPath: '/', title: 'Root', href: '/', html: '

Root

' }; const attempt = (json: string) => { try { new module.BrowserSession(json).free(); return { error: '', trap: false }; } catch (error) { return { error: String(error), trap: error instanceof WebAssembly.RuntimeError }; } }; // Both oversized manifests are otherwise valid; the controls prove that // removing a limit cannot leave this test green via unrelated validation. const byteLimit = 16 * 1024 * 1024; const empty = JSON.stringify({ nodes: [{ ...root, html: '' }] }); const atBytes = JSON.stringify({ nodes: [{ ...root, html: 'x'.repeat(byteLimit - empty.length) }] }); const bytesAccepted = attempt(atBytes); const bytes = attempt(atBytes.replace('"html":"', '"html":"x')); const wide = [root, ...Array.from({ length: 4096 }, (_, i) => ({ path: `/n${i}`, name: `n${i}`, displayPath: `/n${i}`, parent: '/', title: `Node ${i}`, href: `/n${i}/`, html: '', }))]; const nodesAccepted = attempt(JSON.stringify({ nodes: wide.slice(0, 4096) })); const nodes = attempt(JSON.stringify({ nodes: wide })); const deep: Record[] = [root]; let parent = '/'; for (let i = 0; i < 32; i++) { const path = `${parent === '/' ? '' : parent}/a`; deep.push({ path, name: 'a', displayPath: path, parent, title: path, href: `${path}/`, html: '

Node

' }); parent = path; } const accepted = new module.BrowserSession(JSON.stringify({ nodes: deep })); const deepest = JSON.parse(accepted.read(parent)).path; accepted.free(); deep.push({ path: `${parent}/a`, name: 'a', displayPath: `${parent}/a`, parent, title: 'Too deep', href: `${parent}/a/`, html: '' }); const depth = attempt(JSON.stringify({ nodes: deep })); return { bytes, nodes, depth, deepest, bytesAccepted, nodesAccepted, probePublished: 'panic_probe' in module.BrowserSession.prototype }; }, base); expect(result.bytesAccepted).toEqual({ error: '', trap: false }); expect(result.nodesAccepted).toEqual({ error: '', trap: false }); expect(result.bytes).toEqual({ error: 'document JSON exceeds the input byte limit', trap: false }); expect(result.nodes).toEqual({ error: 'document node count must be between 1 and MAX_NODES', trap: false }); expect(result.depth).toEqual({ error: 'document hierarchy exceeds the depth limit', trap: false }); expect(result.deepest.split('/').length - 1).toBe(32); expect(result.probePublished).toBe(false); }); test('a controlled release Rust panic traps, not a Protocol failure; a fresh instance recovers', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; await page.route('**/wip-probe/wip_browser*', async (route) => { const wasm = new URL(route.request().url()).pathname.endsWith('.wasm'); await route.fulfill({ body: await probeAsset(wasm ? 'wip_browser_bg.wasm' : 'wip_browser.js'), contentType: wasm ? 'application/wasm' : 'text/javascript' }); }); await page.goto(new URL('worldspace.json', base).href); const result = await page.evaluate(async (base) => { const manifest = await (await fetch(new URL('worldspace.json', base))).text(); const module = await import(new URL('wip-probe/wip_browser.js', base).href); await module.default(); const session = new module.BrowserSession(manifest); const before = JSON.parse(session.read('/')).path; let trap = false, detail = ''; try { session.panic_probe(); } catch (error) { trap = error instanceof WebAssembly.RuntimeError; detail = String(error); } // Retain the aborted wrapper too: its generated GC finalizer enters WASM. (window as unknown as Record).__quarantinedSession = session; // Do not call/free the aborted Rust object: a mutable borrow was interrupted. // A distinct glue module creates a genuinely new WASM instance. const fresh = await import(`${new URL('wip-probe/wip_browser.js', base).href}?fresh`); await fresh.default(); const replacement = new fresh.BrowserSession(manifest); const after = JSON.parse(replacement.read('/')).path; replacement.free(); return { before, after, trap, detail }; }, base); expect(result.before).toBe('/'); expect(result.trap).toBe(true); expect(result.detail).toContain('RuntimeError'); expect(result.after).toBe('/'); }); test('the site retires a trapped WASM session and does not replay calls on later routes', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; await page.route('**/wip-wasm/wip_browser.js', async (route) => { const source = (await probeAsset('wip_browser.js')).toString(); await route.fulfill({ contentType: 'text/javascript', body: `${source} BrowserSession.prototype.read = function () { window.__panicReadCalls = (window.__panicReadCalls || 0) + 1; window.__trappedSession = new WeakRef(this); return this.panic_probe(); }; const originalFree = BrowserSession.prototype.free; BrowserSession.prototype.free = function () { window.__panicFreeCalls = (window.__panicFreeCalls || 0) + 1; return originalFree.call(this); };` }); }); await page.route('**/wip-wasm/*.wasm', async (route) => { await route.fulfill({ contentType: 'application/wasm', body: await probeAsset('wip_browser_bg.wasm') }); }); await page.goto(base); const stage = page.locator('[data-landing-stage]'); await expect(stage).toHaveAttribute('data-wip-state', 'fallback'); expect(await page.evaluate(() => (window as Window & { __panicReadCalls?: number }).__panicReadCalls)).toBe(1); await page.locator('.demo-node[data-path="/book"]').click(); await expect(page).toHaveURL(new URL('book/', base).href); await expect(stage).toHaveAttribute('data-wip-state', 'fallback'); await expect(page.locator('[data-selected-content] h1')).toHaveText('Book'); expect(await page.evaluate(() => (window as Window & { __panicReadCalls?: number }).__panicReadCalls)).toBe(1); // Force collection after continuations release their locals. The only test // reference is weak, so this catches accidentally dropping the quarantine. const cdp = await page.context().newCDPSession(page); await cdp.send('HeapProfiler.collectGarbage'); expect(await page.evaluate(() => Boolean((window as unknown as { __trappedSession: WeakRef }).__trappedSession.deref()))).toBe(true); await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false }))); await cdp.send('HeapProfiler.collectGarbage'); expect(await page.evaluate(() => ({ retained: Boolean((window as unknown as { __trappedSession: WeakRef }).__trappedSession.deref()), frees: (window as Window & { __panicFreeCalls?: number }).__panicFreeCalls || 0, }))).toEqual({ retained: true, frees: 0 }); await cdp.detach(); });