feat: power reference site with in-process WIP WASM

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.
This commit is contained in:
2026-10-16 05:59:46 +09:00
parent ad08037f4f
commit c480b67301
26 changed files with 2719 additions and 63 deletions
+131
View File
@@ -0,0 +1,131 @@
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();
});
+81
View File
@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
test.use({ javaScriptEnabled: true });
test('real WASM owns isolated Client/Host sessions and operates without a transport', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
// Use an asset page, so the site UI is not the source of this test's observations.
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-wasm/wip_browser.js', base).href);
await module.default();
const session = new module.BrowserSession(manifest);
const input = JSON.parse(manifest);
const altered = structuredClone(input);
const document = altered.nodes.find((node: { path: string }) => node.path !== '/' && node.path !== '/book');
document.html = '<p>Isolated Worldspace content</p>';
document.title = 'Isolated title';
const other = new module.BrowserSession(JSON.stringify(altered));
// Asset loading has finished. All following interactions must be synchronous
// local calls: neither fetch nor a socket is a transport for WIP.
const originalFetch = window.fetch;
window.fetch = () => { throw new Error('Unexpected WIP network transport'); };
try {
const tree = JSON.parse(session.tree());
const bodies = tree.map((node: { path: string }) => JSON.parse(session.read(node.path)));
const first = JSON.parse(session.read(document.path));
const second = JSON.parse(other.read(document.path));
let missing = '', malformed = '', draft = '', operation = '';
const callsBeforeInvalid = JSON.parse(session.stats()).calls;
try { session.call(document.path, 'not-an-operation'); } catch (error) { operation = String(error); }
const callsAfterInvalid = JSON.parse(session.stats()).calls;
try { session.read('/not-published'); } catch (error) { missing = String(error); }
try { session.read('/book/../hidden'); } catch (error) { malformed = String(error); }
try { session.read('/draft/rust-api-model.md'); } catch (error) { draft = String(error); }
const stats = JSON.parse(session.stats());
const isolatedStats = JSON.parse(other.stats());
session.free();
other.free();
return { tree, bodies, input, first, second, missing, malformed, draft, operation, callsBeforeInvalid, callsAfterInvalid, stats, isolatedStats };
} finally { window.fetch = originalFetch; }
}, base);
expect(result.tree).toEqual(result.input.nodes.map(({ html, ...node }: { html: string }) => node));
expect(result.bodies).toEqual(result.input.nodes.map(({ path, displayPath, title, href, html }: Record<string, string>) => ({ path, displayPath, title, href, html })));
expect(result.first.html).not.toBe(result.second.html);
expect(result.second.html).toBe('<p>Isolated Worldspace content</p>');
expect(result.second.title).toBe('Isolated title');
expect(result.stats.observes).toBeGreaterThan(0);
expect(result.stats.interfaces).toBeGreaterThan(0);
expect(result.stats.calls).toBeGreaterThan(result.tree.length);
expect(result.isolatedStats.calls).toBe(1);
expect(result.missing).not.toBe('');
expect(result.malformed).not.toBe('');
expect(result.draft).not.toBe('');
expect(result.operation).not.toBe('');
expect(result.callsAfterInvalid).toBe(result.callsBeforeInvalid);
});
test('WASM rejects invalid initialization and remains usable after a handled input failure', 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 errors: string[] = [];
for (const input of ['{', '{"nodes":[]}', '{"nodes":null}']) {
try { new module.BrowserSession(input).free(); } catch (error) { errors.push(String(error)); }
}
const manifest = await (await fetch(new URL('worldspace.json', base))).text();
const session = new module.BrowserSession(manifest);
const body = JSON.parse(session.read('/'));
session.free();
let disposed = false;
try { session.read('/'); } catch { disposed = true; }
return { errors, body, disposed };
}, base);
expect(result.errors).toHaveLength(3);
expect(result.body.path).toBe('/');
expect(result.body.html).toContain('data-root-page');
expect(result.disposed).toBe(true);
});
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from '@playwright/test';
import { load } from 'cheerio';
// Shared by dev and preview: a successful build alone cannot catch Vite's
// dynamic-import rewriting of generated modules served from public/.
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
test('generated WASM loads as a static asset and hydrates real WIP data', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const errors: string[] = [];
const assets: { url: string; status: number }[] = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('response', (response) => {
if (response.status() >= 500) errors.push(`${response.status()} ${response.url()}`);
if (new URL(response.url()).pathname.includes('/wip-wasm/')) {
assets.push({ url: response.url(), status: response.status() });
}
});
await page.route(base, async (route) => {
const response = await route.fetch();
const $ = load(await response.text());
$('[data-selected-content]').prepend('<p data-ssr-only>Not a WIP result</p>');
await route.fulfill({ response, body: $.html() });
});
await page.goto(base);
const stage = page.locator('[data-landing-stage]');
await expect(stage).toHaveAttribute('data-wip-state', 'ready');
await expect(page.locator('[data-ssr-only]')).toHaveCount(0);
await expect(page.locator('[data-selected-content]')).toHaveAttribute('data-wip-path', '/');
for (const counter of ['observes', 'interfaces', 'calls']) {
expect(Number(await stage.getAttribute(`data-wip-${counter}`))).toBeGreaterThan(0);
}
const calls = Number(await stage.getAttribute('data-wip-calls'));
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', 'ready');
await expect(page.locator('[data-selected-content]')).toHaveAttribute('data-wip-path', '/book');
expect(Number(await stage.getAttribute('data-wip-calls'))).toBe(calls + 1);
expect(assets.map(({ url }) => url).sort()).toEqual([
new URL('wip-wasm/wip_browser.js', base).href,
new URL('wip-wasm/wip_browser_bg.wasm', base).href,
].sort());
expect(assets.every(({ status }) => status === 200)).toBe(true);
await expect(page.locator('vite-error-overlay')).toHaveCount(0);
expect(errors).toEqual([]);
});
+397
View File
@@ -0,0 +1,397 @@
import { expect, test, type Page } from '@playwright/test';
import { load } from 'cheerio';
import { bookURL, documentPaths, draftPaths, treePaths } from './documents';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
const stage = '[data-landing-stage]';
const article = '[data-selected-content]';
const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`);
const routeURL = (path: string, base: string) => path === '/' ? base : path === '/book' ? new URL('book/', base).href : bookURL(path, base);
async function ready(page: Page, path: string) {
await expect(page.locator(stage)).toHaveAttribute('data-current-path', path);
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'ready');
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
const canonical = await nodeAt(page, path).getAttribute('data-wip-path');
expect(canonical).toBeTruthy();
await expect(page.locator(stage)).toHaveAttribute('data-wip-path', canonical!);
await expect(page.locator(article)).toHaveAttribute('data-wip-path', canonical!);
await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page');
for (const counter of ['observes', 'interfaces', 'calls']) {
expect(Number(await page.locator(stage).getAttribute(`data-wip-${counter}`))).toBeGreaterThan(0);
}
}
async function useNestedArticleScroll(page: Page) {
await page.addInitScript(() => {
const constrain = (document: Document) => {
const article = document.querySelector<HTMLElement>('[data-selected-content]');
if (article) { article.style.maxHeight = '240px'; article.style.overflow = 'auto'; }
};
document.addEventListener('astro:page-load', () => constrain(document));
// Constrain incoming markup BEFORE Astro scrolls to a cross-page fragment.
// Changing overflow after that native scroll makes the mobile test race the
// layout instead of exercising the intended nested scrolling geometry.
document.addEventListener('astro:before-swap', (event) => {
constrain((event as Event & { newDocument: Document }).newDocument);
});
});
}
async function probeBridge(page: Page, base: string, displayPath: string) {
return page.evaluate(async ({ base, displayPath }) => {
const moduleURL = new URL('wip-wasm/wip_browser.js', base).href;
const module = await import(moduleURL);
await module.default();
const client = new module.BrowserSession(await (await fetch(new URL('worldspace.json', base))).text());
try {
const nodes = JSON.parse(client.tree()) as { path: string; displayPath: string; name: string; title: string; href: string }[];
const selected = nodes.find((node) => node.displayPath === displayPath)!;
const result = JSON.parse(client.read(selected.path));
const template = document.createElement('template');
template.innerHTML = result.html;
return { nodes, html: template.innerHTML, stats: JSON.parse(client.stats()) };
} finally { client.free(); }
}, { base, displayPath });
}
// Run this suite against both ordinary builds and SITE_BASE builds. All URLs
// derive from the project's baseURL; no root-only module or document shortcuts.
test('real WASM results replace poisoned SSR labels and article, rather than just observing', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const url = bookURL('/1-concept.md', base);
await page.route(url, async (route) => {
const response = await route.fetch();
const $ = load(await response.text());
$('.node-label').text('SSR-only wrong label');
$(article).prepend('<p data-ssr-only>SSR-only wrong body</p>');
await route.fulfill({ response, body: $.html() });
});
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
await page.route('**/wip-wasm/wip_browser.js', async (route) => { await gate; await route.continue(); });
const wasmResponses: string[] = [];
page.on('response', (response) => { if (new URL(response.url()).pathname.endsWith('.wasm') && response.ok()) wasmResponses.push(response.url()); });
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await expect(page.locator('[data-ssr-only]')).toBeVisible();
await expect(nodeAt(page, '/1-concept.md')).toHaveText('SSR-only wrong label');
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'loading');
const anchor = await nodeAt(page, '/1-concept.md').elementHandle();
release();
await ready(page, '/1-concept.md');
await expect(page.locator('[data-ssr-only]')).toHaveCount(0);
await expect(nodeAt(page, '/1-concept.md')).toHaveText('1-concept.md');
expect(await anchor!.evaluate((el) => el === document.querySelector('.demo-node[data-path="/1-concept.md"]'))).toBe(true);
expect(wasmResponses).toHaveLength(1);
expect(wasmResponses[0]).toContain(new URL('wip-wasm/', base).pathname);
// Independently read the same genuine Rust bridge, without exposing a test
// session on window. Compare actual data/HTML, not success markers alone.
const expected = await probeBridge(page, base, '/1-concept.md');
expect(await page.locator(article).innerHTML()).toBe(expected.html);
for (const node of expected.nodes) {
await expect(nodeAt(page, node.displayPath)).toHaveAttribute('data-wip-path', node.path);
await expect(nodeAt(page, node.displayPath)).toHaveAttribute('href', node.href);
await expect(nodeAt(page, node.displayPath)).toHaveAttribute('title', node.title);
if (node.displayPath !== '/') {
const label = node.displayPath === '/book' ? `${node.name.replace(/\/$/, '')}/` : node.name;
await expect(nodeAt(page, node.displayPath)).toHaveText(label);
}
}
expect(expected.stats.observes).toBeGreaterThan(0);
expect(expected.stats.interfaces).toBeGreaterThan(0);
expect(expected.stats.calls).toBeGreaterThan(0);
} finally {
release();
await page.unrouteAll({ behavior: 'wait' });
}
});
test('every public route uses one persistent WASM session with only static asset/page requests', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const requests: { url: string; method: string; type: string }[] = [];
const sockets: string[] = [];
page.on('request', (request) => requests.push({ url: request.url(), method: request.method(), type: request.resourceType() }));
page.on('websocket', (socket) => sockets.push(socket.url()));
await page.goto('./');
await ready(page, '/');
const anchors = await page.locator('.demo-node').elementHandles();
let calls = Number(await page.locator(stage).getAttribute('data-wip-calls'));
for (const path of treePaths.slice(1)) {
// Hidden children still have native anchors; selection opens their ancestors.
await nodeAt(page, path).evaluate((el) => (el as HTMLAnchorElement).click());
await ready(page, path);
await expect(page).toHaveURL(routeURL(path, base));
await expect(page.locator(article + ' h1')).toHaveCount(1);
const next = Number(await page.locator(stage).getAttribute('data-wip-calls'));
expect(next, `one document call for ${path}`).toBe(calls + 1);
calls = next;
}
for (const anchor of anchors) {
expect(await anchor.evaluate((el) => el === document.querySelector(`.demo-node[data-path="${(el as HTMLElement).dataset.path}"]`))).toBe(true);
}
const paths = requests.map((request) => new URL(request.url).pathname);
expect(paths.filter((path) => path.endsWith('/worldspace.json'))).toHaveLength(1);
expect(paths.filter((path) => path.endsWith('/wip_browser.js'))).toHaveLength(1);
expect(paths.filter((path) => path.endsWith('.wasm'))).toHaveLength(1);
const documents = new Set(treePaths.map((path) => new URL(routeURL(path, base)).pathname));
for (const request of requests.filter((request) => ['fetch', 'xhr'].includes(request.type))) {
expect(request.method).toBe('GET');
const url = new URL(request.url);
expect(url.origin).toBe(new URL(base).origin);
expect(url.pathname.startsWith(new URL(base).pathname)).toBe(true);
expect(documents.has(url.pathname) || url.pathname.endsWith('/worldspace.json') || url.pathname.endsWith('.wasm'), request.url).toBe(true);
}
expect(sockets).toEqual([]);
await expect(page.locator('.demo-node[data-path*="draft"], a[href*="/draft/"]')).toHaveCount(0);
});
test('WIP navigation retains collapse, history reading position, fragments and single handlers', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
// Exercise nested scroll even on mobile, whose normal scroll belongs to window.
await useNestedArticleScroll(page);
await page.goto(bookURL('/3-protocol.md', base));
await ready(page, '/3-protocol.md');
await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click();
const position = await page.locator(article).evaluate((el) => { el.scrollTop = 180; return el.scrollTop; });
expect(position).toBe(180);
const pane = await page.locator('.tree-pane').elementHandle();
// A pointer click scrolls the offscreen Markdown link into view before Astro
// navigates. History must restore that departure position, not the earlier 180.
await page.evaluate(() => {
document.addEventListener('astro:before-swap', () => {
sessionStorage.setItem('wip-test-departure-scroll', String(document.querySelector('[data-selected-content]')!.scrollTop));
}, { once: true });
});
const crossPageLink = () => page.locator(article).getByRole('link', { name: '4.2. Text View', exact: true }).first();
await crossPageLink().click();
await ready(page, '/4.2-text-view.md');
const departurePosition = await page.evaluate(() => Number(sessionStorage.getItem('wip-test-departure-scroll')));
expect(departurePosition).toBeGreaterThan(position);
expect(new URL(page.url()).hash).not.toBe('');
await expect.poll(() => page.evaluate(() => {
const target = document.getElementById(decodeURIComponent(location.hash.slice(1)));
return !!target && target.getBoundingClientRect().top < innerHeight;
})).toBe(true);
await page.goBack();
await ready(page, '/3-protocol.md');
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(departurePosition);
await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false');
// Independently check the controlled 180px position without Playwright's
// pointer actionability scrolling. Native anchor activation still uses Astro.
await page.locator(article).evaluate((el) => { el.scrollTop = 180; });
await crossPageLink().evaluate((el) => (el as HTMLAnchorElement).click());
await ready(page, '/4.2-text-view.md');
await page.goBack();
await ready(page, '/3-protocol.md');
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(position);
await page.goForward();
await ready(page, '/4.2-text-view.md');
for (const path of ['/2-core-model.md', '/3-protocol.md', '/2-core-model.md']) {
await nodeAt(page, path).click();
await ready(page, path);
}
await page.locator('.node-toggle[data-toggle-path="/book"]').click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
await page.locator('.node-toggle[data-toggle-path="/book"]').click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true');
await nodeAt(page, '/1-concept.md').focus();
await page.keyboard.press('ArrowRight'); // Expand exactly once, without navigation.
await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'true');
await page.keyboard.press('ArrowDown');
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused();
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
await page.reload();
await ready(page, '/2-core-model.md');
const fresh = await probeBridge(page, base, '/2-core-model.md');
expect(Number(await page.locator(stage).getAttribute('data-wip-calls'))).toBe(fresh.stats.calls);
});
for (const leaveDocument of [false, true]) {
test(`fragment history restores separate article positions${leaveDocument ? ' after a document round trip' : ' without a swap'}`, async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const path = '/4.2-text-view.md';
const url = bookURL(path, base);
const assets: string[] = [];
page.on('request', (request) => {
if (/worldspace\.json|wip-wasm/.test(request.url())) assets.push(request.url());
});
// The normal mobile layout scrolls window. Force the nested reading pane on
// both projects so this cannot pass by checking Astro's window scroll alone.
await useNestedArticleScroll(page);
await page.goto(url);
await ready(page, path);
const calls = Number(await page.locator(stage).getAttribute('data-wip-calls'));
const initialBody = await page.locator(article).elementHandle();
const originalKey = await page.evaluate(() => history.state.wipReadingEntry);
const setWindowPosition = async (top: number) => {
const position = await page.evaluate((top) => { window.scrollTo(0, top); return scrollY; }, top);
// Let Astro save its own window-scroll state independently of our pane.
await expect.poll(() => page.evaluate(() => history.state.scrollY)).toBe(position);
return position;
};
const originalWindowPosition = await setWindowPosition(70);
await page.locator(article).evaluate((el) => { el.scrollTop = 180; });
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(180);
const fragment = page.locator(article).getByRole('link', { name: '名前と文字列', exact: true }).first();
const fragmentURL = new URL((await fragment.getAttribute('href'))!, url).href;
// Native anchor activation, without Playwright scrolling the link into view
// and silently replacing the controlled departure position before the push.
await fragment.evaluate((el) => (el as HTMLAnchorElement).click());
await expect(page).toHaveURL(fragmentURL);
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBeGreaterThan(180);
const fragmentKey = await page.evaluate(() => history.state.wipReadingEntry);
expect(originalKey).toBeTruthy();
expect(fragmentKey).toBeTruthy();
expect(fragmentKey).not.toBe(originalKey);
expect(await initialBody!.evaluate((el) => el === document.querySelector('[data-selected-content]'))).toBe(true);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(calls));
// Restore the reader's latest position, not merely the fragment's anchor.
await page.locator(article).evaluate((el) => { el.scrollTop = 420; });
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(420);
const fragmentWindowPosition = await setWindowPosition(130);
let expectedCalls = calls;
if (leaveDocument) {
await nodeAt(page, '/2-core-model.md').evaluate((el) => (el as HTMLAnchorElement).click());
await ready(page, '/2-core-model.md');
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(++expectedCalls));
await page.goBack();
await ready(page, path);
await expect(page).toHaveURL(fragmentURL);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(++expectedCalls));
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(420);
}
const body = await page.locator(article).elementHandle();
// Repeat traversals with new reading positions: each departure must update
// only its own entry, including the no-hash entry that shares this document.
for (const [originalPosition, fragmentPosition] of [[180, 420], [230, 470]]) {
await page.goBack();
await expect(page).toHaveURL(url);
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(originalPosition);
expect(await page.evaluate(() => history.state.wipReadingEntry)).toBe(originalKey);
await expect.poll(() => page.evaluate(() => scrollY)).toBe(originalWindowPosition);
await page.locator(article).evaluate((el) => { el.scrollTop = 230; });
await page.goForward();
await expect(page).toHaveURL(fragmentURL);
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(fragmentPosition);
expect(await page.evaluate(() => history.state.wipReadingEntry)).toBe(fragmentKey);
await expect.poll(() => page.evaluate(() => scrollY)).toBe(fragmentWindowPosition);
await page.locator(article).evaluate((el) => { el.scrollTop = 470; });
await ready(page, path);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(expectedCalls));
expect(await body!.evaluate((el) => el === document.querySelector('[data-selected-content]'))).toBe(true);
}
if (leaveDocument) {
await page.goForward();
await ready(page, '/2-core-model.md');
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(++expectedCalls));
await page.goBack();
await ready(page, path);
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(470);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(++expectedCalls));
}
for (const suffix of ['/worldspace.json', '/wip_browser.js', '.wasm']) {
expect(assets.filter((url) => new URL(url).pathname.endsWith(suffix))).toHaveLength(1);
}
});
}
test('WIP-rendered fragment and modifier links retain native navigation semantics', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.goto(bookURL('/4.2-text-view.md', base));
await ready(page, '/4.2-text-view.md');
const calls = await page.locator(stage).getAttribute('data-wip-calls');
const body = await page.locator(article).elementHandle();
const link = page.locator(article).getByRole('link', { name: '名前と文字列', exact: true }).first();
const href = await link.getAttribute('href');
await link.click();
await expect(page).toHaveURL(new URL(href!, bookURL('/4.2-text-view.md', base)).href);
await ready(page, '/4.2-text-view.md');
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', calls!);
expect(await body!.evaluate((el) => el === document.querySelector('[data-selected-content]'))).toBe(true);
const originalURL = page.url();
const [tab] = await Promise.all([
page.context().waitForEvent('page'),
nodeAt(page, '/2-core-model.md').click({ modifiers: ['ControlOrMeta'] }),
]);
await ready(tab, '/2-core-model.md');
await expect(tab).toHaveURL(bookURL('/2-core-model.md', base));
expect(page.url()).toBe(originalURL);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', calls!);
await tab.close();
});
test('slow WASM initialization cannot roll a newer Astro route back to a stale read', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
await page.route('**/wip-wasm/*.wasm', async (route) => { await gate; await route.continue(); });
try {
await page.goto(bookURL('/1-concept.md', base), { waitUntil: 'domcontentloaded' });
await expect(page.locator('.demo-tree')).toHaveAttribute('data-enhanced', 'true');
await page.evaluate(() => {
for (const path of ['/2-core-model.md', '/3-protocol.md', '/4-client.md']) {
document.querySelector<HTMLAnchorElement>(`.demo-node[data-path="${path}"]`)!.click();
}
});
await expect(page).toHaveURL(bookURL('/4-client.md', base));
await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/4-client.md');
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'loading');
release();
await ready(page, '/4-client.md');
await expect(page.locator(article + ' h1')).toHaveText('4. クライアント');
// Only the still-live route is allowed to call read after shared init resolves.
const fresh = await probeBridge(page, base, '/4-client.md');
expect(Number(await page.locator(stage).getAttribute('data-wip-calls'))).toBe(fresh.stats.calls);
} finally {
release();
await page.unrouteAll({ behavior: 'wait' });
}
});
for (const asset of ['**/wip-wasm/wip_browser.js', '**/wip-wasm/*.wasm', '**/worldspace.json']) {
test(`failed ${asset} stays explicitly static and navigable`, async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.route(asset, (route) => route.abort());
await page.goto(bookURL('/1-concept.md', base));
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'fallback');
await expect(page.locator(article + ' h1')).toHaveText('1. コンセプト');
await expect(page.locator(article)).not.toHaveAttribute('data-wip-path');
await nodeAt(page, '/2-core-model.md').click();
await expect(page).toHaveURL(bookURL('/2-core-model.md', base));
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'fallback');
await expect(page.locator(article + ' h1')).toHaveText('2. コアモデル');
await expect(page.locator(stage)).not.toHaveAttribute('data-wip-calls');
});
}
test('unknown and draft routes remain 404 rather than reading the WIP home document', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
for (const url of [new URL('missing-wip-document/', base).href, ...draftPaths.map((path) => bookURL(path, base))]) {
const response = await page.goto(url);
expect(response!.status()).toBe(404);
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'fallback');
await expect(page.locator(article + ' h1')).toHaveText('このページは見つかりません。');
await expect(page.locator(article)).not.toHaveAttribute('data-wip-path');
}
});
test.describe('no JavaScript', () => {
test.use({ javaScriptEnabled: false });
test('static public pages remain usable and never claim WIP execution', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const assets: string[] = [];
page.on('request', (request) => { if (/worldspace\.json|wip-wasm/.test(request.url())) assets.push(request.url()); });
await page.goto('./');
for (const path of ['/book', ...documentPaths]) {
await nodeAt(page, path).click();
await expect(page).toHaveURL(routeURL(path, base));
await expect(page.locator(article + ' h1')).toBeVisible();
await expect(page.locator(stage)).not.toHaveAttribute('data-wip-state');
await expect(page.locator(article)).not.toHaveAttribute('data-wip-path');
}
expect(assets).toEqual([]);
});
});
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { createMarkdownProcessor } from '@astrojs/markdown-remark';
import { load } from 'cheerio';
import documentLinks, { contentsRoot } from '../plugins/document-links.mjs';
import { bookTree } from '../src/lib/landing-tree.ts';
import { createWorldspaceData } from '../src/lib/worldspace-data.ts';
const pages = {
'/': { title: 'Worldspace Interface Protocol', href: '/', html: '<h1>Home</h1>' },
'/book': { title: 'Book', href: '/book/', html: '<h1>Book</h1>' },
};
const source = (id) => ({ id, title: id, href: `/book/${id}/`, html: `<h1>${id}</h1>` });
async function sourceFiles(directory, prefix = '') {
return (await Promise.all((await readdir(directory, { withFileTypes: true })).map(async (entry) => {
const relative = prefix + entry.name;
return entry.isDirectory() ? sourceFiles(path.join(directory, entry.name), relative + '/')
: entry.name.endsWith('.md') ? [relative] : [];
}))).flat();
}
const files = await sourceFiles(contentsRoot);
const publicFiles = files.filter((file) => !file.startsWith('draft/'));
const processor = await createMarkdownProcessor({ remarkPlugins: [[documentLinks, { base: '/' }]], smartypants: false });
const documents = await Promise.all(publicFiles.map(async (file) => {
const body = await readFile(path.join(contentsRoot, file), 'utf8');
const result = await processor.render(body, { fileURL: new URL(`file://${path.join(contentsRoot, file)}`) });
return { ...source(file.slice(0, -3)), title: body.match(/^#\s+(.+)$/m)?.[1] ?? file, html: result.code };
}));
function checkIdentity(data) {
for (const field of ['path', 'displayPath', 'href']) {
assert.equal(new Set(data.nodes.map((node) => node[field])).size, data.nodes.length);
}
const root = data.nodes.find((node) => node.path === '/');
assert.equal(root.name, '');
assert.equal(root.parent, undefined);
for (const node of data.nodes.filter((node) => node.path !== '/')) {
assert.ok(data.nodes.some((parent) => parent.path === node.parent));
assert.equal(node.path, `${node.parent === '/' ? '' : node.parent}/${node.name}`);
assert.ok(node.name.length > 0 && !node.name.includes('/'));
assert.ok(!node.path.split('/').includes('draft'));
assert.ok(!node.displayPath.split('/').includes('draft'));
}
}
test('actual public documents keep Web and display identities but acquire real parent paths', () => {
const data = createWorldspaceData(documents, pages);
checkIdentity(data);
const display = bookTree(documents);
assert.equal(data.nodes.length, documents.length + 2);
assert.deepEqual(data.nodes.map((node) => node.displayPath), display.map((node) => node.path));
for (const node of data.nodes) {
const legacy = display.find((item) => item.path === node.displayPath);
assert.equal(node.parent, legacy.parent === undefined ? undefined : data.nodes.find((item) => item.displayPath === legacy.parent).path);
if (legacy.href) {
assert.equal(node.href, legacy.href);
assert.equal(node.html, documents.find((entry) => `/${entry.id}.md` === node.displayPath).html);
assert.match(node.html, /<h1/);
}
}
const section = data.nodes.find((node) => node.displayPath === '/1.1-interaction-abstraction.md');
assert.equal(section.path, '/book/1-concept.md/1.1-interaction-abstraction.md');
assert.equal(section.parent, '/book/1-concept.md');
assert.equal(section.href, '/book/1.1-interaction-abstraction/');
assert.equal(data.nodes[1].name, 'book');
assert.equal(display[1].name, 'book/');
});
test('drafts are removed before hierarchy, body and URL generation', () => {
const marker = 'PRIVATE-DRAFT-CONTENT-MUST-NOT-SERIALIZE';
const drafts = files.filter((file) => file.startsWith('draft/')).map((file) => ({ ...source(file.slice(0, -3)), html: marker }));
assert.ok(drafts.length > 0);
const publicOnly = createWorldspaceData(documents, pages);
const mixed = createWorldspaceData([...documents, ...drafts], pages);
assert.deepEqual(mixed, publicOnly);
assert.ok(!JSON.stringify(mixed).includes(marker));
assert.ok(!mixed.nodes.some((node) => node.href.includes('/draft/')));
});
test('nested numbering, real directories and site base prefixes remain unambiguous', () => {
const docs = ['1-parent', '1.2-child', '1.2.3-leaf', 'other/1-parent', 'other/1.1-child']
.map((id) => ({ ...source(id), href: `/reference/book/${id}/` }));
const data = createWorldspaceData(docs, {
'/': { ...pages['/'], href: '/reference/' },
'/book': { ...pages['/book'], href: '/reference/book/' },
'/other': { title: 'other/', href: '/reference/book/other/', html: '<h1>other/</h1>' },
});
checkIdentity(data);
assert.equal(data.nodes.find((node) => node.displayPath === '/1.2.3-leaf.md').path,
'/book/1-parent.md/1.2-child.md/1.2.3-leaf.md');
assert.equal(data.nodes.find((node) => node.displayPath === '/other/1.1-child.md').path,
'/book/other/1-parent.md/1.1-child.md');
assert.ok(data.nodes.every((node) => node.href.startsWith('/reference/')));
});
test('invalid, duplicate and incomplete generated inputs fail rather than lose documents', () => {
assert.throws(() => createWorldspaceData([source('a'), source('a')], pages), /Duplicate document/);
for (const id of ['', '/absolute', 'a//b', 'a/../b', 'a/./b', 'a/draft/b']) {
assert.throws(() => createWorldspaceData([source(id)], pages), /Invalid public document id/);
}
assert.throws(() => createWorldspaceData([source('a')], {}), /Missing rendered page/);
assert.throws(() => createWorldspaceData([{ ...source('a'), href: '/book/' }], pages), /Duplicate Web URL/);
assert.throws(() => createWorldspaceData([{ ...source('a'), href: '//external.example/' }], pages), /Invalid page metadata/);
assert.throws(() => createWorldspaceData([{ ...source('a'), title: '' }], pages), /Invalid page metadata/);
assert.throws(() => createWorldspaceData([source('dir/a')], pages), /Missing rendered page: \/dir/);
});
test('root and book index content is supplied by their existing page renderers', () => {
const data = createWorldspaceData([], pages);
assert.deepEqual(data.nodes, [
{ path: '/', name: '', displayPath: '/', ...pages['/'] },
{ path: '/book', name: 'book', displayPath: '/book', parent: '/', ...pages['/book'] },
]);
});
// Explicit post-build validation, usable for either root or prefixed deployments.
// WIP_TEST_GENERATED=1 node --test tests/worldspace-data.test.mjs
test('built artifact contains exactly public routes and the same rendered article bodies',
{ skip: process.env.WIP_TEST_GENERATED !== '1' }, async () => {
const data = JSON.parse(await readFile(new URL('../dist/worldspace.json', import.meta.url), 'utf8'));
checkIdentity(data);
assert.equal(data.nodes.length, documents.length + 2);
assert.deepEqual(new Set(data.nodes.filter((node) => node.displayPath.endsWith('.md')).map((node) => node.displayPath.slice(1))), new Set(publicFiles));
for (const node of data.nodes) {
const relative = node.displayPath === '/' ? 'index.html' : node.displayPath === '/book' ? 'book/index.html'
: `book/${node.displayPath.slice(1, -3)}/index.html`;
const page = load(await readFile(new URL(`../dist/${relative}`, import.meta.url), 'utf8'));
// Astro adds hydration/render markers outside the actual article fragment.
// Compare normalized markup, not generated comment/whitespace placement.
const fragment = load(node.html, {}, false);
const article = page('[data-selected-content]');
assert.equal(article.length, 1, `${relative} has a document body`);
assert.equal(fragment.text().replace(/\s+/g, ' ').trim(), article.text().replace(/\s+/g, ' ').trim(), node.path);
assert.deepEqual(fragment('a[href]').map((_, item) => fragment(item).attr('href')).get(),
article.find('a[href]').map((_, item) => page(item).attr('href')).get(), node.path);
}
});