Files
Hare db4c8ba97f
Reference site / build (push) Canceled after 0s
feat: add highlighted protocol views and inline operation forms
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.
2026-10-26 10:12:22 +09:00

368 lines
24 KiB
TypeScript

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');
});
}