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) { 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', '']) { 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' : 'カウンターの説明 '; } 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); }); });