diff --git a/site/src/components/PlaygroundPage.astro b/site/src/components/PlaygroundPage.astro
new file mode 100644
index 0000000..b5ea229
--- /dev/null
+++ b/site/src/components/PlaygroundPage.astro
@@ -0,0 +1,14 @@
+---
+import { href } from '../lib/book';
+---
+Playground
+ProtocolのOperationでObjectを作り、状態を変えるメモリ内のデモです。Protocolで開くと、実際のObserveでカウンターを取得します。
+
+ /::collection:create(name)でカウンターを追加し、remove(name)で削除します。
+ /::counter:get()で現在値、increment(amount)で増減、reset()で0に戻します。
+
+初期状態はappleが3、orangeが0です。操作後に再Observeし、ツリーの子Objectと値を更新します。名前は1〜32文字の英数字・_・-、カウンターは最大32個です。
+Argumentsの入力例:
+{'{"name":{"kind":"string","value":"banana"}}'}
+{'{"amount":{"kind":"integer","value":"2"}}'}
+状態はこのタブのProtocol session内だけに保存され、再読み込みで初期状態に戻ります。文書Objectや他の利用者の状態は変更しません。JavaScriptやWASMが利用できない場合は、この説明のみ表示します。
diff --git a/site/src/components/ProtocolPanel.astro b/site/src/components/ProtocolPanel.astro
new file mode 100644
index 0000000..33debb0
--- /dev/null
+++ b/site/src/components/ProtocolPanel.astro
@@ -0,0 +1,20 @@
+---
+import '../styles/protocol.css';
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/site/src/components/RootPage.astro b/site/src/components/RootPage.astro
index 257d119..d0add6f 100644
--- a/site/src/components/RootPage.astro
+++ b/site/src/components/RootPage.astro
@@ -1,5 +1,5 @@
---
-import { href, repository } from '../lib/book';
+import { href } from '../lib/book';
---
diff --git a/site/src/layouts/Site.astro b/site/src/layouts/Site.astro
index b6f5298..6b5f191 100644
--- a/site/src/layouts/Site.astro
+++ b/site/src/layouts/Site.astro
@@ -14,7 +14,7 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin
';
+ const session = new module.BrowserSession(JSON.stringify({ nodes: [{ path: '/', name: '', displayPath: '/', title, href: '/', html: 'body only on call
' }] }));
+ const observed = JSON.parse(session.observe('/', 0));
+ const fetched = JSON.parse(session.fetch_interface('/', 'document'));
+ const pre = document.createElement('pre');
+ pre.textContent = observed.textView;
+ document.body.append(pre);
+ session.free();
+ return { observed, fetched, title, displayed: pre.textContent, scripts: pre.querySelectorAll('script').length, executed: '__textViewExecuted' in window };
+ }, base);
+ // Golden output is also checked against render_object directly in native tests.
+ expect(result.observed.textView).toBe('object {\n name: "";\n description: " 引用 \\"quote\\" \\\\ space\\nnext\\tline ";\n interfaces: ["/::document"];\n}\n');
+ expect(result.observed.observation.object.description).toBe(result.title);
+ expect(result.displayed).toBe(result.observed.textView);
+ expect(result.scripts).toBe(0);
+ expect(result.executed).toBe(false);
+ expect(result.fetched.stats).toEqual({ observes: 1, interfaces: 1, calls: 0 });
+});
diff --git a/site/tests/browser/protocol.spec.ts b/site/tests/browser/protocol.spec.ts
new file mode 100644
index 0000000..768c4a7
--- /dev/null
+++ b/site/tests/browser/protocol.spec.ts
@@ -0,0 +1,484 @@
+import { expect, test, type Page } from '@playwright/test';
+import { readFile } from 'node:fs/promises';
+
+test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
+const panel = (page: Page) => page.locator('[data-protocol-panel]');
+const protocolURL = (base: string, route = '') => { const url = new URL(route, base); url.searchParams.set('mode', 'protocol'); return url.href; };
+async function ready(page: Page) {
+ await expect(panel(page)).toBeVisible();
+ await expect(panel(page)).toHaveAttribute('data-state', 'ready');
+ await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'manual');
+ await expect(page.locator('[data-selected-content]')).toBeHidden();
+}
+async function descriptor(page: Page) {
+ await expect(page.getByLabel('Object Text View')).toContainText('object {');
+ await page.locator('[data-interface-toggle]').first().click();
+ await expect(page.getByLabel('Interface Text View')).toContainText('interface');
+ await page.getByLabel('Operation', { exact: true }).selectOption('read');
+}
+
+async function traceObserves(page: Page) {
+ await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
+ const response = await route.fetch();
+ await route.fulfill({ response, body: `${await response.text()}\nconst originalObserve = BrowserSession.prototype.observe; BrowserSession.prototype.observe = function(path, depth) { (window.__observed ||= []).push({ path, depth }); return originalObserve.call(this, path, depth); };` });
+ });
+}
+const observedCalls = (page: Page) => page.evaluate(() => (window as Window & { __observed?: { path: string; depth: number }[] }).__observed ?? []);
+
+for (const [route, path, name] of [
+ ['', '/', ''], ['book/', '/book', 'book'],
+ ['book/1.1-interaction-abstraction/', '/book/1-concept.md/1.1-interaction-abstraction.md', '1.1-interaction-abstraction.md'],
+ ['sources/', '/sources', 'sources'], ['packages/', '/packages', 'packages'],
+]) {
+ test(`Protocol opens the selected Object immediately: ${path}`, async ({ page }, info) => {
+ await traceObserves(page);
+ // No tree click or Observe action is needed on any of these entry paths.
+ await page.goto(protocolURL(info.project.use.baseURL!, route)); await ready(page);
+ const object = page.getByLabel('Object Text View');
+ await expect(object).toBeVisible();
+ await expect(object).toContainText(`name: ${JSON.stringify(name)}`);
+ await expect(panel(page)).toHaveAttribute('data-path', path);
+ await expect(panel(page)).toHaveAttribute('data-observes', '1');
+ await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+ await expect(page.locator('[data-protocol-status]')).toBeHidden();
+ expect(await observedCalls(page)).toEqual([{ path, depth: 1 }]);
+ await page.getByRole('button', { name: 'Read', exact: true }).click();
+ await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
+ await page.getByRole('button', { name: 'Protocol', exact: true }).click(); await ready(page);
+ await expect(object).toBeVisible();
+ await expect(object).toContainText(`name: ${JSON.stringify(name)}`);
+ expect(await observedCalls(page)).toEqual([{ path, depth: 1 }, { path, depth: 1 }]);
+ await expect(panel(page)).toHaveAttribute('data-observes', '1'); // Fresh Client cache.
+ await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+ await page.reload(); await ready(page);
+ await expect(object).toBeVisible();
+ await expect(object).toContainText(`name: ${JSON.stringify(name)}`);
+ await expect(page.locator('[data-protocol-status]')).toBeHidden();
+ expect(await observedCalls(page)).toEqual([{ path, depth: 1 }]);
+ });
+}
+
+test('Object selection shows an unboxed Text View without Observe forms or step panels', async ({ page }, info) => {
+ await page.goto(protocolURL(info.project.use.baseURL!, 'sources/')); await ready(page);
+ const object = page.getByLabel('Object Text View');
+ await expect(object).toBeVisible();
+ await expect(object).toContainText('object {');
+ await expect(object).toContainText('name: "sources"');
+ await expect(object).toHaveCSS('border-top-width', '0px');
+ await expect(object).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
+ await expect(panel(page).locator('fieldset, legend, h2, h3, [data-protocol-path], [data-protocol-depth], [data-protocol-observe], [data-protocol-stats], [data-protocol-children]')).toHaveCount(0);
+ await expect(page.locator('[data-protocol-status]')).toBeHidden();
+ await expect(page.locator('[data-protocol-operation-controls]')).toBeHidden();
+ await expect(page.getByLabel('Operation result')).toBeHidden();
+ await expect(page.getByRole('button', { name: '? /::document', exact: true })).toBeVisible();
+ await page.screenshot({ path: info.outputPath('protocol-object.png'), fullPage: true });
+ await page.getByRole('button', { name: '? /::document', exact: true }).click();
+ await expect(page.getByLabel('Interface Text View')).toBeVisible();
+ await expect(page.locator('[data-protocol-operation-controls]')).toBeVisible();
+ await expect(page.getByLabel('Operation result')).toBeHidden();
+ await page.getByRole('button', { name: 'v /::document', exact: true }).click();
+ await expect(page.locator('[data-protocol-operation-controls]')).toBeHidden();
+ await expect(object).toBeVisible();
+});
+
+test('tree selection automatically observes Depth 1 only, including root, nested paths and reselection', async ({ page }, info) => {
+ await traceObserves(page);
+ await page.goto(protocolURL(info.project.use.baseURL!)); await ready(page);
+ const expected: { path: string; depth: number }[] = [{ path: '/', depth: 1 }];
+ for (const [display, canonicalPath] of [['/', '/'], ['/book', '/book'], ['/1-concept.md', '/book/1-concept.md'], ['/sources', '/sources'], ['/packages', '/packages']]) {
+ await page.locator(`.demo-node[data-path="${display}"]`).click();
+ await ready(page);
+ await expect(panel(page)).toHaveAttribute('data-path', canonicalPath);
+ await expect(page.getByLabel('Object Text View')).toContainText('object {');
+ await expect(page.getByRole('button', { name: '? /::document', exact: true })).toBeEnabled();
+ await expect(page.getByLabel('Interface Text View')).toBeHidden();
+ await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+ await expect(page.locator('[data-protocol-call]')).toBeDisabled();
+ expected.push({ path: canonicalPath, depth: 1 });
+ expect(await observedCalls(page)).toEqual(expected);
+ }
+ await page.locator('[data-interface-toggle]').first().click();
+ await page.getByLabel('Operation', { exact: true }).selectOption('read');
+ await page.locator('[data-protocol-call]').click();
+ await expect(panel(page)).toHaveAttribute('data-calls', '1');
+ // Selecting the current Object resets the previous Interface and result.
+ await page.locator('.demo-node[data-path="/packages"]').click();
+ await expect(panel(page)).toHaveAttribute('data-path', '/packages');
+ await expect(page.getByLabel('Object Text View')).toContainText('name: "packages"');
+ await expect(page.getByRole('button', { name: '> /::document', exact: true })).toBeVisible();
+ await expect(page.getByLabel('Interface Text View')).toBeHidden();
+ await expect(page.getByLabel('Operation result')).toBeHidden();
+ await expect(panel(page)).toHaveAttribute('data-calls', '1');
+ expected.push({ path: '/packages', depth: 1 });
+ expect(await observedCalls(page)).toEqual(expected);
+ await page.goBack(); await ready(page);
+ await expect(panel(page)).toHaveAttribute('data-path', '/sources');
+ await expect(page.getByLabel('Object Text View')).toContainText('name: "sources"');
+ expected.push({ path: '/sources', depth: 1 });
+ expect(await observedCalls(page)).toEqual(expected);
+});
+
+test('pending tree observations wait for WASM and only the latest selection runs', async ({ page }, info) => {
+ await traceObserves(page);
+ let release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ await page.route('**/wip-wasm/*.wasm', async route => { await gate; await route.continue(); });
+ try {
+ await page.goto(protocolURL(info.project.use.baseURL!));
+ await expect(panel(page)).toHaveAttribute('data-state', 'loading');
+ await page.locator('.demo-node[data-path="/"]').click();
+ await page.locator('.demo-node[data-path="/packages"]').click();
+ await expect(page).toHaveURL(protocolURL(info.project.use.baseURL!, 'packages/'));
+ await expect(panel(page)).toHaveAttribute('data-path', '/packages');
+ await page.locator('.demo-node[data-path="/sources"]').click();
+ await expect(page).toHaveURL(protocolURL(info.project.use.baseURL!, 'sources/'));
+ await expect(panel(page)).toHaveAttribute('data-path', '/sources');
+ release(); await ready(page);
+ await expect(page.getByLabel('Object Text View')).toContainText('name: "sources"');
+ expect(await observedCalls(page)).toEqual([{ path: '/sources', depth: 1 }]);
+ await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+ } finally { release(); }
+});
+
+test('Interface clicks fetch unknown signatures and fold/reopen acquired ones without another exchange', async ({ page }, info) => {
+ // Read's automatic acquisition must not mark the manual session as known.
+ await page.goto(info.project.use.baseURL!);
+ await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
+ await page.getByRole('button', { name: 'Protocol', exact: true }).click(); await ready(page);
+ await expect(page.getByLabel('Object Text View')).toBeVisible();
+ const toggle = page.locator('[data-interface-toggle]');
+ const view = page.getByLabel('Interface Text View');
+ await expect(toggle).toHaveText('? /::document');
+ await expect(page.locator('.protocol-interface-scope')).toHaveCount(0);
+ await expect(page.locator('[data-protocol-interfaces]')).not.toContainText('scope:');
+ await expect(toggle).not.toHaveAttribute('aria-describedby');
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ await expect(view).toBeHidden();
+ await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
+ await toggle.focus(); await page.keyboard.press('Enter');
+ await expect(toggle).toHaveText('v /::document');
+ await expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ await expect(view).toBeVisible();
+ await expect(view).toContainText('interface "/::document"');
+ const signature = await view.textContent();
+ await expect(panel(page)).toHaveAttribute('data-interfaces', '1');
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+ await expect(page.getByLabel('Operation', { exact: true })).toBeEnabled();
+ await toggle.focus(); await page.keyboard.press('Space');
+ await expect(toggle).toHaveText('> /::document');
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ await expect(view).toBeHidden();
+ await expect(page.getByLabel('Operation', { exact: true })).toBeDisabled();
+ await toggle.click();
+ await expect(view).toHaveText(signature!);
+ await expect(panel(page)).toHaveAttribute('data-interfaces', '1');
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+ await page.locator('.demo-node[data-path="/sources"]').click(); await ready(page);
+ await expect(toggle).toHaveText('> /::document');
+ await expect(view).toBeHidden();
+ await toggle.click();
+ await expect(view).toHaveText(signature!);
+ await expect(panel(page)).toHaveAttribute('data-interfaces', '1');
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+ await page.reload(); await ready(page);
+ await expect(page.getByLabel('Object Text View')).toBeVisible();
+ await expect(toggle).toHaveText('? /::document');
+ await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
+});
+
+test('failed Interface acquisition remains unknown and cannot retain a previous Operation target', async ({ page }, info) => {
+ // Advertise an additional unsupported reference at the UI boundary. Its
+ // acquisition still goes through the real WASM Client/Host failure path.
+ const missingName = '
';
+ await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
+ const response = await route.fetch();
+ await route.fulfill({ response, body: `${await response.text()}\nconst originalObserve = BrowserSession.prototype.observe; BrowserSession.prototype.observe = function(...args) { const value = JSON.parse(originalObserve.apply(this, args)); value.observation.object.interfaces.push({scope: '/', name: ${JSON.stringify(missingName)}}); return JSON.stringify(value); };` });
+ });
+ await page.goto(protocolURL(info.project.use.baseURL!)); await ready(page);
+ await page.locator('.demo-node[data-path="/"]').click();
+ await page.getByRole('button', { name: '? /::document', exact: true }).click();
+ await page.getByLabel('Operation', { exact: true }).selectOption('read');
+ const missing = page.getByRole('button', { name: `? /::${missingName}`, exact: true });
+ await missing.click();
+ await expect(missing).toHaveAttribute('aria-expanded', 'false');
+ await expect(page.getByRole('button', { name: '> /::document', exact: true })).toBeVisible();
+ await expect(page.locator('[data-protocol-status]')).toContainText('protocol');
+ await expect(page.locator('[data-interface-view]:visible')).toHaveCount(0);
+ await expect(page.locator('[data-protocol-call]')).toBeDisabled();
+ await expect(page.getByLabel('Operation result')).toBeHidden();
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+ await expect(page.locator('[data-protocol-interfaces] img')).toHaveCount(0);
+ expect(await page.evaluate(() => '__interfaceExecuted' in window)).toBe(false);
+ await page.getByRole('button', { name: '> /::document', exact: true }).click();
+ await expect(page.locator('[data-interface-view]:visible')).toContainText('interface "/::document"');
+ await expect(page.getByLabel('Operation', { exact: true })).toBeEnabled();
+});
+
+test('Protocol observes automatically but acquires Interfaces and calls Operations only explicitly', async ({ page }, info) => {
+ const unexpected: string[] = [];
+ page.on('pageerror', error => unexpected.push(error.message));
+ // Catch accidental use of the automatic convenience path, including hidden
+ // normal-mode reads whose separate Client counters would not reveal them.
+ await page.route('**/wip-wasm/wip_browser.js*', async route => {
+ const response = await route.fetch();
+ await route.fulfill({ response, body: `${await response.text()}\nfor (const key of ['tree', 'read']) { const original = BrowserSession.prototype[key]; BrowserSession.prototype[key] = function(...args) { window.__autoReads = (window.__autoReads || 0) + 1; return original.apply(this, args); }; }` });
+ });
+ await page.goto(protocolURL(info.project.use.baseURL!));
+ await ready(page);
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+ await expect(panel(page)).toHaveAttribute('data-observes', '1');
+ await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
+ await descriptor(page);
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+ await expect(panel(page)).toHaveAttribute('data-observes', '1');
+ await expect(panel(page)).toHaveAttribute('data-interfaces', '1');
+ await page.locator('[data-protocol-call]').click();
+ await expect(panel(page)).toHaveAttribute('data-calls', '1');
+ const result = JSON.parse(await page.getByLabel('Operation result').innerText());
+ expect(result.path).toBe('/');
+ expect(result.operation).toBe('read');
+ expect(result.interface).toEqual({ scope: '/', name: 'document' });
+ expect(JSON.stringify(result.result)).toContain('Worldspace');
+ expect(JSON.stringify(result.result)).toContain('html');
+ expect(await page.evaluate(() => (window as Window & { __autoReads?: number }).__autoReads ?? 0)).toBe(0);
+ expect(unexpected).toEqual([]);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+ await panel(page).evaluate(element => { element.scrollTop = 0; });
+ await page.screenshot({ path: info.outputPath('protocol.png'), fullPage: true });
+});
+
+for (const route of ['book/1-concept/', 'sources/', 'packages/']) {
+ test(`manual read of ${route} agrees with normal content without creating child catalog Objects`, async ({ page }, info) => {
+ await page.goto(protocolURL(info.project.use.baseURL!, route));
+ await ready(page);
+ const expectedTitle = await page.locator('[data-selected-content] h1').textContent();
+ await descriptor(page);
+ await page.locator('[data-protocol-call]').click();
+ await expect(panel(page)).toHaveAttribute('data-calls', '1');
+ await expect(page.getByLabel('Operation result')).toContainText(expectedTitle!);
+ await page.getByRole('button', { name: 'Read', exact: true }).click();
+ await expect(page.locator('[data-selected-content]')).toBeVisible();
+ await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
+ await page.getByRole('button', { name: 'Protocol', exact: true }).click();
+ await ready(page);
+ await expect(panel(page)).toHaveAttribute('data-calls', '1');
+ await expect(page.getByLabel('Operation result')).toBeHidden();
+ });
+}
+
+test('selection, history, reload and mode switches never replay an operation', async ({ page }, info) => {
+ const base = info.project.use.baseURL!;
+ await page.goto(protocolURL(base));
+ await ready(page);
+ await descriptor(page);
+ await page.locator('[data-protocol-call]').click();
+ await expect(panel(page)).toHaveAttribute('data-calls', '1');
+ for (const name of ['sources', 'packages']) {
+ await page.locator(`.demo-node[data-path="/${name}"]`).click();
+ await expect(page).toHaveURL(protocolURL(base, `${name}/`));
+ await ready(page);
+ await expect(panel(page)).toHaveAttribute('data-calls', '1');
+ await expect(page.getByLabel('Operation result')).toBeHidden();
+ await expect(page.locator('[data-protocol-call]')).toBeDisabled();
+ }
+ await page.goBack(); await ready(page);
+ await expect(panel(page)).toHaveAttribute('data-path', '/sources');
+ await expect(panel(page)).toHaveAttribute('data-calls', '1');
+ await page.goForward(); await ready(page);
+ await expect(panel(page)).toHaveAttribute('data-path', '/packages');
+ await expect(panel(page)).toHaveAttribute('data-calls', '1');
+ await page.reload(); await ready(page);
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+ await expect(page.getByLabel('Operation result')).toBeHidden();
+});
+
+test('fragment history synchronizes Read and Protocol without requiring an Astro swap', async ({ page }, info) => {
+ await page.goto(new URL('book/1-concept/', info.project.use.baseURL!).href);
+ await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
+ // Use a real same-document link so Astro records its fragment shortcut state.
+ await page.evaluate(() => {
+ const heading = document.querySelector('[data-selected-content] [id]')!;
+ const link = document.createElement('a'); link.href = `#${heading.id}`; link.textContent = 'fragment regression';
+ document.querySelector('[data-selected-content]')!.append(link); link.click();
+ });
+ await expect(page).toHaveURL(/#/);
+ await page.getByRole('button', { name: 'Protocol', exact: true }).click(); await ready(page);
+ await page.goBack();
+ await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-mode', 'read');
+ await expect(panel(page)).toBeHidden();
+ await expect(page.locator('[data-selected-content]')).toBeVisible();
+ await page.goForward(); await ready(page);
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+});
+
+test('history into Protocol cancels deferred normal dispatch before the destination swaps', async ({ page }, info) => {
+ await traceObserves(page);
+ const base = info.project.use.baseURL!;
+ let releaseNormal!: () => void; let releasePage!: () => void;
+ const normalGate = new Promise(resolve => { releaseNormal = resolve; });
+ const pageGate = new Promise(resolve => { releasePage = resolve; });
+ let normalRequested = false; let destinationRequested = false; let holdDestination = false;
+ await page.route('**/wip-wasm/wip_browser.js', async route => {
+ normalRequested = true;
+ const response = await route.fetch(); await normalGate;
+ await route.fulfill({ response, body: `${await response.text()}\nfor (const key of ['tree', 'read']) { const original = BrowserSession.prototype[key]; BrowserSession.prototype[key] = function(...args) { window.__autoReads = (window.__autoReads || 0) + 1; return original.apply(this, args); }; }\nwindow.__normalModuleLoaded = true;` });
+ });
+ await page.route('**/sources/?mode=protocol', async route => {
+ if (holdDestination) { destinationRequested = true; await pageGate; }
+ await route.continue();
+ });
+ try {
+ await page.goto(protocolURL(base, 'sources/')); await ready(page);
+ await page.locator('.demo-node[data-path="/packages"]').click(); await ready(page);
+ await expect(page).toHaveURL(protocolURL(base, 'packages/'));
+ await expect(page.getByLabel('Object Text View')).toContainText('name: "packages"');
+ const before = await observedCalls(page);
+ expect(before).toEqual([{ path: '/sources', depth: 1 }, { path: '/packages', depth: 1 }]);
+ await page.getByRole('button', { name: 'Read', exact: true }).click();
+ await expect.poll(() => normalRequested).toBe(true);
+ holdDestination = true;
+ await page.evaluate(() => history.back());
+ await expect.poll(() => destinationRequested).toBe(true);
+ await expect(page).toHaveURL(protocolURL(base, 'sources/'));
+ releaseNormal();
+ await expect.poll(() => page.evaluate(() => Boolean((window as Window & { __normalModuleLoaded?: boolean }).__normalModuleLoaded))).toBe(true);
+ // Wait for WASM initialization too; no sleeps and no reliance on URL-only counters.
+ await page.evaluate(async base => { const module = await import(new URL('wip-wasm/wip_browser.js', base).href); await module.default(); }, base);
+ expect(await page.evaluate(() => (window as Window & { __autoReads?: number }).__autoReads ?? 0)).toBe(0);
+ expect(await observedCalls(page)).toEqual(before);
+ releasePage(); await ready(page);
+ await expect(panel(page)).toHaveAttribute('data-path', '/sources');
+ await expect(page.getByLabel('Object Text View')).toContainText('name: "sources"');
+ expect(await observedCalls(page)).toEqual([...before, { path: '/sources', depth: 1 }]);
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+ } finally { releaseNormal(); releasePage(); }
+});
+
+test('history from Protocol to another Read route never reads the outgoing Object', async ({ page }, info) => {
+ const base = info.project.use.baseURL!;
+ let release!: () => void; let hold = false; let requested = false;
+ const gate = new Promise(resolve => { release = resolve; });
+ await page.route('**/wip-wasm/wip_browser.js', async route => {
+ const response = await route.fetch();
+ await route.fulfill({ response, body: `${await response.text()}\nconst originalRead = BrowserSession.prototype.read; BrowserSession.prototype.read = function(path) { (window.__readPaths ||= []).push(path); return originalRead.call(this, path); };` });
+ });
+ await page.route(new URL('sources/', base).href, async route => {
+ if (hold) { requested = true; await gate; } await route.continue();
+ });
+ const paths = () => page.evaluate(() => (window as Window & { __readPaths?: string[] }).__readPaths ?? []);
+ try {
+ await page.goto(new URL('sources/', base).href);
+ await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
+ await page.locator('.demo-node[data-path="/packages"]').click();
+ await expect(page).toHaveURL(new URL('packages/', base).href);
+ await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
+ await page.getByRole('button', { name: 'Protocol', exact: true }).click(); await ready(page);
+ const before = await paths(); expect(before).toEqual(['/sources', '/packages']);
+ hold = true; await page.evaluate(() => history.back());
+ await expect.poll(() => requested).toBe(true);
+ await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-mode', 'read');
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => resolve())));
+ expect(await paths()).toEqual(before);
+ release();
+ await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-current-path', '/sources');
+ await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
+ expect(await paths()).toEqual([...before, '/sources']);
+ } finally { release(); }
+});
+
+test('input and protocol errors remain visible, and tree selection clears stale controls', async ({ page }, info) => {
+ await page.route('**/worldspace.json', async route => {
+ const response = await route.fetch(); const data = await response.json();
+ data.nodes = data.nodes.filter((node: { path: string }) => node.path !== '/sources');
+ await route.fulfill({ response, json: data });
+ });
+ await page.goto(protocolURL(info.project.use.baseURL!)); await ready(page);
+ await page.locator('.demo-node[data-path="/sources"]').click(); await ready(page);
+ await expect(page.locator('[data-protocol-status]')).toBeVisible();
+ await expect(page.locator('[data-protocol-status]')).toContainText('protocol');
+ await expect(page.locator('[data-protocol-call]')).toBeDisabled();
+ await expect(page.getByLabel('Object Text View')).toBeHidden();
+ await page.locator('.demo-node[data-path="/"]').click();
+ await expect(panel(page)).toHaveAttribute('data-path', '/');
+ await ready(page);
+ await descriptor(page);
+ await page.locator('[data-protocol-arguments]').fill('{broken');
+ await page.locator('[data-protocol-call]').click();
+ await expect(page.locator('[data-protocol-status]')).toBeVisible();
+ await expect(page.locator('[data-protocol-status]')).toContainText('input');
+ await expect(panel(page)).toHaveAttribute('data-calls', '0');
+ await page.locator('.demo-node[data-path="/book"]').click(); await ready(page);
+ await expect(page.getByLabel('Object Text View')).toContainText('name: "book"');
+ await expect(page.locator('[data-protocol-operation-controls]')).toBeHidden();
+ await expect(page.locator('[data-protocol-status]')).toBeHidden();
+ await expect(page.locator('[data-protocol-call]')).toBeDisabled();
+});
+
+test('Text View and returned markup are displayed as text, including in dark mode', async ({ page }, info) => {
+ await page.emulateMedia({ colorScheme: 'dark' });
+ await page.route('**/worldspace.json', async route => {
+ const response = await route.fetch(); const data = await response.json();
+ data.nodes[0].title = '
';
+ data.nodes[0].html = 'UNSAFE RESULT
';
+ await route.fulfill({ response, json: data });
+ });
+ await page.goto(protocolURL(info.project.use.baseURL!)); await ready(page);
+ await descriptor(page);
+ await expect(page.getByLabel('Object Text View')).toContainText('
(window as Window & { __executed?: number }).__executed)).toBeUndefined();
+ await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark');
+ await page.unroute('**/worldspace.json');
+ await page.getByRole('button', { name: 'Read', exact: true }).focus();
+ await page.keyboard.press('Enter');
+ await expect(panel(page)).toBeHidden();
+ await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
+});
+
+test('asset failure leaves a clear unavailable state and readable static fallback', async ({ page }, info) => {
+ await page.route('**/wip-wasm/wip_browser.js*', route => route.abort());
+ await page.goto(protocolURL(info.project.use.baseURL!));
+ await expect(panel(page)).toHaveAttribute('data-state', 'unavailable');
+ await expect(page.getByLabel('Object Text View')).toBeHidden();
+ await page.getByRole('button', { name: 'Read', exact: true }).click();
+ await expect(page.locator('[data-selected-content]').getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
+ await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'fallback');
+});
+
+test('a trapped manual session is quarantined across routes and normal mode uses an independent instance', async ({ page }, info) => {
+ const asset = (name: string) => readFile(new URL(`../../test-assets/wip-wasm/${name}`, import.meta.url));
+ await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
+ const source = (await asset('wip_browser.js')).toString();
+ await route.fulfill({ contentType: 'text/javascript', body: `${source}\nBrowserSession.prototype.observe = function () { window.__trapCalls = (window.__trapCalls || 0) + 1; window.__trappedManual = new WeakRef(this); return this.panic_probe(); };` });
+ });
+ await page.route('**/wip-wasm/*.wasm', async route => route.fulfill({ contentType: 'application/wasm', body: await asset('wip_browser_bg.wasm') }));
+ await page.goto(protocolURL(info.project.use.baseURL!));
+ await expect(panel(page)).toHaveAttribute('data-state', 'trapped');
+ await page.locator('.demo-node[data-path="/sources"]').click();
+ await expect(panel(page)).toHaveAttribute('data-state', 'trapped');
+ await expect(page.getByLabel('Object Text View')).toBeHidden();
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send('HeapProfiler.collectGarbage');
+ expect(await page.evaluate(() => Boolean((window as unknown as { __trappedManual: WeakRef