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('[data-operation-form="create"]')!; create.querySelector('input')!.value = 'must_not_run'; document.querySelector('[data-operation-control="create"] > button')!.click(); document.querySelector('[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('[data-operation-form="remove"]')!; remove.querySelector('input')!.value = 'apple'; document.querySelector('[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 = '日本語 " \\ '; 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'); });