feat: add protocol explorer with catalogs and live playground

This commit is contained in:
2026-10-19 18:21:30 +09:00
parent b82c1d0a70
commit 75a36f2298
38 changed files with 1836 additions and 83 deletions
+19
View File
@@ -0,0 +1,19 @@
import { expect, test } from '@playwright/test';
for (const javaScriptEnabled of [false, true]) {
test.describe(javaScriptEnabled ? 'WASM tree' : 'static tree', () => {
test.use({ javaScriptEnabled, reducedMotion: 'reduce' });
test('book uses its Object name without a directory suffix', async ({ page }, info) => {
await page.goto('./');
if (javaScriptEnabled) await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
const book = page.locator('.demo-node[data-path="/book"]');
await expect(book.locator('.node-label')).toHaveText('book');
await expect(book).toHaveAttribute('aria-label', 'book');
await expect(book).toHaveAttribute('href', new URL('book/', info.project.use.baseURL!).pathname);
await book.click();
await expect(page).toHaveURL(new URL('book/', info.project.use.baseURL!).href);
if (javaScriptEnabled) await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
await expect(book.locator('.node-label')).toHaveText('book');
});
});
}
+6 -2
View File
@@ -23,7 +23,8 @@ export const draftPaths = [
'/draft/typescript-api-model.md',
'/draft/unresolved-issues.md',
];
export const treePaths = ['/', '/book', ...documentPaths];
export const catalogPaths = ['/sources', '/packages'];
export const treePaths = ['/', '/book', ...documentPaths, ...catalogPaths, '/playground'];
export const treeNodeCount = treePaths.length;
export const chapterSections: Record<string, string[]> = {
'/1-concept.md': ['/1.1-interaction-abstraction.md', '/1.2-existing-abstractions.md', '/1.3-design-principles.md', '/1.4-concepts-and-scope.md'],
@@ -31,9 +32,12 @@ export const chapterSections: Record<string, string[]> = {
'/4-client.md': ['/4.1-ai-tools.md', '/4.2-text-view.md'],
'/5-transports.md': ['/5.1-wip-over-http.md'],
};
export const treeParents: Record<string, string> = Object.fromEntries(treePaths.filter((path) => path !== '/').map((path) => [path, path === '/book' ? '/' : '/book']));
export const treeParents: Record<string, string> = Object.fromEntries(treePaths.filter((path) => path !== '/').map((path) => [path, path === '/book' || catalogPaths.includes(path) || path === '/playground' ? '/' : '/book']));
for (const [chapter, sections] of Object.entries(chapterSections)) {
for (const section of sections) treeParents[section] = chapter;
}
export const rootChildCount = treePaths.filter((path) => treeParents[path] === '/').length;
export const routeURL = (path: string, base: string) => path === '/' ? new URL('./', base).href
: path === '/book' || catalogPaths.includes(path) || path === '/playground' ? new URL(`${path.slice(1)}/`, base).href : bookURL(path, base);
export const bookURL = (path: string, base: string) => new URL(`book/${path.slice(1, -3).split('/').map(encodeURIComponent).join('/')}/`, base).href;
+3 -3
View File
@@ -92,7 +92,7 @@ test.describe('static font and tree rendering', () => {
const gapEnd = new DOMPoint((3 * (level - 2) + 3) * Number(svg.dataset.cellWidth), 12).matrixTransform(svg.getScreenCTM()!);
return { x: p.x, y: p.y, end: end.x, gapEnd: gapEnd.x, level };
}));
expect(rootChildCount).toBe(1);
expect(rootChildCount).toBe(4);
expect(joins.filter(({ level }) => level === 2)).toHaveLength(rootChildCount);
expect(joins.filter(({ level }) => level === 3)).toHaveLength(Object.values(treeParents).filter((parent) => parent === '/book').length);
for (const [index, join] of joins.entries()) {
@@ -149,10 +149,10 @@ test.describe('static font and tree rendering', () => {
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
await expect(page.locator('.trunk-line')).toBeVisible();
await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ ');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├─ ');
await expect(page.locator('.branch-prefix').first().locator('path')).toHaveCount(0);
await expect(page.locator('.branch-prefix').first().locator('use')).toHaveCount(3);
await expect(page.locator('.branch-prefix').first().locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9492');
await expect(page.locator('.branch-prefix').first().locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9500');
await expect(page.locator('.tree-glyphs defs path')).toHaveCount(5);
await expect(page.locator('.site-header').getByRole('link', { name: 'Book', exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+21 -17
View File
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import { documentPaths, treePaths, treeNodeCount, bookURL, chapterSections, treeParents } from './documents';
import { documentPaths, treePaths, treeNodeCount, bookURL, routeURL, chapterSections, treeParents } from './documents';
test.use({ javaScriptEnabled: true });
@@ -11,7 +11,7 @@ const visiblePrefixes = '.node-row:not([hidden]) .branch-prefix';
async function expectNodeGlitch(page: import('@playwright/test').Page) {
expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.some((node) =>
node.querySelector('.node-label')!.textContent !== ((node as HTMLElement).dataset.path === '/book' ? 'book/' : (node as HTMLElement).dataset.path!.split('/').at(-1))
node.querySelector('.node-label')!.textContent !== ((node as HTMLElement).dataset.path === '/book' ? 'book' : (node as HTMLElement).dataset.path!.split('/').at(-1))
))).toBe(true);
}
@@ -511,7 +511,7 @@ for (const time of [1500, 2600]) {
for (const row of await page.locator('.node-row').all()) await expect(row).toHaveCSS('transform', 'none');
await expect(page.locator('.root-mark')).toHaveCSS('transform', 'none');
await expect(page.locator('.site-header')).toHaveCSS('opacity', '1');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ ');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├─ ');
await expect(page.locator('.intro-art')).toBeHidden();
await page.clock.runFor(4000);
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
@@ -533,7 +533,7 @@ test('skipping an active node glitch restores exact filenames without altering d
await page.keyboard.press('Escape');
await complete(page);
await expect(page.locator('[data-glitching]')).toHaveCount(0);
for (const path of treePaths.slice(1)) await expect(nodeAt(page, path).locator('.node-label')).toHaveText(path === '/book' ? 'book/' : path.split('/').at(-1)!);
for (const path of treePaths.slice(1)) await expect(nodeAt(page, path).locator('.node-label')).toHaveText(path === '/book' ? 'book' : path.split('/').at(-1)!);
await page.clock.runFor(2000);
expect(await page.locator(selectedContent).innerHTML()).toBe(html);
await page.locator('.root-mark').hover();
@@ -578,7 +578,7 @@ test('landing contains only the real filename tree and selected canonical conten
await expect(folder).toHaveAttribute('aria-expanded', 'true');
await expect(folder).toHaveAttribute('aria-level', path === '/' ? '1' : '2');
if (path === '/book') {
await expect(folder.locator('.node-label')).toHaveText('book/');
await expect(folder.locator('.node-label')).toHaveText('book');
const toggle = page.locator('.node-toggle[data-toggle-path="/book"]');
await expect(toggle).toHaveText('−');
expect(await toggle.evaluate((button) => button.tagName)).toBe('BUTTON');
@@ -617,7 +617,8 @@ test('landing contains only the real filename tree and selected canonical conten
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
await expect(page.locator(selectedContent + ' .root-page[data-root-page]')).toHaveCount(1);
await expect(page.locator(selectedContent)).not.toContainText('1. コンセプト');
expect(await page.locator('.landing h1, .landing h2, .landing h3, .landing h4, .landing h5, .landing h6').evaluateAll((headings) => headings.every((heading) => heading.closest('[data-selected-content]')))).toBe(true);
await expect(page.locator('[data-protocol-panel]')).toBeHidden();
expect(await page.locator('.landing :is(h1, h2, h3, h4, h5, h6):visible').evaluateAll((headings) => headings.every((heading) => heading.closest('[data-selected-content]')))).toBe(true);
await checkGlyphRefs(page);
});
@@ -630,9 +631,10 @@ test('numbered chapters nest visually, fold independently, and retain section UR
await expect(chapter).toHaveAttribute('aria-level', '3');
await expect(child).toHaveAttribute('aria-level', '4');
const prefix = child.locator('..').locator('.branch-prefix');
await expect(prefix).toHaveAttribute('data-prefix', ' │ ├─ ');
await expect(nodeAt(page, '/1.4-concepts-and-scope.md').locator('..').locator('.branch-prefix')).toHaveAttribute('data-prefix', ' │ └─ ');
await expect(prefix).toHaveAttribute('data-prefix', '│ │ ├─ ');
await expect(nodeAt(page, '/1.4-concepts-and-scope.md').locator('..').locator('.branch-prefix')).toHaveAttribute('data-prefix', '│ │ └─ ');
const before = await page.locator(selectedContent).innerHTML();
await nodeAt(page, '/1-concept.md').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click();
for (const path of chapterSections[initialPath]) await expect(nodeAt(page, path)).toBeHidden();
await expect(nodeAt(page, '/2-core-model.md')).toBeVisible();
@@ -717,7 +719,7 @@ test('keyboard arrows only move focus or fold; Enter and Space activate route li
await page.keyboard.press('ArrowLeft');
await expect(book).toHaveAttribute('aria-expanded', 'false');
for (const path of documentPaths) await expect(nodeAt(page, path)).toBeHidden();
await expect(page.getByRole('treeitem')).toHaveCount(2);
await expect(page.getByRole('treeitem')).toHaveCount(5);
await unchanged();
await checkGlyphRefs(page);
await page.keyboard.press('ArrowRight');
@@ -743,7 +745,7 @@ test('keyboard arrows only move focus or fold; Enter and Space activate route li
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
await checkGlyphRefs(page);
await page.keyboard.press('End');
const finalPath = documentPaths.at(-1)!;
const finalPath = '/playground';
const finalLeaf = nodeAt(page, finalPath);
await expect(finalLeaf).toBeFocused();
await expect(finalLeaf).toHaveAttribute('aria-selected', 'false');
@@ -754,19 +756,19 @@ test('keyboard arrows only move focus or fold; Enter and Space activate route li
if (scroll.needsScroll) expect(scroll.top).toBeGreaterThan(0);
expect(scroll.inside).toBe(true);
await page.keyboard.press('ArrowUp');
const previousPath = '/5-transports.md';
const previousPath = '/packages';
await expect(nodeAt(page, previousPath)).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(finalLeaf).toBeFocused();
await unchanged();
await expect(page.locator('.demo-node[tabindex="0"]')).toHaveCount(1);
await page.keyboard.press('Enter');
await expect(page).toHaveURL(bookURL(finalPath, testInfo.project.use.baseURL!));
await expect(page).toHaveURL(routeURL(finalPath, testInfo.project.use.baseURL!));
await expect(finalLeaf).toHaveAttribute('aria-current', 'page');
await expect(page.locator(selectedContent)).toBeVisible();
await nodeAt(page, previousPath).focus();
await page.keyboard.press('Space');
await expect(page).toHaveURL(bookURL(previousPath, testInfo.project.use.baseURL!));
await expect(page).toHaveURL(routeURL(previousPath, testInfo.project.use.baseURL!));
await expect(nodeAt(page, previousPath)).toHaveAttribute('aria-current', 'page');
await expect(page.locator(selectedContent)).toBeVisible();
});
@@ -863,14 +865,15 @@ test.describe('glyph trunk regressions', () => {
await book.focus();
await page.keyboard.press('ArrowLeft');
await expect(book).toHaveAttribute('aria-expanded', 'false');
// Book is the sole root child: its descendants never extend the first-column spine.
expect(await checkEnd('book')).toBe(expanded);
// Collapsing Book moves the following catalog leaves up and shortens the root spine.
const collapsed = await checkEnd('book');
expect(collapsed).toBeLessThan(expanded);
await page.keyboard.press('Home');
await page.keyboard.press('ArrowLeft');
await expect(page.getByRole('treeitem')).toHaveCount(1);
await checkEnd('root');
await page.keyboard.press('ArrowRight');
expect(await checkEnd('root-reopened')).toBe(expanded);
expect(await checkEnd('root-reopened')).toBe(collapsed);
await book.focus();
await page.keyboard.press('ArrowRight');
expect(await checkEnd('all-reopened')).toBe(expanded);
@@ -892,7 +895,8 @@ test.describe('JavaScript-free glyph tree', () => {
await expect(home).toContainText('Worldspace Interface Protocol');
const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true });
await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', testInfo.project.use.baseURL!).pathname);
await expect(nav.getByRole('link', { name: /^Source(?:\s|$)/ })).toHaveAttribute('href', (await page.locator('.site-header').getByRole('link', { name: 'Source', exact: true }).getAttribute('href'))!);
await expect(nav.getByRole('link', { name: /^Sources(?:\s|$)/ })).toHaveAttribute('href', new URL('sources/', testInfo.project.use.baseURL!).pathname);
await expect(nav.getByRole('link', { name: /^Packages(?:\s|$)/ })).toHaveAttribute('href', new URL('packages/', testInfo.project.use.baseURL!).pathname);
const initialHTML = await page.locator(selectedContent).innerHTML();
await expect(page.locator('.static-guides, .guide-body, .ascii-panel')).toHaveCount(0);
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
+193
View File
@@ -0,0 +1,193 @@
import { expect, test, type Page } from '@playwright/test';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
const panel = (page: Page) => page.locator('[data-protocol-panel]');
const node = (page: Page, name: string) => page.locator(`.demo-node[data-path="/playground${name ? `/${name}` : ''}"]`);
const value = (page: Page, name: string) => node(page, name).locator('.node-value');
const url = (base: string, child?: string) => {
const result = new URL('playground/?mode=protocol', base);
if (child) result.searchParams.set('object', `/playground/${child}`);
return result.href;
};
async function ready(page: Page, path = '/playground') {
await expect(panel(page)).toHaveAttribute('data-state', 'ready');
await expect(panel(page)).toHaveAttribute('data-path', path);
await expect(page.getByLabel('Object Text View')).toBeVisible();
}
async function execute(page: Page, iface: string, operation: string, args?: object) {
const toggle = page.getByRole('button', { name: new RegExp(`^[?>v] /::${iface}$`) });
if (await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click();
await page.getByLabel('Operation', { exact: true }).selectOption(operation);
if (args) await page.getByLabel('Arguments (tagged JSON record)').fill(JSON.stringify(args));
const calls = Number(await panel(page).getAttribute('data-calls'));
await page.getByRole('button', { name: 'Execute Operation', exact: true }).click();
await expect(page.getByLabel('Operation result')).toBeVisible();
await expect(panel(page)).toHaveAttribute('data-calls', String(calls + 1));
await expect(page.locator('[data-protocol-status]')).toBeHidden();
return JSON.parse(await page.getByLabel('Operation result').innerText());
}
const nameArg = (name: string) => ({ name: { kind: 'string', value: name } });
const amountArg = (amount: string) => ({ amount: { kind: 'integer', value: amount } });
test('collection and counter mutations update the tree through actual Observe', async ({ page }, info) => {
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 observe = BrowserSession.prototype.observe; BrowserSession.prototype.observe = function(path, depth) { (window.__observations ||= []).push({path,depth}); return observe.call(this,path,depth); };` });
});
await page.goto(url(info.project.use.baseURL!)); await ready(page);
await expect(value(page, 'apple')).toHaveText('3');
await expect(value(page, 'orange')).toHaveText('0');
await expect(page.getByRole('button', { name: '? /::collection', exact: true })).toBeVisible();
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await execute(page, 'collection', 'create', nameArg('banana'));
await expect(value(page, 'banana')).toHaveText('0');
await node(page, 'banana').click(); await ready(page, '/playground/banana');
await expect(node(page, 'banana')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('button', { name: '? /::counter', exact: true })).toBeVisible();
const before = Number(await panel(page).getAttribute('data-observes'));
const increment = await execute(page, 'counter', 'increment', amountArg('2'));
expect(increment.result).toEqual({ kind: 'integer', value: '2' });
await expect(value(page, 'banana')).toHaveText('2');
await expect(node(page, 'banana').locator('.node-label')).toHaveText('banana');
await expect(page.getByLabel('Object Text View')).toContainText('description: "2"');
expect(Number(await panel(page).getAttribute('data-observes'))).toBeGreaterThan(before);
expect(await page.evaluate(() => (window as Window & { __observations?: unknown[] }).__observations?.slice(-2)))
.toEqual([{ path: '/playground', depth: 1 }, { path: '/playground/banana', depth: 1 }]);
await execute(page, 'counter', 'increment', amountArg('-1'));
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '1' });
await execute(page, 'counter', 'reset');
await expect(value(page, 'banana')).toHaveText('0');
await page.screenshot({ path: info.outputPath('playground-counter.png'), fullPage: true });
await node(page, '').click(); await ready(page);
await execute(page, 'collection', 'remove', nameArg('banana'));
await expect(node(page, 'banana')).toHaveCount(0);
await execute(page, 'collection', 'create', nameArg('banana'));
await expect(value(page, 'banana')).toHaveText('0');
await node(page, 'banana').click(); await ready(page, '/playground/banana');
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '0' });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
test('state survives selection, history and Read mode, but reload resets the session', async ({ page }, info) => {
const base = info.project.use.baseURL!;
await page.goto(url(base, 'apple')); await ready(page, '/playground/apple');
await execute(page, 'counter', 'increment', amountArg('4'));
await expect(value(page, 'apple')).toHaveText('7');
await node(page, 'orange').click(); await ready(page, '/playground/orange');
await page.goBack(); await ready(page, '/playground/apple');
await expect(value(page, 'apple')).toHaveText('7');
await page.goForward(); await ready(page, '/playground/orange');
await page.getByRole('button', { name: 'Read', exact: true }).click();
await expect(page.locator('[data-selected-content] h1')).toHaveText('Playground');
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
await expect(page.locator('[data-playground-row]')).toHaveCount(0);
await page.getByRole('button', { name: 'Protocol', exact: true }).click(); await ready(page, '/playground/orange');
await expect(value(page, 'apple')).toHaveText('7');
await page.locator('.demo-node[data-path="/book"]').click();
await ready(page, '/book');
await expect(value(page, 'apple')).toHaveText('7');
await node(page, 'apple').click(); await ready(page, '/playground/apple');
await expect(value(page, 'apple')).toHaveText('7');
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await page.reload(); await ready(page, '/playground/apple');
await expect(value(page, 'apple')).toHaveText('3');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
});
test('invalid operations do not alter the tree and deleted history does not resurrect a counter', async ({ page }, info) => {
await page.goto(url(info.project.use.baseURL!)); await ready(page);
await execute(page, 'collection', 'create', nameArg('temporary'));
await node(page, 'temporary').click(); await ready(page, '/playground/temporary');
await node(page, '').click(); await ready(page);
await execute(page, 'collection', 'remove', nameArg('temporary'));
await page.goBack();
await expect(panel(page)).toHaveAttribute('data-path', '/playground/temporary');
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(page.getByLabel('Object Text View')).toBeHidden();
await expect(node(page, 'temporary')).toHaveCount(0);
await node(page, '').click(); await ready(page);
await page.getByRole('button', { name: '> /::collection', exact: true }).click();
for (const invalid of ['apple', '../book', '<img src=x onerror=alert(1)>']) {
await page.getByLabel('Operation', { exact: true }).selectOption('create');
await page.getByLabel('Arguments (tagged JSON record)').fill(JSON.stringify(nameArg(invalid)));
await page.getByRole('button', { name: 'Execute Operation', exact: true }).click();
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(page.getByLabel('Operation result')).toBeHidden();
await expect(page.locator('[data-playground-row]')).toHaveCount(2);
await expect(value(page, 'apple')).toHaveText('3');
}
await expect(panel(page).locator('img')).toHaveCount(0);
await node(page, 'apple').click(); await ready(page, '/playground/apple');
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
await page.getByLabel('Operation', { exact: true }).selectOption('increment');
await page.getByLabel('Arguments (tagged JSON record)').fill(JSON.stringify(amountArg('9223372036854775807')));
await page.getByRole('button', { name: 'Execute Operation', exact: true }).click();
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(value(page, 'apple')).toHaveText('3');
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '3' });
});
test('dynamic children participate in tree keyboard navigation and folding', async ({ page }, info) => {
await page.goto(url(info.project.use.baseURL!)); await ready(page);
await page.locator('.demo-node[data-path="/book"]').click(); await ready(page, '/book');
await node(page, 'apple').focus(); await page.keyboard.press('Enter');
await ready(page, '/playground/apple');
await expect(node(page, 'apple')).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(node(page, 'orange')).toBeFocused();
await node(page, '').focus();
await page.keyboard.press('ArrowRight');
await expect(node(page, 'apple')).toBeFocused();
await page.keyboard.press('Enter'); await ready(page, '/playground/apple');
await expect(node(page, 'apple')).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(node(page, 'orange')).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(node(page, '')).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(node(page, 'apple')).toBeHidden();
await expect(node(page, '')).toHaveAttribute('aria-expanded', 'false');
await page.keyboard.press('ArrowRight');
await expect(node(page, 'apple')).toBeVisible();
});
for (const failurePath of ['/playground', '/playground/apple']) test(`failed refresh at ${failurePath} preserves success without replaying the mutation`, async ({ page }, info) => {
await page.emulateMedia({ colorScheme: 'dark' });
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 call = BrowserSession.prototype.call_operation; const observe = BrowserSession.prototype.observe; let failRefresh = false; BrowserSession.prototype.call_operation = function(...args) { const result = call.apply(this,args); failRefresh = true; return result; }; BrowserSession.prototype.observe = function(...args) { if(failRefresh && args[0] === ${JSON.stringify(failurePath)}) { failRefresh = false; throw new Error('refresh unavailable'); } return observe.apply(this,args); };` });
});
await page.goto(url(info.project.use.baseURL!, 'apple')); await ready(page, '/playground/apple');
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
await page.getByLabel('Operation', { exact: true }).selectOption('increment');
expect(JSON.parse(await page.getByLabel('Arguments (tagged JSON record)').inputValue())).toEqual(amountArg('1'));
await page.getByRole('button', { name: 'Execute Operation', exact: true }).click();
await expect(page.locator('[data-protocol-status]')).toContainText('Operationは成功しましたが再Observeに失敗');
expect(JSON.parse(await page.getByLabel('Operation result').innerText()).result).toEqual({ kind: 'integer', value: '4' });
await expect(panel(page)).toHaveAttribute('data-calls', '1');
// Stale observations and target readiness are retired, not inferred from the Call result.
if (failurePath === '/playground') await expect(node(page, 'apple')).toHaveCount(0);
else await expect(value(page, 'apple')).toHaveText('4');
await expect(page.getByRole('button', { name: 'Execute Operation', exact: true })).toBeDisabled();
await expect(page.getByLabel('Operation', { exact: true })).toBeDisabled();
await expect(page.getByLabel('Object Text View')).toBeHidden();
await page.locator('.demo-node[data-path="/book"]').click(); await ready(page, '/book');
if (failurePath === '/playground') await expect(node(page, 'apple')).toHaveCount(0);
else await expect(value(page, 'apple')).toHaveText('4');
await node(page, '').click(); await ready(page);
await node(page, 'apple').click(); await ready(page, '/playground/apple');
await expect(value(page, 'apple')).toHaveText('4');
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await expect(page.locator('[data-protocol-status]')).toBeHidden();
});
test.describe('static explanation', () => {
test.use({ javaScriptEnabled: false });
test('Playground is readable without claiming live counter state', async ({ page }, info) => {
await page.goto(url(info.project.use.baseURL!, 'apple'));
await expect(page.locator('[data-selected-content] h1')).toHaveText('Playground');
await expect(page.locator('[data-selected-content]')).toContainText('再読み込みで初期状態');
await expect(panel(page)).toBeHidden();
await expect(page.locator('[data-playground-row]')).toHaveCount(0);
});
});
+139
View File
@@ -0,0 +1,139 @@
import { expect, test } from '@playwright/test';
test.use({ javaScriptEnabled: true });
test('manual exchanges are explicit, cached, session-local, and return genuine signatures', 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 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 normal = new module.BrowserSession(manifest);
const manual = new module.BrowserSession(manifest);
const data = JSON.parse(manifest);
const node = data.nodes.find((n: { path: string }) => n.path !== '/');
const originalFetch = window.fetch;
window.fetch = () => { throw new Error('Manual protocol must not use a network transport'); };
try {
normal.tree();
const initial = JSON.parse(manual.stats());
let missing;
try { manual.call_operation(node.path, '/', 'document', 'read', '{}'); }
catch (error) { missing = JSON.parse(String(error)); }
const observed = JSON.parse(manual.observe(node.path, 0));
const cachedObject = JSON.parse(manual.observe(node.path, 0));
const descriptor = JSON.parse(manual.fetch_interface('/', 'document'));
const cachedInterface = JSON.parse(manual.fetch_interface('/', 'document'));
const called = JSON.parse(manual.call_operation(node.path, '/', 'document', 'read', '{}'));
const second = JSON.parse(manual.call_operation(node.path, '/', 'document', 'read', '{}'));
const result = { initial, missing, observed, cachedObject, descriptor, cachedInterface, called, second, node };
normal.free();
manual.free();
return result;
} finally { window.fetch = originalFetch; }
}, base);
expect(result.initial).toEqual({ observes: 0, interfaces: 0, calls: 0 });
expect(result.missing).toMatchObject({ kind: 'client', code: 'MissingObservation' });
expect(result.observed).toMatchObject({ path: result.node.path, cached: false, stats: { observes: 1, interfaces: 0, calls: 0 } });
expect(result.observed.observation.object).toMatchObject({ name: result.node.name, description: result.node.title, interfaces: [{ scope: '/', name: 'document' }] });
expect(result.observed.observation.children).toBeNull();
expect(result.observed.observation.object).not.toHaveProperty('html');
expect(result.observed.textView).toContain('interfaces: ["/::document"];');
expect(result.observed.textView).not.toContain('validator:');
expect(result.observed.textView).not.toContain('ref:');
expect(result.cachedObject).toEqual({ ...result.observed, cached: true });
expect(result.descriptor).toMatchObject({ reference: { scope: '/', name: 'document' }, cached: false, stats: { observes: 1, interfaces: 1, calls: 0 } });
expect(result.descriptor.descriptor.operations).toEqual([expect.objectContaining({ name: 'read', parameters: [] })]);
expect(result.descriptor.textView).toMatch(/^interface "\/::document" \{\n/m);
expect(result.descriptor.textView).toContain('read(');
expect(result.cachedInterface).toEqual({ ...result.descriptor, cached: true });
expect(result.called).toMatchObject({ path: result.node.path, interface: { scope: '/', name: 'document' }, operation: 'read', result: { kind: 'record', value: { path: { kind: 'string', value: result.node.path }, html: { kind: 'string', value: result.node.html } } }, stats: { observes: 1, interfaces: 1, calls: 1 } });
expect(result.called).not.toHaveProperty('textView');
expect(result.second.stats).toEqual({ observes: 1, interfaces: 1, calls: 2 });
});
test('manual errors distinguish input, Client, Protocol and renderer failures without automatic work', 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 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 attempt = (fn: () => unknown) => {
try { fn(); return { kind: 'unexpected success' }; }
catch (error) {
if (error instanceof WebAssembly.RuntimeError) throw error;
return JSON.parse(String(error));
}
};
const missingObject = attempt(() => session.call_operation('/', '/', 'document', 'read', '{}'));
const depth = [-1, 0.1, 33, NaN, Infinity, 2 ** 32].map((d) => attempt(() => session.observe('/', d)));
const oversizePath = attempt(() => session.observe('/'.repeat(4097), 0));
const malformedPath = attempt(() => session.observe('relative', 0));
const unknownPath = attempt(() => session.observe('/not-published-protocol-test', 0));
const unknownInterface = attempt(() => session.fetch_interface('/', 'not-published-protocol-test'));
const afterMissing = JSON.parse(session.stats());
const retainedPath = attempt(() => session.observe('/not-published-protocol-test', 0));
const retainedInterface = attempt(() => session.fetch_interface('/', 'not-published-protocol-test'));
const afterRetained = JSON.parse(session.stats());
session.observe('/', 0);
const missingInterface = attempt(() => session.call_operation('/', '/', 'document', 'read', '{}'));
session.fetch_interface('/', 'document');
const unknownOperation = attempt(() => session.call_operation('/', '/', 'document', 'unknown', '{}'));
const mismatchedInterface = attempt(() => session.call_operation('/', '/', 'other', 'read', '{}'));
const invalidArguments = attempt(() => session.call_operation('/', '/', 'document', 'read', '{"unexpected":{"kind":"integer","value":"9007199254740993"}}'));
const malformedArguments = ['{', '[]', '{"x":42}', '{"x":{"kind":"integer","value":9007199254740993}}', '{"x":{"kind":"bytes","value":[256]}}', ' '.repeat(65537)].map((args) => attempt(() => session.call_operation('/', '/', 'document', 'read', args)));
const beforeCall = JSON.parse(session.stats());
const called = JSON.parse(session.call_operation('/', '/', 'document', 'read', '{}'));
session.free();
const data = JSON.parse(manifest);
data.nodes.find((n: { path: string }) => n.path === '/').title = 'x'.repeat(1024 * 1024 + 1);
const large = new module.BrowserSession(JSON.stringify(data));
const render = attempt(() => large.observe('/', 0));
const renderStats = JSON.parse(large.stats());
large.free();
return { missingObject, missingInterface, depth, oversizePath, malformedPath, unknownPath, unknownInterface, afterMissing, retainedPath, retainedInterface, afterRetained, unknownOperation, mismatchedInterface, invalidArguments, malformedArguments, beforeCall, called, render, renderStats };
}, base);
expect(result.missingObject).toMatchObject({ kind: 'client', code: 'MissingObservation' });
expect(result.missingInterface).toMatchObject({ kind: 'client', code: 'MissingObservation' });
for (const error of [...result.depth, result.oversizePath, ...result.malformedArguments]) expect(error.kind).toBe('input');
expect(result.malformedPath).toMatchObject({ kind: 'client', code: 'InvalidRequest' });
expect(result.unknownPath).toMatchObject({ kind: 'protocol', code: 'NotFound' });
expect(result.unknownInterface).toMatchObject({ kind: 'protocol', code: 'InterfaceNotFound' });
expect(result.afterMissing).toEqual({ observes: 1, interfaces: 1, calls: 0 });
expect(result.afterRetained).toEqual(result.afterMissing);
expect(result.retainedPath.kind).toBe('client');
expect(result.retainedInterface.kind).toBe('client');
for (const error of [result.unknownOperation, result.mismatchedInterface, result.invalidArguments]) expect(error.kind).toBe('client');
expect(result.beforeCall.calls).toBe(0);
expect(result.called.stats.calls).toBe(1);
expect(result.render.kind).toBe('render');
expect(result.renderStats).toEqual({ observes: 1, interfaces: 0, calls: 0 });
});
test('Rust Text View preserves Unicode, whitespace and quoted escapes without becoming HTML', 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 title = ' 引用 "quote" \\ space\nnext\tline <script>window.__textViewExecuted=true</script>';
const session = new module.BrowserSession(JSON.stringify({ nodes: [{ path: '/', name: '', displayPath: '/', title, href: '/', html: '<p>body only on call</p>' }] }));
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 <script>window.__textViewExecuted=true</script>";\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 });
});
+484
View File
@@ -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<void>(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 = '<img src=x onerror=window.__interfaceExecuted=1>';
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<void>(resolve => { releaseNormal = resolve; });
const pageGate = new Promise<void>(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<void>(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<void>(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 = '<img src=x onerror="window.__executed=1">';
data.nodes[0].html = '<script>window.__executed=1</script><p>UNSAFE RESULT</p>';
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('<img');
await page.locator('[data-protocol-call]').click();
await expect(page.getByLabel('Operation result')).toContainText('UNSAFE RESULT');
await expect(panel(page).locator('img, script')).toHaveCount(0);
expect(await page.evaluate(() => (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<object> }).__trappedManual.deref()))).toBe(true);
expect(await page.evaluate(() => (window as Window & { __trapCalls?: number }).__trapCalls)).toBe(1);
await page.getByRole('button', { name: 'Read', exact: true }).click();
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
await cdp.detach();
});
test.describe('without JavaScript', () => {
test.use({ javaScriptEnabled: false });
test('protocol URL retains static reading and does not claim execution', async ({ page }, info) => {
await page.goto(protocolURL(info.project.use.baseURL!, 'sources/'));
await expect(page.locator('[data-selected-content]')).toBeVisible();
await expect(page.locator('[data-mode-switch]')).toBeHidden();
await expect(panel(page)).toBeHidden();
await expect(page.locator('[data-landing-stage]')).not.toHaveAttribute('data-wip-state');
});
});
+16 -12
View File
@@ -1,6 +1,6 @@
import { expect, test, type Page } from '@playwright/test';
import { load } from 'cheerio';
import { bookURL, documentPaths, draftPaths, treeNodeCount, treePaths } from './documents';
import { bookURL, documentPaths, draftPaths, treeNodeCount, treePaths, routeURL } from './documents';
test.use({ javaScriptEnabled: true });
@@ -8,8 +8,7 @@ const stage = '[data-landing-stage]';
const article = 'article.selected-content.prose[data-selected-content]';
const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`);
const toggle = (page: Page) => page.locator('button.node-toggle[data-toggle-path="/book"]');
const urlFor = (path: string, base: string) => path === '/' ? new URL('./', base).href
: path === '/book' ? new URL('book/', base).href : bookURL(path, base);
const urlFor = routeURL;
async function settled(page: Page, path: string) {
await expect(page.locator(stage)).toHaveAttribute('data-current-path', path);
@@ -82,11 +81,10 @@ test('home response contains independent root content and the unchanged public t
expect(copy).toMatch(/インタフェース/);
const nav = home.find('nav[aria-label="ルートページのナビゲーション"]');
expect(nav.length).toBe(1);
expect(nav.find('a').length).toBe(2);
expect(nav.find('a').length).toBe(3);
expect(nav.find('a').filter((_, el) => $(el).text().includes('Book')).attr('href')).toBe(new URL('book/', base).pathname);
const source = $('.site-header a').filter((_, el) => $(el).text().includes('Source')).attr('href');
expect(source).toMatch(/^https:\/\//);
expect(nav.find('a').filter((_, el) => $(el).text().includes('Source')).attr('href')).toBe(source);
expect(nav.find('a').filter((_, el) => $(el).text().includes('Sources')).attr('href')).toBe(new URL('sources/', base).pathname);
expect(nav.find('a').filter((_, el) => $(el).text().includes('Packages')).attr('href')).toBe(new URL('packages/', base).pathname);
expect($(article).html()).not.toBe(chapter(article).html());
expect($(article).text()).not.toContain('1. コンセプト');
const chapterCopy = chapter(article).find('p').filter((_, el) => Boolean(chapter(el).text().trim())).first().text().trim();
@@ -131,11 +129,10 @@ test('root content navigation works without changing the tree or overflowing nar
await expect(home.locator('p').filter({ hasText: 'Worldspace Interface Protocol' })).toBeVisible();
await expect(page.locator(article)).not.toContainText('1. コンセプト');
const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true });
await expect(nav.getByRole('link')).toHaveCount(2);
await expect(nav.getByRole('link')).toHaveCount(3);
await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', base).pathname);
const source = await page.locator('.site-header').getByRole('link', { name: 'Source', exact: true }).getAttribute('href');
expect(source).toMatch(/^https:\/\//);
await expect(nav.getByRole('link', { name: /^Source(?:\s|$)/ })).toHaveAttribute('href', source!);
await expect(nav.getByRole('link', { name: /^Sources(?:\s|$)/ })).toHaveAttribute('href', new URL('sources/', base).pathname);
await expect(nav.getByRole('link', { name: /^Packages(?:\s|$)/ })).toHaveAttribute('href', new URL('packages/', base).pathname);
if (testInfo.project.name === 'mobile') {
for (const width of [320, 360, 390]) {
await page.setViewportSize({ width, height: 740 });
@@ -155,7 +152,7 @@ test('root content navigation works without changing the tree or overflowing nar
test('all 14 document responses contain the current article and metadata-only public tree', async ({ request }, testInfo) => {
expect(documentPaths).toHaveLength(14);
expect(treeNodeCount).toBe(16);
expect(treeNodeCount).toBe(19);
for (const path of documentPaths) {
const url = bookURL(path, testInfo.project.use.baseURL!);
const response = await request.get(url);
@@ -370,6 +367,7 @@ test('directory anchors navigate independently from the sibling plus/minus toggl
const html = await page.locator(article).innerHTML();
const title = await page.title();
await expect(nodeAt(page, '/book')).toHaveAttribute('href', new URL('book/', base).pathname);
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click();
await expect(toggle(page)).toHaveText('+');
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
@@ -378,6 +376,7 @@ test('directory anchors navigate independently from the sibling plus/minus toggl
expect(await page.locator(article).innerHTML()).toBe(html);
await expect(page).toHaveTitle(title);
await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true');
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click();
await expect(toggle(page)).toHaveText('−');
await expect(nodeAt(page, path)).toBeVisible();
@@ -404,6 +403,7 @@ test('persisted tree retains scroll and collapse, expanding only an ancestor nee
const pane = await page.locator('.tree-pane').elementHandle();
const viewport = await page.locator('.tree-viewport').elementHandle();
const root = await nodeAt(page, '/').elementHandle();
await nodeAt(page, '/4-client.md').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click();
await page.locator('.tree-viewport').evaluate((el) => {
(el as HTMLElement).style.height = '120px';
@@ -451,7 +451,9 @@ test('home preserves a collapsed root until selecting a child requires expansion
await page.goto(bookURL(path, base));
await settled(page, path);
const pane = await page.locator('.tree-pane').elementHandle();
await nodeAt(page, '/4-client.md').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click();
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
await nodeAt(page, '/').focus();
@@ -573,9 +575,11 @@ test('rapid navigation settles on the last file and repeated swaps do not duplic
await page.keyboard.press('ArrowDown');
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused();
expect(page.url()).toBe(currentURL);
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
await expect(toggle(page)).toHaveText('+');
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true');
await expect(toggle(page)).toHaveText('−');
+1 -1
View File
@@ -11,7 +11,7 @@ test('home and book share the usable tree without client JavaScript', async ({ p
await expect(page.locator('.root-mark')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('[data-selected-content]')).not.toContainText('1. コンセプト');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ ');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├─ ');
const root = await page.locator('.root-mark').boundingBox();
const spine = await page.locator('.branch-vector').first().evaluate((element) => {
const svg = element as SVGSVGElement;
+1
View File
@@ -261,6 +261,7 @@ test.describe('live CSS theme with JavaScript enabled', () => {
await expect(page).toHaveURL(bookURL('/3-protocol.md', testInfo.project.use.baseURL!));
await complete(page);
await page.evaluate(() => document.fonts.ready.then(() => undefined));
await nodeAt(page, '/1-concept.md').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click();
await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false');
const content = await page.locator(article).elementHandle();
+45
View File
@@ -0,0 +1,45 @@
import { expect, test } from '@playwright/test';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
test('fold controls appear on row hover or keyboard focus without shifting the tree', async ({ page }) => {
await page.goto('./book/');
await expect(page.locator('.demo-tree')).toHaveAttribute('data-enhanced');
const node = page.locator('.demo-node[data-path="/book"]');
const toggle = page.locator('[data-toggle-path="/book"]');
await page.mouse.move(0, 0);
await expect(toggle).toHaveCSS('opacity', '0');
await expect(toggle).toHaveCSS('pointer-events', 'none');
const before = await node.boundingBox();
await node.hover();
await expect(toggle).toHaveCSS('opacity', '1');
expect(await node.boundingBox()).toEqual(before);
await toggle.click();
await expect(node).toHaveAttribute('aria-expanded', 'false');
await toggle.click();
await expect(node).toHaveAttribute('aria-expanded', 'true');
await page.locator('[data-selected-content]').focus();
await page.mouse.move(0, 0);
await expect(toggle).toHaveCSS('opacity', '0');
await node.focus();
await expect(toggle).toHaveCSS('opacity', '1');
await page.keyboard.press('ArrowLeft');
await expect(node).toHaveAttribute('aria-expanded', 'false');
await page.keyboard.press('ArrowRight');
await expect(node).toHaveAttribute('aria-expanded', 'true');
});
test.describe('touch without hover', () => {
test.use({ hasTouch: true, isMobile: true });
test('fold controls remain visible and tappable', async ({ page }) => {
await page.goto('./book/');
await expect(page.locator('.demo-tree')).toHaveAttribute('data-enhanced');
expect(await page.evaluate(() => matchMedia('(hover: none)').matches)).toBe(true);
const toggle = page.locator('[data-toggle-path="/book"]');
await expect(toggle).toHaveCSS('opacity', '1');
await toggle.tap();
await expect(page.locator('.demo-node[data-path="/book"]')).toHaveAttribute('aria-expanded', 'false');
await toggle.tap();
await expect(page.locator('.demo-node[data-path="/book"]')).toHaveAttribute('aria-expanded', 'true');
});
});
+67 -4
View File
@@ -1,13 +1,13 @@
import { expect, test, type Page } from '@playwright/test';
import { load } from 'cheerio';
import { bookURL, documentPaths, draftPaths, treePaths } from './documents';
import { bookURL, documentPaths, draftPaths, treePaths, routeURL, catalogPaths } from './documents';
import { catalogs, type CatalogName } from '../../src/lib/catalog';
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);
@@ -96,8 +96,7 @@ test('real WASM results replace poisoned SSR labels and article, rather than jus
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);
await expect(nodeAt(page, node.displayPath)).toHaveText(node.name);
}
}
expect(expected.stats.observes).toBeGreaterThan(0);
@@ -148,12 +147,74 @@ test('every public route uses one persistent WASM session with only static asset
await expect(page.locator('.demo-node[data-path*="draft"], a[href*="/draft/"]')).toHaveCount(0);
});
test('catalog root leaves share static and real WASM bodies, links and history', async ({ page, request }, testInfo) => {
const base = testInfo.project.use.baseURL!;
for (const path of catalogPaths) {
const name = path.slice(1) as CatalogName;
const response = await request.get(routeURL(path, base));
expect(response.ok()).toBe(true);
const $ = load(await response.text());
const staticBody = $(article);
expect(staticBody.find('h1').text()).toBe(catalogs[name].title);
expect(staticBody.find('.catalog-list > li').length).toBe(catalogs[name].entries.length);
const links = staticBody.find('a').map((_, el) => $(el).attr('href')).get();
expect(links).toEqual(catalogs[name].entries.flatMap((entry) => entry.links.map((link) => link.href)));
await page.goto(routeURL(path, base));
await ready(page, path);
const result = await probeBridge(page, base, path);
const body = load(result.html, {}, false);
expect(body.text().replace(/\s+/g, ' ').trim()).toBe(staticBody.text().replace(/\s+/g, ' ').trim());
expect(body('a').map((_, el) => body(el).attr('href')).get()).toEqual(links);
expect(await page.locator(article).innerHTML()).toBe(result.html);
await expect(nodeAt(page, path)).not.toHaveAttribute('aria-expanded');
await expect(nodeAt(page, path)).toHaveAttribute('aria-level', '2');
await expect(page.locator(`.node-toggle[data-toggle-path="${path}"]`)).toHaveCount(0);
await expect(page.locator(`.demo-node[data-path^="${path}/"]`)).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
}
await nodeAt(page, '/sources').click();
await ready(page, '/sources');
await nodeAt(page, '/packages').click();
await ready(page, '/packages');
await page.goBack();
await ready(page, '/sources');
await expect(page).toHaveURL(routeURL('/sources', base));
await page.goForward();
await ready(page, '/packages');
await expect(page).toHaveURL(routeURL('/packages', base));
});
test.describe('catalog static fallback', () => {
test.use({ javaScriptEnabled: false });
test('root catalog anchors and direct pages work without JavaScript', async ({ page }, testInfo) => {
await page.goto('./');
for (const path of catalogPaths) {
await nodeAt(page, path).click();
await expect(page).toHaveURL(routeURL(path, testInfo.project.use.baseURL!));
await expect(page.locator(article + ' h1')).toHaveText(catalogs[path.slice(1) as CatalogName].title);
await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page');
await expect(nodeAt(page, path)).not.toHaveAttribute('aria-expanded');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
}
});
});
test('catalog bodies and native navigation remain readable when the WASM module fails', async ({ page }, testInfo) => {
await page.route('**/wip-wasm/wip_browser.js', (route) => route.abort());
await page.goto(routeURL('/sources', testInfo.project.use.baseURL!));
await expect(page.locator(article + ' h1')).toHaveText('Sources');
await nodeAt(page, '/packages').click();
await expect(page).toHaveURL(routeURL('/packages', testInfo.project.use.baseURL!));
await expect(page.locator(article + ' h1')).toHaveText('Packages');
});
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 nodeAt(page, '/1-concept.md').locator('..').hover();
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);
@@ -193,8 +254,10 @@ test('WIP navigation retains collapse, history reading position, fragments and s
await nodeAt(page, path).click();
await ready(page, path);
}
await nodeAt(page, '/book').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/book"]').click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
await nodeAt(page, '/book').locator('..').hover();
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();