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
+115
View File
@@ -0,0 +1,115 @@
import { expect, test, type Page } from '@playwright/test';
import { tokenize } from '@vortexlabs/kirari/core';
import { json } from '@vortexlabs/kirari/languages/json';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
const result = (page: Page) => page.getByLabel('Operation result', { exact: true });
async function readDocument(page: Page, base: string) {
await page.goto(new URL('?mode=protocol', base).href);
await page.getByRole('button', { name: '? /::document', exact: true }).click();
await page.getByRole('button', { name: 'Run read', exact: true }).click();
await page.getByRole('button', { name: 'Execute read', exact: true }).click();
await expect(result(page)).toBeVisible();
}
for (const scheme of ['light', 'dark'] as const) {
test(`Kirari JSON tokens preserve response text and use readable ${scheme} colors`, async ({ page }, info) => {
await page.emulateMedia({ colorScheme: scheme });
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
const response = await route.fetch();
// Preserve the real call and attach a display-only fixture for JSON kinds
// not emitted by this site's current Operations (booleans and null).
await route.fulfill({ response, body: `${await response.text()}\nconst original = BrowserSession.prototype.call_operation; BrowserSession.prototype.call_operation = function(...args) { const value = JSON.parse(original.apply(this,args)); value.preview = { enabled: true, disabled: false, empty: null, fraction: -1.25e-8, nested: [1, { 'escaped"key': '日本語 🐈 \\" <img src=x onerror="window.__jsonExecuted=1"> & \\n\\t' }] }; window.__jsonResponse = value; return JSON.stringify(value); };` });
});
await readDocument(page, info.project.use.baseURL!);
const source = await page.evaluate(() => JSON.stringify((window as unknown as { __jsonResponse: unknown }).__jsonResponse, null, 2));
const view = result(page);
expect(await view.textContent()).toBe(source);
expect(await view.innerText()).toBe(source);
const expected = tokenize(source, json).filter(token => token.type !== 'text').map(token => ({ className: `kirari-${token.type}`, text: token.value }));
expect(await view.locator('span').evaluateAll(elements => elements.map(element => ({ className: element.className, text: element.textContent })))).toEqual(expected);
for (const type of ['property', 'string', 'number', 'boolean', 'null', 'punctuation']) await expect(view.locator(`.kirari-${type}`).first()).toBeVisible();
await expect(view.locator('img, script, a, style, iframe')).toHaveCount(0);
expect(await page.evaluate(() => '__jsonExecuted' in window)).toBe(false);
const selected = await view.evaluate(element => {
const range = document.createRange(); range.selectNodeContents(element);
const selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range);
const text = selection.toString(); selection.removeAllRanges(); return text;
});
expect(selected).toBe(source);
const palette = await view.evaluate(element => {
const probe = document.createElement('span'); probe.style.backgroundColor = 'var(--paper)'; element.append(probe);
const background = getComputedStyle(probe).backgroundColor; probe.remove();
return { background, colors: [...element.querySelectorAll('span')].map(span => getComputedStyle(span).color) };
});
const luminance = (color: string) => {
const rgb = color.match(/[\d.]+/g)!.slice(0, 3).map(Number).map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return .2126 * rgb[0] + .7152 * rgb[1] + .0722 * rgb[2];
};
expect(new Set(palette.colors).size).toBe(6);
for (const color of palette.colors) {
const a = luminance(color), b = luminance(palette.background);
expect((Math.max(a, b) + .05) / (Math.min(a, b) + .05)).toBeGreaterThanOrEqual(4.5);
}
const key = view.locator('.kirari-property').first();
const before = await key.evaluate(element => getComputedStyle(element).color);
await page.emulateMedia({ colorScheme: scheme === 'light' ? 'dark' : 'light' });
await expect(key).not.toHaveCSS('color', before);
expect(await view.textContent()).toBe(source);
await page.emulateMedia({ colorScheme: scheme });
await page.screenshot({ path: info.outputPath(`json-${scheme}.png`), fullPage: true });
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '1');
await page.getByRole('button', { name: 'Run read', exact: true }).click();
await expect(view).toBeHidden(); await expect(view.locator('span')).toHaveCount(0);
});
}
test('real response markup stays literal and each new result replaces the old highlighted JSON', async ({ page }, info) => {
await page.route('**/worldspace.json', async route => {
const response = await route.fetch(); const data = await response.json();
data.nodes[0].html = '<script>window.__jsonExecuted=1</script><img src=x onerror="window.__jsonExecuted=1">日本語 🐈 & " \\';
await route.fulfill({ response, json: data });
});
await readDocument(page, info.project.use.baseURL!);
const view = result(page);
await expect(view.locator('.kirari-string').filter({ hasText: '<script>' })).toHaveCount(1);
await expect(view.locator('script, img')).toHaveCount(0);
expect(await page.evaluate(() => '__jsonExecuted' in window)).toBe(false);
await page.locator('.demo-node[data-path="/playground"]').click();
await page.locator('.demo-node[data-path="/playground/apple"]').click();
await expect(view).toBeHidden(); await expect(view.locator('span')).toHaveCount(0);
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
await expect(view.locator('.kirari-string').filter({ hasText: '<script>' })).toHaveCount(0);
const called = JSON.parse((await view.textContent())!);
expect(called.result).toEqual({ kind: 'integer', value: '4' });
expect(called.stats.calls).toBe(2);
await expect(view.locator('.kirari-number')).toHaveCount(3); // calls, interfaces, observes
await expect(page.locator('.demo-node[data-path="/playground/apple"]')).toHaveText('apple');
await expect(page.locator('.node-value')).toHaveCount(0);
await page.getByLabel('increment.amount', { exact: true }).fill('2');
await expect(view).toBeHidden(); await expect(view.locator('span')).toHaveCount(0);
});
test('a highlighting failure keeps the successful result as text without replaying the call', 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();
await page.evaluate(() => {
const original = document.createDocumentFragment.bind(document);
document.createDocumentFragment = () => { document.createDocumentFragment = original; throw new Error('presentation unavailable'); };
});
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
const view = result(page);
await expect(view).toBeVisible(); await expect(view.locator('span')).toHaveCount(0);
expect(JSON.parse((await view.textContent())!).result).toEqual({ kind: 'integer', value: '4' });
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '1');
await expect(page.locator('.demo-node[data-path="/playground/apple"]')).toHaveText('apple');
await expect(page.locator('.node-value')).toHaveCount(0);
await expect(page.locator('[data-protocol-status]')).toBeHidden();
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
await expect(view.locator('.kirari-property').first()).toBeVisible();
expect(JSON.parse((await view.textContent())!).result).toEqual({ kind: 'integer', value: '5' });
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '2');
});
+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');
});
+82 -40
View File
@@ -3,7 +3,6 @@ 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}`);
@@ -14,13 +13,23 @@ async function ready(page: Page, path = '/playground') {
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) {
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<string, { kind: string; value: string }>) {
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();
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();
@@ -29,40 +38,45 @@ async function execute(page: Page, iface: string, operation: string, args?: obje
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) => {
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(value(page, 'apple')).toHaveText('3');
await expect(value(page, 'orange')).toHaveText('0');
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(value(page, 'banana')).toHaveText('0');
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(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"');
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' });
await execute(page, 'counter', 'reset');
await expect(value(page, 'banana')).toHaveText('0');
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(value(page, 'banana')).toHaveText('0');
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);
@@ -71,27 +85,30 @@ test('collection and counter mutations update the tree through actual Observe',
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');
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(value(page, 'apple')).toHaveText('7');
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(value(page, 'apple')).toHaveText('7');
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(value(page, 'apple')).toHaveText('7');
await expect(node(page, 'apple')).toHaveText('apple');
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 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(value(page, 'apple')).toHaveText('3');
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) => {
@@ -108,25 +125,47 @@ test('invalid operations do not alter the tree and deleted history does not resu
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();
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(value(page, 'apple')).toHaveText('3');
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();
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();
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(value(page, 'apple')).toHaveText('3');
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' : 'カウンターの説明 <img src=x onerror="window.__descriptionExecuted=1">'; } 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');
@@ -155,30 +194,33 @@ for (const failurePath of ['/playground', '/playground/apple']) test(`failed ref
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 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();
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();
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(value(page, 'apple')).toHaveText('4');
await expect(page.getByRole('button', { name: 'Execute Operation', exact: true })).toBeDisabled();
await expect(page.getByLabel('Operation', { exact: true })).toBeDisabled();
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(value(page, 'apple')).toHaveText('4');
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(value(page, 'apple')).toHaveText('4');
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', () => {
+57 -17
View File
@@ -14,7 +14,7 @@ async function descriptor(page: Page) {
await expect(page.getByLabel('Object Text View')).toContainText('object {');
await page.locator('[data-interface-toggle]').first().click();
await expect(page.getByLabel('Interface Text View')).toContainText('interface');
await page.getByLabel('Operation', { exact: true }).selectOption('read');
await page.getByRole('button', { name: 'Run read', exact: true }).click();
}
async function traceObserves(page: Page) {
@@ -60,6 +60,39 @@ for (const [route, path, name] of [
});
}
for (const colorScheme of ['light', 'dark'] as const) {
test(`Interface buttons use a text-width TUI background in ${colorScheme} mode`, async ({ page }, info) => {
await page.emulateMedia({ colorScheme });
await page.goto(protocolURL(info.project.use.baseURL!, 'playground/')); await ready(page);
const button = page.getByRole('button', { name: '? /::collection', exact: true });
const toggle = page.locator('[data-interface-toggle]').filter({ hasText: '/::collection' });
await expect(button).toBeVisible();
const background = await button.evaluate(element => {
const probe = document.createElement('span'); probe.style.backgroundColor = 'var(--code-bg)';
element.append(probe); const color = getComputedStyle(probe).backgroundColor; probe.remove(); return color;
});
expect(background).not.toBe('rgba(0, 0, 0, 0)');
for (const state of ['?', 'v', '>']) {
await page.mouse.move(0, 0);
await page.getByRole('button', { name: 'Read', exact: true }).focus();
await expect(toggle).toHaveText(`${state} /::collection`);
await expect(toggle).toHaveCSS('background-color', background);
await expect(toggle).toHaveCSS('border-radius', '0px');
await expect(toggle).toHaveCSS('border-top-width', '0px');
await expect(toggle).toHaveCSS('padding-top', '0px');
expect(await toggle.evaluate(element => element.getBoundingClientRect().width < element.parentElement!.getBoundingClientRect().width)).toBe(true);
await toggle.hover();
await expect(toggle).not.toHaveCSS('background-color', background);
if (state !== '>') await toggle.click();
}
await page.mouse.move(0, 0); await toggle.focus(); await page.keyboard.press('Enter');
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(toggle).toHaveCSS('outline-width', '2px');
await expect(page.getByLabel('Interface Text View').filter({ visible: true })).toContainText('/::collection');
await page.screenshot({ path: info.outputPath(`interface-background-${colorScheme}.png`), fullPage: true });
});
}
test('Object selection shows an unboxed Text View without Observe forms or step panels', async ({ page }, info) => {
await page.goto(protocolURL(info.project.use.baseURL!, 'sources/')); await ready(page);
const object = page.getByLabel('Object Text View');
@@ -70,16 +103,20 @@ test('Object selection shows an unboxed Text View without Observe forms or step
await expect(object).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(panel(page).locator('fieldset, legend, h2, h3, [data-protocol-path], [data-protocol-depth], [data-protocol-observe], [data-protocol-stats], [data-protocol-children]')).toHaveCount(0);
await expect(page.locator('[data-protocol-status]')).toBeHidden();
await expect(page.locator('[data-protocol-operation-controls]')).toBeHidden();
await expect(page.locator('[data-protocol-operation-controls], [data-protocol-operation], [data-protocol-arguments], [data-operation-form], [data-protocol-call]')).toHaveCount(0);
await expect(page.getByLabel('Operation result')).toBeHidden();
await expect(page.getByRole('button', { name: '? /::document', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('protocol-object.png'), fullPage: true });
await page.getByRole('button', { name: '? /::document', exact: true }).click();
await expect(page.getByLabel('Interface Text View')).toBeVisible();
await expect(page.locator('[data-protocol-operation-controls]')).toBeVisible();
await expect(page.getByRole('button', { name: 'Run read', exact: true })).toBeEnabled();
await expect(page.locator('[data-protocol-operation-controls], [data-protocol-operation], [data-protocol-arguments], [data-protocol-call]')).toHaveCount(0);
await page.getByRole('button', { name: 'Run read', exact: true }).click();
await expect(page.getByRole('form', { name: 'read arguments', exact: true })).toHaveAttribute('data-operation-form', 'read');
await expect(page.getByRole('button', { name: 'Execute read', exact: true })).toBeEnabled();
await expect(page.getByLabel('Operation result')).toBeHidden();
await page.getByRole('button', { name: 'v /::document', exact: true }).click();
await expect(page.locator('[data-protocol-operation-controls]')).toBeHidden();
await expect(page.locator('[data-protocol-operation-controls], [data-protocol-operation], [data-protocol-arguments], [data-operation-form], [data-protocol-call]')).toHaveCount(0);
await expect(object).toBeVisible();
});
@@ -96,12 +133,12 @@ test('tree selection automatically observes Depth 1 only, including root, nested
await expect(page.getByLabel('Interface Text View')).toBeHidden();
await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(page.locator('[data-protocol-call]')).toBeDisabled();
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
expected.push({ path: canonicalPath, depth: 1 });
expect(await observedCalls(page)).toEqual(expected);
}
await page.locator('[data-interface-toggle]').first().click();
await page.getByLabel('Operation', { exact: true }).selectOption('read');
await page.getByRole('button', { name: 'Run read', exact: true }).click();
await page.locator('[data-protocol-call]').click();
await expect(panel(page)).toHaveAttribute('data-calls', '1');
// Selecting the current Object resets the previous Interface and result.
@@ -167,12 +204,12 @@ test('Interface clicks fetch unknown signatures and fold/reopen acquired ones wi
const signature = await view.textContent();
await expect(panel(page)).toHaveAttribute('data-interfaces', '1');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(page.getByLabel('Operation', { exact: true })).toBeEnabled();
await expect(page.getByRole('button', { name: 'Run read', exact: true })).toBeEnabled();
await toggle.focus(); await page.keyboard.press('Space');
await expect(toggle).toHaveText('> /::document');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(view).toBeHidden();
await expect(page.getByLabel('Operation', { exact: true })).toBeDisabled();
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
await toggle.click();
await expect(view).toHaveText(signature!);
await expect(panel(page)).toHaveAttribute('data-interfaces', '1');
@@ -201,21 +238,21 @@ test('failed Interface acquisition remains unknown and cannot retain a previous
await page.goto(protocolURL(info.project.use.baseURL!)); await ready(page);
await page.locator('.demo-node[data-path="/"]').click();
await page.getByRole('button', { name: '? /::document', exact: true }).click();
await page.getByLabel('Operation', { exact: true }).selectOption('read');
await page.getByRole('button', { name: 'Run read', exact: true }).click();
const missing = page.getByRole('button', { name: `? /::${missingName}`, exact: true });
await missing.click();
await expect(missing).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByRole('button', { name: '> /::document', exact: true })).toBeVisible();
await expect(page.locator('[data-protocol-status]')).toContainText('protocol');
await expect(page.locator('[data-interface-view]:visible')).toHaveCount(0);
await expect(page.locator('[data-protocol-call]')).toBeDisabled();
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
await expect(page.getByLabel('Operation result')).toBeHidden();
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(page.locator('[data-protocol-interfaces] img')).toHaveCount(0);
expect(await page.evaluate(() => '__interfaceExecuted' in window)).toBe(false);
await page.getByRole('button', { name: '> /::document', exact: true }).click();
await expect(page.locator('[data-interface-view]:visible')).toContainText('interface "/::document"');
await expect(page.getByLabel('Operation', { exact: true })).toBeEnabled();
await expect(page.getByRole('button', { name: 'Run read', exact: true })).toBeEnabled();
});
test('Protocol observes automatically but acquires Interfaces and calls Operations only explicitly', async ({ page }, info) => {
@@ -283,7 +320,7 @@ test('selection, history, reload and mode switches never replay an operation', a
await ready(page);
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await expect(page.getByLabel('Operation result')).toBeHidden();
await expect(page.locator('[data-protocol-call]')).toBeDisabled();
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
}
await page.goBack(); await ready(page);
await expect(panel(page)).toHaveAttribute('data-path', '/sources');
@@ -400,22 +437,25 @@ test('input and protocol errors remain visible, and tree selection clears stale
await page.locator('.demo-node[data-path="/sources"]').click(); await ready(page);
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(page.locator('[data-protocol-status]')).toContainText('protocol');
await expect(page.locator('[data-protocol-call]')).toBeDisabled();
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
await expect(page.getByLabel('Object Text View')).toBeHidden();
await page.locator('.demo-node[data-path="/"]').click();
await expect(panel(page)).toHaveAttribute('data-path', '/');
await ready(page);
await descriptor(page);
await page.locator('[data-protocol-arguments]').fill('{broken');
await page.locator('.demo-node[data-path="/playground"]').click(); await ready(page);
await page.locator('.demo-node[data-path="/playground/apple"]').click(); await ready(page);
await expect(panel(page)).toHaveAttribute('data-path', '/playground/apple');
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
await page.getByLabel('increment.amount', { exact: true }).fill('broken');
await page.locator('[data-protocol-call]').click();
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(page.locator('[data-protocol-status]')).toContainText('input');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await page.locator('.demo-node[data-path="/book"]').click(); await ready(page);
await expect(page.getByLabel('Object Text View')).toContainText('name: "book"');
await expect(page.locator('[data-protocol-operation-controls]')).toBeHidden();
await expect(page.locator('[data-protocol-operation-controls], [data-protocol-operation], [data-protocol-arguments], [data-operation-form], [data-protocol-call]')).toHaveCount(0);
await expect(page.locator('[data-protocol-status]')).toBeHidden();
await expect(page.locator('[data-protocol-call]')).toBeDisabled();
});
test('Text View and returned markup are displayed as text, including in dark mode', async ({ page }, info) => {
+367
View File
@@ -0,0 +1,367 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
interface View { textView: string; textViewTokens: { kind: string; text: string }[]; }
type Captures = { observe?: View; fetch_interface?: View };
async function capture(page: Page) {
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()}\nfor (const method of ['observe','fetch_interface']) { const original = BrowserSession.prototype[method]; BrowserSession.prototype[method] = function(...args) { const response = original.apply(this,args); (window.__textViews ||= {})[method] = JSON.parse(response); return response; }; }` });
});
}
async function exactView(page: Page, view: Locator, method: keyof Captures) {
const expected = await page.evaluate(method => (window as unknown as { __textViews: Captures }).__textViews[method]!, method);
expect(expected.textViewTokens.length).toBeGreaterThan(0);
expect(expected.textViewTokens.map(token => token.text).join('')).toBe(expected.textView);
// DOM/range text preserves the final LF. Browser selection can omit an unpainted
// trailing newline, so compare its behavior with the same unhighlighted <pre>.
expect(await view.evaluate(element => {
const text = element.cloneNode(true) as HTMLElement;
text.querySelectorAll('[data-operation-control]').forEach(control => control.remove());
return text.textContent;
})).toBe(expected.textView);
const copied = await view.evaluate(element => {
const source = element.cloneNode(true) as HTMLElement; source.removeAttribute('id');
source.querySelectorAll('[data-operation-control]').forEach(control => control.remove());
source.querySelectorAll<HTMLElement>('*').forEach(node => { node.style.animation = 'none'; });
source.style.position = 'absolute'; element.after(source);
const range = document.createRange(); range.selectNodeContents(source);
const rangeText = range.toString();
const selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range);
const highlighted = selection.toString();
const plain = source.cloneNode(false) as HTMLElement; plain.removeAttribute('id');
plain.textContent = source.textContent; source.after(plain);
range.selectNodeContents(plain); selection.removeAllRanges(); selection.addRange(range);
const unhighlighted = selection.toString(); selection.removeAllRanges(); plain.remove(); source.remove();
return { rangeText, highlighted, unhighlighted };
});
expect(copied.rangeText).toBe(expected.textView);
expect(copied.highlighted).toBe(copied.unhighlighted);
const semantic = expected.textViewTokens.filter(token => !['whitespace', 'newline', 'indentation', 'plain'].includes(token.kind));
expect(await view.locator('[data-text-kind]').evaluateAll(elements => elements.map(element => ({ kind: (element as HTMLElement).dataset.textKind, text: element.textContent })))).toEqual(semantic);
await expect(view.locator('img, script, a, style')).toHaveCount(0);
}
for (const scheme of ['light', 'dark'] as const) {
test(`real Text View token roles preserve content and use a readable ${scheme} palette`, async ({ page }, info) => {
await page.emulateMedia({ colorScheme: scheme }); await capture(page);
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
const object = page.getByLabel('Object Text View');
await expect(object.locator('[data-text-kind="keyword"]').first()).toHaveText('object');
await exactView(page, object, 'observe');
await page.getByRole('button', { name: '? /::document', exact: true }).click();
const signature = page.locator('[data-interface-view]:visible');
await exactView(page, signature, 'fetch_interface');
for (const kind of ['keyword', 'type-name', 'operation-name', 'field-name', 'reference', 'documentation', 'symbol']) {
expect(await signature.locator(`[data-text-kind="${kind}"]`).count()).toBeGreaterThan(0);
}
const palette = await page.locator('.protocol-signature [data-text-kind]').evaluateAll(elements => {
const probe = document.createElement('span'); probe.style.backgroundColor = 'var(--paper)'; document.body.append(probe);
const background = getComputedStyle(probe).backgroundColor; probe.remove();
return { background, colors: elements.map(element => getComputedStyle(element).color) };
});
const luminance = (color: string) => {
const rgb = color.match(/[\d.]+/g)!.slice(0, 3).map(Number).map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return .2126 * rgb[0] + .7152 * rgb[1] + .0722 * rgb[2];
};
expect(new Set(palette.colors).size).toBeGreaterThanOrEqual(6);
for (const color of palette.colors) {
const a = luminance(color), b = luminance(palette.background);
expect((Math.max(a, b) + .05) / (Math.min(a, b) + .05)).toBeGreaterThanOrEqual(4.5);
}
const text = await signature.textContent();
const keyword = signature.locator('[data-text-kind="keyword"]').first();
const before = await keyword.evaluate(element => getComputedStyle(element).color);
await page.emulateMedia({ colorScheme: scheme === 'light' ? 'dark' : 'light' });
await expect(keyword).not.toHaveCSS('color', before);
expect(await signature.textContent()).toBe(text);
await page.getByRole('button', { name: 'v /::document', exact: true }).click();
await page.getByRole('button', { name: '> /::document', exact: true }).click();
await exactView(page, signature, 'fetch_interface');
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-interfaces', '1');
await page.emulateMedia({ colorScheme: scheme });
await page.screenshot({ path: info.outputPath(`text-view-${scheme}.png`), fullPage: true });
});
}
test('Unicode and markup remain literal text and Playground refresh keeps token styling', async ({ page }, info) => {
await capture(page);
await page.route('**/worldspace.json', async route => {
const response = await route.fetch(); const data = await response.json();
data.nodes[0].title = '日本語 🐈 <img src=x onerror="window.__highlightExecuted=1"> & \\ \n\t';
await route.fulfill({ response, json: data });
});
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
const object = page.getByLabel('Object Text View');
await expect(object.locator('[data-text-kind="string"]').last()).toContainText('日本語 🐈 <img');
await exactView(page, object, 'observe');
expect(await page.evaluate(() => '__highlightExecuted' in window)).toBe(false);
await page.locator('.demo-node[data-path="/playground"]').click();
await page.getByRole('button', { name: '? /::collection', exact: true }).click();
await expect(page.locator('[data-interface-view]:visible [data-text-kind="parameter-name"]').first()).toHaveText('`name`');
await page.locator('.demo-node[data-path="/playground/apple"]').click();
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
await expect(object.locator('[data-text-kind="string"]').last()).toHaveText('"An in-memory counter."');
expect(JSON.parse(await page.getByLabel('Operation result').innerText()).result).toEqual({ kind: 'integer', value: '4' });
await exactView(page, object, 'observe');
await expect(page.getByLabel('Operation result').locator('[data-text-kind]')).toHaveCount(0);
});
test('Interface lines fade in top-to-bottom at 10ms per line, including cached reopen', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' }); await capture(page);
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
const toggle = page.getByRole('button', { name: '? /::collection', exact: true });
await expect(toggle).toBeEnabled();
for (let attempt = 0; attempt < 2; attempt++) {
const timing = await page.locator('[data-interface-toggle]').filter({ hasText: '/::collection' }).evaluate(button => {
(button as HTMLButtonElement).click();
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
const lines = [...view.querySelectorAll('.text-view-line')];
const animations = view.getAnimations({ subtree: true });
const timings = lines.flatMap(line => line.getAnimations()).map(animation => {
const effect = animation.effect!;
return { delay: Number(effect.getTiming().delay), duration: effect.getComputedTiming().duration, end: effect.getComputedTiming().endTime };
});
animations.forEach(animation => { animation.pause(); animation.currentTime = 20; });
const opacity = lines.map(line => Number(getComputedStyle(line).opacity));
const source = view.cloneNode(true) as HTMLElement;
source.querySelectorAll('[data-operation-control]').forEach(control => control.remove());
const text = source.textContent;
animations.forEach(animation => animation.finish());
return { timings, opacity, lines: lines.map(line => line.textContent), text };
});
expect(timing.timings.length).toBe(timing.lines.length);
expect(timing.timings.length).toBeGreaterThan(2);
expect(timing.timings[0].delay).toBe(0);
expect(Math.max(...timing.timings.map(item => Number(item.end)))).toBeCloseTo(timing.lines.length * 10, 5);
expect(timing.timings.every(item => Number(item.duration) === 10)).toBe(true);
timing.timings.forEach((item, index) => expect(item.delay).toBe(index * 10));
expect(timing.opacity[0]).toBeGreaterThan(0);
expect(timing.opacity.at(-1)).toBe(0);
expect(timing.lines.join('\n') + '\n').toBe(timing.text);
await exactView(page, page.locator('[data-interface-view]:visible'), 'fetch_interface');
await page.getByRole('button', { name: 'v /::collection', exact: true }).click();
await expect(page.locator('[data-interface-view]:visible')).toHaveCount(0);
}
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-interfaces', '1');
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
});
test('Interface height grows from zero and pushes following rows down in sync with line fades', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
const toggle = page.locator('[data-interface-toggle]').first();
await expect(toggle).toBeEnabled();
for (let attempt = 0; attempt < 2; attempt++) {
const result = await toggle.evaluate(button => {
const panel = button.closest<HTMLElement>('[data-protocol-panel]')!;
const following = button.parentElement!.nextElementSibling!;
const top = () => following.getBoundingClientRect().top - panel.getBoundingClientRect().top + panel.scrollTop;
const before = top();
(button as HTMLButtonElement).click();
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
const animations = view.getAnimations({ subtree: true });
const expansion = view.querySelector('.text-view-reveal')!.getAnimations()[0];
const duration = Number(expansion.effect!.getComputedTiming().duration);
const lineCount = view.querySelectorAll('.text-view-line').length;
animations.forEach(animation => animation.pause());
const frames = [0, .25, .5, .75, 1].map(progress => {
animations.forEach(animation => { animation.currentTime = progress * duration; });
return { height: view.getBoundingClientRect().height, following: top() };
});
animations.forEach(animation => animation.finish());
return { before, frames, duration, lineCount,
finalHeight: view.getBoundingClientRect().height,
contentHeight: view.querySelector('.text-view-lines')!.scrollHeight };
});
expect(result.duration).toBe(result.lineCount * 10);
expect(result.frames[0].height).toBe(0);
expect(result.frames[0].following).toBeCloseTo(result.before, 1);
for (let index = 1; index < result.frames.length; index++) {
expect(result.frames[index].height).toBeGreaterThan(result.frames[index - 1].height);
expect(result.frames[index].following).toBeGreaterThan(result.frames[index - 1].following);
expect(result.frames[index].following - result.before).toBeCloseTo(result.frames[index].height, 1);
}
expect(result.frames.at(-1)!.height).toBeCloseTo(result.finalHeight, 1);
expect(Math.abs(result.finalHeight - result.contentHeight)).toBeLessThan(1);
// No retained fixed height: wrapping after expansion must still size naturally.
const resized = await page.locator('[data-interface-view]:visible').evaluate(view => {
(view as HTMLElement).style.width = '16ch';
return { height: view.getBoundingClientRect().height, content: view.querySelector('.text-view-lines')!.scrollHeight };
});
expect(resized.height).toBeGreaterThan(result.finalHeight);
expect(Math.abs(resized.height - resized.content)).toBeLessThan(1);
await page.locator('[data-interface-view]:visible').evaluate(view => (view as HTMLElement).style.removeProperty('width'));
await toggle.click();
await expect(page.locator('[data-interface-view]:visible')).toHaveCount(0);
}
});
test('closing shrinks the current height and lifts following rows at 10ms per line', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
const toggle = page.locator('[data-interface-toggle]').first();
await expect(toggle).toBeEnabled();
for (const openingProgress of [1, .4]) {
const result = await toggle.evaluate((button, openingProgress) => {
(button as HTMLButtonElement).click();
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
const lineCount = view.querySelectorAll('.text-view-line').length;
view.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = openingProgress * lineCount * 10; });
const panel = button.closest<HTMLElement>('[data-protocol-panel]')!;
const following = button.parentElement!.nextElementSibling!;
const top = () => following.getBoundingClientRect().top - panel.getBoundingClientRect().top + panel.scrollTop;
const before = { height: view.getBoundingClientRect().height, top: top(), opacity: getComputedStyle(view.querySelector('.text-view-line')!).opacity };
(button as HTMLButtonElement).click();
const animations = view.getAnimations({ subtree: true });
const duration = Number(view.querySelector('.text-view-reveal')!.getAnimations()[0].effect!.getComputedTiming().duration);
const lineTimings = [...view.querySelectorAll('.text-view-line')].map(line => line.getAnimations()[0].effect!.getTiming());
animations.forEach(animation => animation.pause());
const frames = [0, .25, .5, .75, 1].map(progress => {
animations.forEach(animation => { animation.currentTime = progress * duration; });
return { height: view.getBoundingClientRect().height, top: top(), opacity: Number(getComputedStyle(view.querySelector('.text-view-line')!).opacity) };
});
const inert = view.inert;
animations.forEach(animation => animation.finish());
return { before, frames, inert, expanded: button.getAttribute('aria-expanded'), duration, lineCount, lineTimings };
}, openingProgress);
result.lineTimings.forEach((timing, index) => {
expect(timing.duration).toBe(10);
expect(timing.delay).toBe((result.lineCount - index - 1) * 10);
});
expect(result.duration).toBe(result.lineCount * 10);
expect(result.inert).toBe(true);
expect(result.expanded).toBe('false');
expect(result.frames[0].height).toBeCloseTo(result.before.height, 1);
expect(result.frames[0].top).toBeCloseTo(result.before.top, 1);
expect(result.frames[0].opacity).toBeCloseTo(Number(result.before.opacity), 4);
for (let index = 1; index < result.frames.length; index++) {
expect(result.frames[index].height).toBeLessThan(result.frames[index - 1].height);
expect(result.frames[index].top).toBeLessThan(result.frames[index - 1].top);
expect(result.frames[index].opacity).toBeLessThanOrEqual(result.frames[index - 1].opacity);
expect(result.before.top - result.frames[index].top).toBeCloseTo(result.before.height - result.frames[index].height, 1);
}
expect(result.frames.at(-1)!.height).toBe(0);
expect(result.frames.at(-1)!.opacity).toBe(0);
await expect(page.locator('[data-interface-view]:not([hidden])')).toHaveCount(0);
await expect(page.locator('.text-view-reveal')).toHaveCount(0);
await expect(page.locator('[data-protocol-call]')).toHaveCount(0);
}
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-interfaces', '1');
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
});
test('reopening during collapse cannot be cleared by the old completion', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
const toggle = page.locator('[data-interface-toggle]').first();
await expect(toggle).toBeEnabled();
const state = await toggle.evaluate(async button => {
const click = () => (button as HTMLButtonElement).click();
click();
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
view.getAnimations({ subtree: true }).forEach(animation => animation.finish());
click();
const closing = view.getAnimations({ subtree: true });
closing.forEach(animation => { animation.pause(); animation.currentTime = 50; });
const settled = Promise.allSettled(closing.map(animation => animation.finished));
click();
view.getAnimations({ subtree: true }).forEach(animation => animation.finish());
await settled;
return { hidden: view.hidden, inert: view.inert, text: view.textContent, expanded: button.getAttribute('aria-expanded') };
});
expect(state.hidden).toBe(false);
expect(state.inert).toBe(false);
expect(state.text).toContain('interface');
expect(state.expanded).toBe('true');
await expect(page.locator('.text-view-closing')).toHaveCount(0);
await toggle.click();
await expect(page.locator('[data-interface-view]:not([hidden])')).toHaveCount(0);
});
test('reduced motion and print settle a running collapse without leaving content', async ({ page }, info) => {
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
const toggle = page.locator('[data-interface-toggle]').first();
await expect(toggle).toBeEnabled();
for (const preference of ['reduce', 'print']) {
await page.emulateMedia({ media: 'screen', reducedMotion: 'no-preference' });
await toggle.evaluate(button => {
(button as HTMLButtonElement).click();
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
view.getAnimations({ subtree: true }).forEach(animation => animation.finish());
(button as HTMLButtonElement).click();
view.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = 50; });
});
await expect(page.locator('.text-view-closing')).toHaveCount(1);
await page.emulateMedia(preference === 'reduce' ? { reducedMotion: 'reduce' } : { media: 'print' });
await expect(page.locator('.text-view-reveal')).toHaveCount(0);
await expect(page.locator('[data-interface-view]:not([hidden])')).toHaveCount(0);
}
});
test('rapid collapse and switching Interfaces cannot restore old animated text', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
await expect(page.getByRole('button', { name: '? /::collection', exact: true })).toBeEnabled();
await page.evaluate(() => {
const buttons = [...document.querySelectorAll<HTMLButtonElement>('[data-interface-toggle]')];
const collection = buttons.find(button => button.textContent?.includes('/::collection'))!;
const documentButton = buttons.find(button => button.textContent?.includes('/::document'))!;
collection.click();
document.getElementById(collection.getAttribute('aria-controls')!)!.getAnimations({ subtree: true }).forEach(animation => animation.pause());
collection.click(); documentButton.click();
});
const view = page.locator('[data-interface-view]:visible');
await expect(view).toContainText('interface "/::document"');
await expect.poll(() => view.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
await expect(page.getByRole('button', { name: '> /::collection', exact: true })).toHaveAttribute('aria-expanded', 'false');
expect(await page.locator('[data-interface-view][hidden]').textContent()).toBe('');
await page.evaluate(() => {
document.querySelector<HTMLButtonElement>('[data-interface-toggle][aria-expanded="true"]')!.click();
document.querySelector<HTMLButtonElement>('[data-mode="read"]')!.click();
});
await expect(page.locator('.text-view-line')).toHaveCount(0);
});
test('reduced motion disables expansion and fades, including a live preference change', async ({ page }, info) => {
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
await page.getByRole('button', { name: '? /::document', exact: true }).click();
const view = page.locator('[data-interface-view]:visible');
expect(await view.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
await expect(view.locator('.text-view-line').first()).toHaveCSS('opacity', '1');
const fullHeight = await view.evaluate(element => element.getBoundingClientRect().height);
expect(fullHeight).toBeGreaterThan(0);
await page.getByRole('button', { name: 'v /::document', exact: true }).click();
await page.emulateMedia({ reducedMotion: 'no-preference' });
const count = await page.getByRole('button', { name: '> /::document', exact: true }).evaluate(button => {
(button as HTMLButtonElement).click();
const animations = document.getElementById(button.getAttribute('aria-controls')!)!.getAnimations({ subtree: true });
animations.forEach(animation => { animation.pause(); animation.currentTime = 0; });
return animations.length;
});
expect(count).toBeGreaterThan(0);
expect(await page.locator('[data-interface-view]:not([hidden])').evaluate(element => element.getBoundingClientRect().height)).toBe(0);
await page.emulateMedia({ reducedMotion: 'reduce' });
await expect.poll(() => view.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
await expect(view.locator('.text-view-line').last()).toHaveCSS('opacity', '1');
expect(await view.evaluate(element => element.getBoundingClientRect().height)).toBeCloseTo(fullHeight, 1);
await page.emulateMedia({ media: 'print', reducedMotion: 'no-preference' });
expect(await view.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
});
for (const variant of ['missing', 'mismatch', 'unknown-kind'] as const) {
test(`token compatibility fallback is safe for ${variant}`, 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)); const variant = ${JSON.stringify(variant)}; if (variant === 'missing') delete value.textViewTokens; else value.textViewTokens = [{kind:'unknown" onclick="alert(1)',text:variant === 'mismatch' ? '<img src=x onerror=alert(1)>' : value.textView}]; window.__plainSignature = value.textView; return JSON.stringify(value); };` });
});
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
const object = page.getByLabel('Object Text View'); await expect(object).toBeVisible();
expect(await object.textContent()).toBe(await page.evaluate(() => (window as unknown as { __plainSignature: string }).__plainSignature));
await expect(object.locator('*')).toHaveCount(0);
await page.getByRole('button', { name: '? /::document', exact: true }).click();
await expect(page.locator('[data-interface-view]:visible [data-text-kind="keyword"]').first()).toHaveText('interface');
});
}