feat: add protocol explorer with catalogs and live playground
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user