Files
Hare db4c8ba97f
Reference site / build (push) Canceled after 0s
feat: add highlighted protocol views and inline operation forms
Expose Rust Text View tokens and safely highlight signatures and JSON results. Add animated Interface and inline Run disclosures with a shared 10ms line cadence, typed argument inputs, and explicit execution.

Update documentation and browser regressions for the new controls and explicit counter-value responses.
2026-10-26 10:12:22 +09:00

236 lines
16 KiB
TypeScript

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 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 openOperation(page: Page, operation: string) {
const form = page.getByRole('form', { name: `${operation} arguments`, exact: true });
if (await form.count() === 0) await page.getByRole('button', { name: `Run ${operation}`, exact: true }).click();
await expect(form).toBeVisible();
await expect(form).toHaveAttribute('data-operation-form', operation);
return form;
}
async function execute(page: Page, iface: string, operation: string, args?: Record<string, { kind: string; value: string }>) {
const toggle = page.getByRole('button', { name: new RegExp(`^[?>v] /::${iface}$`) });
if (await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click();
const calls = Number(await panel(page).getAttribute('data-calls'));
const form = await openOperation(page, operation);
await expect(panel(page)).toHaveAttribute('data-calls', String(calls));
for (const [parameter, argument] of Object.entries(args ?? {})) {
await form.getByLabel(`${operation}.${parameter}`, { exact: true }).fill(argument.value);
}
await form.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('Observe updates tree membership while counter values come only from Operations', 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(node(page, 'apple')).toHaveText('apple');
await expect(node(page, 'orange')).toHaveText('orange');
await expect(page.locator('.node-value')).toHaveCount(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(node(page, 'banana')).toHaveText('banana');
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();
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '0' });
const metadata = await page.getByLabel('Object Text View').textContent();
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(node(page, 'banana')).toHaveText('banana');
await expect(node(page, 'banana')).toHaveAttribute('aria-label', 'banana');
expect(await page.getByLabel('Object Text View').textContent()).toBe(metadata);
await expect(page.getByLabel('Object Text View')).toContainText('description: "An in-memory counter."');
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' });
expect((await execute(page, 'counter', 'reset')).result).toEqual({ kind: 'integer', value: '0' });
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '0' });
await expect(node(page, 'banana')).toHaveText('banana');
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(node(page, 'banana')).toHaveText('banana');
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');
expect((await execute(page, 'counter', 'increment', amountArg('4'))).result).toEqual({ kind: 'integer', value: '7' });
await node(page, 'orange').click(); await ready(page, '/playground/orange');
await page.goBack(); await ready(page, '/playground/apple');
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await expect(page.getByLabel('Operation result')).toBeHidden();
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '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(node(page, 'apple')).toHaveText('apple');
await expect(panel(page)).toHaveAttribute('data-calls', '2');
await page.locator('.demo-node[data-path="/book"]').click();
await ready(page, '/book');
await expect(node(page, 'apple')).toHaveText('apple');
await node(page, 'apple').click(); await ready(page, '/playground/apple');
await expect(panel(page)).toHaveAttribute('data-calls', '2');
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '7' });
await expect(page.locator('.node-value')).toHaveCount(0);
await page.reload(); await ready(page, '/playground/apple');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '3' });
});
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)>']) {
const form = await openOperation(page, 'create');
await form.getByLabel('create.name', { exact: true }).fill(invalid);
await form.getByRole('button', { name: 'Execute create', 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(node(page, 'apple')).toHaveText('apple');
}
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();
const incrementForm = await openOperation(page, 'increment');
await incrementForm.getByLabel('increment.amount', { exact: true }).fill('9223372036854775807');
await incrementForm.getByRole('button', { name: 'Execute increment', exact: true }).click();
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(node(page, 'apple')).toHaveText('apple');
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '3' });
});
for (const variant of ['missing', 'null', 'prose', 'numeric'] as const) {
test(`tree membership does not depend on ${variant} descriptions or fetch counter values`, 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 original = BrowserSession.prototype.observe; BrowserSession.prototype.observe = function(...args) { const value = JSON.parse(original.apply(this,args)); for (const child of value.observation.children || []) { const variant = ${JSON.stringify(variant)}; if (variant === 'missing') delete child.object.description; else child.object.description = variant === 'null' ? null : variant === 'numeric' ? '999999' : 'カウンターの説明 <img src=x onerror="window.__descriptionExecuted=1">'; } return JSON.stringify(value); };` });
});
await page.goto(url(info.project.use.baseURL!)); await ready(page);
for (const name of ['apple', 'orange']) {
await expect(node(page, name)).toHaveText(name);
await expect(node(page, name)).toHaveAttribute('aria-label', name);
}
await expect(page.locator('.node-value, [data-playground-row] img')).toHaveCount(0);
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
await node(page, 'apple').click(); await ready(page, '/playground/apple');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(page.getByLabel('Operation result')).toBeHidden();
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '3' });
expect(await page.evaluate(() => '__descriptionExecuted' in window)).toBe(false);
});
}
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); if (args[3] === 'increment') 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();
const form = await openOperation(page, 'increment');
await expect(form.getByLabel('increment.amount', { exact: true })).toHaveValue('1');
await form.getByRole('button', { name: 'Execute increment', 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(node(page, 'apple')).toHaveText('apple');
await expect(form.getByRole('button', { name: 'Execute increment', exact: true })).toBeDisabled();
await expect(form.getByLabel('increment.amount', { exact: true })).toBeDisabled();
await expect(page.getByRole('button', { name: 'Run increment', 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(node(page, 'apple')).toHaveText('apple');
await node(page, '').click(); await ready(page);
await node(page, 'apple').click(); await ready(page, '/playground/apple');
await expect(node(page, 'apple')).toHaveText('apple');
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await expect(page.locator('[data-protocol-status]')).toBeHidden();
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '4' });
await expect(page.locator('.node-value')).toHaveCount(0);
});
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);
});
});