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
+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);
});
});