feat: add highlighted protocol views and inline operation forms
Reference site / build (push) Canceled after 0s

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.
This commit is contained in:
2026-10-26 10:12:22 +09:00
parent 41e88e3c40
commit db4c8ba97f
21 changed files with 1453 additions and 129 deletions
+259
View File
@@ -0,0 +1,259 @@
import { expect, test, type Page } from '@playwright/test';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
const form = (page: Page, name: string) => page.getByRole('form', { name: `${name} arguments`, exact: true });
async function collection(page: Page, base: string) {
await page.goto(new URL('playground/?mode=protocol', base).href);
await page.getByRole('button', { name: '? /::collection', exact: true }).click();
}
test('Run belongs between each Operation comment and declaration; inline arguments call the real Host', async ({ page }, info) => {
await collection(page, info.project.use.baseURL!);
await expect(page.locator('[data-protocol-operation], [data-protocol-operation-controls], [data-protocol-arguments]')).toHaveCount(0);
const positions = await page.locator('[data-operation-control]').evaluateAll(controls => controls.map(control => ({
operation: (control as HTMLElement).dataset.operationControl,
previous: control.previousElementSibling?.textContent,
next: control.nextElementSibling?.textContent,
inInterface: Boolean(control.closest('[data-interface-view]')),
})));
expect(positions.map(position => position.operation)).toEqual(['create', 'remove']);
for (const position of positions) {
expect(position.previous?.trim()).toMatch(/^\/\/\//);
expect(position.next?.trim()).toBe(`operation ${position.operation}(`);
expect(position.inInterface).toBe(true);
}
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
await page.getByRole('button', { name: 'Run create', exact: true }).focus();
await page.keyboard.press('Enter');
const create = form(page, 'create');
await expect(create).toBeVisible();
await expect(create.getByLabel('create.name', { exact: true })).toBeFocused();
expect(await create.textContent()).toBe('create(name:"",) Execute');
expect(await create.evaluate(element => element.closest('[data-interface-view]')!.getAnimations({ subtree: true }).length)).toBe(0);
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
await create.getByLabel('create.name', { exact: true }).fill('banana');
await page.screenshot({ path: info.outputPath('inline-create.png'), fullPage: true });
await page.keyboard.press('Enter');
await expect(page.locator('.demo-node[data-path="/playground/banana"]')).toHaveText('banana');
await expect(page.locator('.node-value')).toHaveCount(0);
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '1');
await expect(page.getByLabel('Operation result')).toContainText('create');
await page.getByRole('button', { name: 'Run remove', exact: true }).click();
await expect(create).toHaveCount(0);
await expect(page.getByLabel('Operation result')).toBeHidden();
await form(page, 'remove').getByLabel('remove.name', { exact: true }).fill('banana');
await page.getByRole('button', { name: 'Execute remove', exact: true }).click();
await expect(page.locator('.demo-node[data-path="/playground/banana"]')).toHaveCount(0);
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '2');
await form(page, 'remove').getByLabel('remove.name', { exact: true }).press('Escape');
await expect(form(page, 'remove')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Run remove', exact: true })).toBeFocused();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
test('inline call expansion and collapse move the declaration at 10ms per line without stale cleanup', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await collection(page, info.project.use.baseURL!);
const toggle = page.getByRole('button', { name: 'Run create', exact: true });
const frames = await toggle.evaluate(button => {
button.closest('[data-interface-view]')!.getAnimations({ subtree: true }).forEach(animation => animation.finish());
const declaration = button.parentElement!.nextElementSibling!;
const body = button.nextElementSibling as HTMLElement;
const offset = () => declaration.getBoundingClientRect().top - button.getBoundingClientRect().top;
const before = offset();
(button as HTMLButtonElement).click();
const animation = body.firstElementChild!.getAnimations()[0];
const duration = Number(animation.effect!.getComputedTiming().duration);
const animations = body.getAnimations({ subtree: true });
animations.forEach(animation => animation.pause());
const opening = [0, .5, 1].map(progress => {
animations.forEach(animation => { animation.currentTime = progress * duration; });
return { height: body.getBoundingClientRect().height, offset: offset() };
});
animations.forEach(animation => animation.finish());
(button as HTMLButtonElement).click();
const closingAnimation = body.firstElementChild!.getAnimations()[0];
const closingAnimations = body.getAnimations({ subtree: true });
closingAnimations.forEach(animation => animation.pause());
const closing = [0, .5, 1].map(progress => {
closingAnimations.forEach(animation => { animation.currentTime = progress * duration; });
return { height: body.getBoundingClientRect().height, offset: offset() };
});
closingAnimations.forEach(animation => animation.finish());
return { before, opening, closing, duration: animation.effect!.getComputedTiming().duration, closeDuration: closingAnimation.effect!.getComputedTiming().duration };
});
expect(frames.duration).toBe(30); expect(frames.closeDuration).toBe(30);
expect(frames.opening[0].height).toBe(0);
expect(frames.opening[1].height).toBeGreaterThan(0);
expect(frames.opening[2].height).toBeGreaterThan(frames.opening[1].height);
expect(frames.closing[1].height).toBeLessThan(frames.closing[0].height);
expect(frames.closing[2].height).toBe(0);
for (const frame of [...frames.opening, ...frames.closing]) expect(frame.offset - frames.before).toBeCloseTo(frame.height, 1);
await expect(form(page, 'create')).toHaveCount(0);
await toggle.evaluate(async button => {
const click = () => (button as HTMLButtonElement).click();
click(); click();
const body = button.nextElementSibling!;
const old = body.firstElementChild!.getAnimations()[0];
old.pause(); old.currentTime = 5;
const settled = old.finished.catch(() => {});
click(); body.getAnimations({ subtree: true }).forEach(animation => animation.finish());
await settled;
});
await expect(form(page, 'create')).toBeVisible();
await expect(form(page, 'create').getByLabel('create.name', { exact: true })).toBeEditable();
await page.evaluate(() => {
const create = document.querySelector<HTMLFormElement>('[data-operation-form="create"]')!;
create.querySelector('input')!.value = 'must_not_run';
document.querySelector<HTMLButtonElement>('[data-operation-control="create"] > button')!.click();
document.querySelector<HTMLButtonElement>('[data-operation-control="remove"] > button')!.click();
create.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
});
await expect(page.locator('[data-operation-form]')).toHaveCount(1);
await expect(form(page, 'remove')).toBeVisible();
await page.evaluate(() => {
const remove = document.querySelector<HTMLFormElement>('[data-operation-form="remove"]')!;
remove.querySelector('input')!.value = 'apple';
document.querySelector<HTMLButtonElement>('[data-interface-toggle][aria-expanded="true"]')!.click();
remove.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
});
await expect(page.locator('[data-operation-control], [data-operation-form]')).toHaveCount(0);
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
});
test('Run rows share Interface fades: 10ms each, top-down open and bottom-up close', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
for (const [iface, operation, count] of [['collection', 'create', 3], ['document', 'read', 1]] as const) {
await page.getByRole('button', { name: `? /::${iface}`, exact: true }).click();
const measured = await page.getByRole('button', { name: `Run ${operation}`, exact: true }).evaluate(button => {
button.closest('[data-interface-view]')!.getAnimations({ subtree: true }).forEach(animation => animation.finish());
(button as HTMLButtonElement).click();
const body = button.nextElementSibling!;
const lines = [...body.querySelectorAll('.text-view-line')];
const timings = () => lines.map(line => {
const animation = line.getAnimations()[0];
return { name: (animation as CSSAnimation).animationName, ...animation.effect!.getTiming() };
});
const opacity = () => lines.map(line => Number(getComputedStyle(line).opacity));
const animations = body.getAnimations({ subtree: true });
animations.forEach(animation => { animation.pause(); animation.currentTime = 5; });
const opening = { timing: timings(), opacity: opacity() };
const duration = body.firstElementChild!.getAnimations()[0].effect!.getComputedTiming().duration;
animations.forEach(animation => animation.finish());
(button as HTMLButtonElement).click();
const closingAnimations = body.getAnimations({ subtree: true });
closingAnimations.forEach(animation => { animation.pause(); animation.currentTime = 5; });
const closing = { timing: timings(), opacity: opacity() };
closingAnimations.forEach(animation => animation.finish());
return { opening, closing, duration };
});
expect(measured.duration).toBe(count * 10);
expect(measured.opening.timing).toHaveLength(count);
measured.opening.timing.forEach((timing, index) => {
expect(timing.name).toBe('text-view-line-in');
expect(timing.duration).toBe(10); expect(timing.delay).toBe(index * 10);
});
expect(measured.opening.opacity[0]).toBeGreaterThan(0);
expect(measured.opening.opacity[0]).toBeLessThan(1);
expect(measured.opening.opacity.slice(1)).toEqual(Array(count - 1).fill(0));
measured.closing.timing.forEach((timing, index) => {
expect(timing.name).toBe('text-view-line-out');
expect(timing.duration).toBe(10); expect(timing.delay).toBe((count - index - 1) * 10);
});
expect(measured.closing.opacity.at(-1)).toBeGreaterThan(0);
expect(measured.closing.opacity.at(-1)).toBeLessThan(1);
expect(measured.closing.opacity.slice(0, -1)).toEqual(Array(count - 1).fill(1));
await expect(form(page, operation)).toHaveCount(0);
}
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
});
test('Run line fades settle on live reduced-motion and print changes', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await collection(page, info.project.use.baseURL!);
const toggle = page.getByRole('button', { name: 'Run create', exact: true });
await toggle.evaluate(button => {
button.closest('[data-interface-view]')!.getAnimations({ subtree: true }).forEach(animation => animation.finish());
(button as HTMLButtonElement).click();
button.nextElementSibling!.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = 5; });
});
await page.emulateMedia({ reducedMotion: 'reduce' });
const create = form(page, 'create');
await expect(create).toBeVisible();
expect(await create.evaluate(element => [...element.querySelectorAll('.text-view-line')].map(line => getComputedStyle(line).opacity))).toEqual(['1', '1', '1']);
expect(await create.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
await toggle.click(); await expect(create).toHaveCount(0);
await page.emulateMedia({ reducedMotion: 'no-preference' });
await toggle.evaluate(button => {
(button as HTMLButtonElement).click();
const body = button.nextElementSibling!;
body.getAnimations({ subtree: true }).forEach(animation => animation.finish());
(button as HTMLButtonElement).click();
body.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = 5; });
});
await page.emulateMedia({ media: 'print' });
await expect(create).toHaveCount(0);
});
test('integer inputs preserve decimal text and no-argument calls still require Execute', async ({ page }, info) => {
await page.goto(new URL('playground/?mode=protocol&object=%2Fplayground%2Fapple', info.project.use.baseURL!).href);
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
const amount = form(page, 'increment').getByLabel('increment.amount', { exact: true });
for (const invalid of ['1.5', '9007199254740993', '1e2']) {
await amount.fill(invalid);
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
await expect(page.locator('.node-value')).toHaveCount(0);
await expect(page.getByLabel('Operation result')).toBeHidden();
}
await amount.fill('-2'); await amount.press('Enter');
expect(JSON.parse(await page.getByLabel('Operation result').innerText()).result).toEqual({ kind: 'integer', value: '1' });
await expect(page.locator('.demo-node[data-path="/playground/apple"]')).toHaveText('apple');
await page.getByRole('button', { name: 'Run get', exact: true }).click();
await expect(form(page, 'get')).toHaveText('get() Execute');
await expect(form(page, 'get').locator('input, textarea, select')).toHaveCount(0);
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '1');
await page.getByRole('button', { name: 'Execute get', exact: true }).click();
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '2');
expect(JSON.parse(await page.getByLabel('Operation result').innerText()).result).toEqual({ kind: 'integer', value: '1' });
});
test('missing Interface tokens retain literal text without guessing execution targets', 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.fetch_interface; BrowserSession.prototype.fetch_interface = function(...args) { const value = JSON.parse(original.apply(this,args)); delete value.textViewTokens; window.__plainInterface = value.textView; return JSON.stringify(value); };` });
});
await collection(page, info.project.use.baseURL!);
expect(await page.locator('[data-interface-view]:visible').textContent()).toBe(await page.evaluate(() => (window as unknown as { __plainInterface: string }).__plainInterface));
await expect(page.locator('[data-operation-control], [data-protocol-call]')).toHaveCount(0);
await expect(page.locator('[data-protocol-status]')).toContainText('Operationの宣言位置を確認できない');
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
});
test('Descriptor fields serialize optional and composite tagged Values without evaluating input', async ({ page }, info) => {
// Synthetic DTO boundary only: capture arguments without calling the Host.
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.fetch_interface; BrowserSession.prototype.fetch_interface = function(...args) { const value = JSON.parse(original.apply(this,args)); if (args[1] === 'collection') value.descriptor.operations[0].parameters = [{name:'text',required:true,type:{kind:'string'}},{name:'enabled',required:true,type:{kind:'boolean'}},{name:'ratio',required:true,type:{kind:'number'}},{name:'nothing',required:true,type:{kind:'unit'}},{name:'items',required:true,type:{kind:'list'}},{name:'optional',required:false,type:{kind:'string'}}]; return JSON.stringify(value); }; BrowserSession.prototype.call_operation = function(...args) { window.__callInput = args; throw new Error('fixture captured'); };` });
});
await collection(page, info.project.use.baseURL!);
await page.getByRole('button', { name: 'Run create', exact: true }).click();
const create = form(page, 'create');
const raw = '日本語 " \\ <img src=x onerror=alert(1)>';
await create.getByLabel('create.text', { exact: true }).fill(raw);
await create.getByLabel('create.enabled', { exact: true }).selectOption('true');
await create.getByLabel('create.ratio', { exact: true }).fill('1.25');
await create.getByLabel('create.items', { exact: true }).fill('{"kind":"list","value":[{"kind":"integer","value":"2"}]}');
await expect(create.getByLabel('create.optional', { exact: true })).toBeDisabled();
await create.getByRole('button', { name: 'Execute create', exact: true }).click();
const argumentsJSON = () => page.evaluate(() => (window as unknown as { __callInput: string[] }).__callInput[4]);
expect(JSON.parse(await argumentsJSON())).toEqual({ text: { kind: 'string', value: raw }, enabled: { kind: 'boolean', value: true }, ratio: { kind: 'number', value: 1.25 }, nothing: { kind: 'unit' }, items: { kind: 'list', value: [{ kind: 'integer', value: '2' }] } });
await create.getByLabel('Include create.optional', { exact: true }).check();
await create.getByRole('button', { name: 'Execute create', exact: true }).click();
expect(JSON.parse(await argumentsJSON()).optional).toEqual({ kind: 'string', value: '' });
await expect(page.locator('[data-protocol-panel] img, [data-protocol-panel] script')).toHaveCount(0);
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
});