Add the public-document Worldspace, Rust Client/Host bridge, and WIP-backed tree and document rendering while preserving static fallback and navigation. Pin the Rust source and build tools, verify resource and trap boundaries, and cover dev and preview loading including same-origin generated module URLs.
132 lines
7.4 KiB
TypeScript
132 lines
7.4 KiB
TypeScript
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: '<p>Root</p>' };
|
|
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<string, string>[] = [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: '<p>Node</p>' });
|
|
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<string, unknown>).__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<object> }).__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<object> }).__trappedSession.deref()),
|
|
frees: (window as Window & { __panicFreeCalls?: number }).__panicFreeCalls || 0,
|
|
}))).toEqual({ retained: true, frees: 0 });
|
|
await cdp.detach();
|
|
});
|