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.
260 lines
18 KiB
TypeScript
260 lines
18 KiB
TypeScript
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');
|
|
});
|