Mike0021's picture
Enable browser HTTP commands with CORS-aware errors and bounded requests
39371ea verified
Raw History Blame Contribute Delete
12.1 kB
import { chromium } from 'playwright';
import { visibleWidth } from '@earendil-works/pi-tui/dist/utils.js';
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
// Deterministic presentation fixtures, separate from actual-model tests.
const url = process.argv[2] ?? 'http://127.0.0.1:4173/';
const label = process.argv[3] ?? 'agent-terminal-dark-render';
const result = { date: new Date().toISOString(), url, kind: 'synthetic rendering fixtures; no model inference', errors: [], checks: [], sizes: [] };
const browser = await chromium.launch({ executablePath: '/usr/bin/chromium', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
context.setDefaultTimeout(15_000);
const page = await context.newPage();
page.on('pageerror', error => result.errors.push(error.stack));
let id = 100;
const user = text => ({ role: 'user', content: [{ type: 'text', text }], timestamp: id++ });
const assistant = (text, extra = {}) => ({ role: 'assistant', content: [{ type: 'text', text }], timestamp: id++, stopReason: 'stop', ...extra });
const tool = (name, args, output, isError = false) => {
const toolCallId = 'render-' + id++;
return [assistant('', { content: [{ type: 'toolCall', id: toolCallId, name, arguments: args }], stopReason: 'toolUse' }),
{ role: 'toolResult', toolCallId, toolName: name, content: [{ type: 'text', text: output }], isError, timestamp: id++ }];
};
const screen = () => page.evaluate(() => window.browserPi.terminal.readScreen());
const waitScreen = text => page.waitForFunction(text => window.browserPi.terminal.readScreen().includes(text), text);
async function fixture(messages) {
await page.evaluate(messages => { window.browserPi.terminal.setMessages(messages, { reset: true }); window.browserPi.terminal.focus(); }, messages);
await page.keyboard.press('End');
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
}
function luminance(value) {
const n = typeof value === 'number' ? value : parseInt(value.slice(1), 16);
const rgb = [n >> 16 & 255, n >> 8 & 255, n & 255].map(v => { v /= 255; return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4; });
return .2126 * rgb[0] + .7152 * rgb[1] + .0722 * rgb[2];
}
async function geometry(name) {
const measured = await page.evaluate(() => {
const rect = selector => { const r = document.querySelector(selector).getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; };
return { viewport: { width: innerWidth, height: innerHeight }, cols: window.browserPi.terminal.cols, rows: window.browserPi.terminal.rows,
panel: rect('.terminal-panel'), terminal: rect('#pi-terminal'), screen: rect('.xterm-screen'), controls: rect('.terminal-controls'),
overflow: document.documentElement.scrollWidth > innerWidth, background: getComputedStyle(document.querySelector('.terminal-panel')).backgroundColor };
});
assert.equal(measured.background, 'rgb(24, 24, 30)');
assert.equal(measured.overflow, false, name + ': page overflow');
assert.equal(measured.screen.x, measured.terminal.x, name + ': no extra CSS padding inside xterm');
assert(measured.screen.right <= measured.terminal.right + 1, name + ': terminal columns clipped');
assert(measured.terminal.right - measured.screen.right < measured.screen.width / measured.cols + 1, name + ': unused scrollbar gutter');
assert(measured.screen.bottom <= measured.terminal.bottom + 1, name + ': terminal rows clipped');
assert(measured.controls.y >= measured.screen.bottom - 1, name + ': controls overlap terminal');
assert(measured.controls.bottom <= measured.panel.bottom, name + ': controls escape panel');
assert((await screen()).includes('/workspace'), name + ': footer clipped');
result.sizes.push({ name, ...measured });
}
try {
await page.goto(url); await page.waitForFunction(() => window.browserPi?.terminal?.readCells);
await page.evaluate(() => window.browserPi.ready);
await fixture([user('USER_ONE'), assistant('ASSISTANT_ONE'), user('USER_TWO'), assistant('ASSISTANT_TWO')]);
await waitScreen('ASSISTANT_TWO');
const cells = await page.evaluate(() => window.browserPi.terminal.readCells());
const rowText = cells.map(row => row.map(cell => cell?.text ?? '').join('').trimEnd());
const u1 = rowText.findIndex(row => row.includes('USER_ONE')), a1 = rowText.findIndex(row => row.includes('ASSISTANT_ONE'));
const u2 = rowText.findIndex(row => row.includes('USER_TWO'));
assert.equal(rowText[u1], ' USER_ONE');
assert.equal(a1 - u1, 3, 'Pi user bottom padding plus assistant separator');
assert.equal(u2 - a1, 3, 'Pi turn separator plus user top padding');
assert.equal(cells[u1 - 1][3].bg, 0x343541, 'Pi user top padded row');
assert.equal(cells[u1 + 1][3].bg, 0x343541, 'Pi user bottom padded row');
result.checks.push('Upstream Pi user/assistant indentation, inner padding and turn separators');
await fixture([user('Think about a task'), assistant('', { content: [
{ type: 'thinking', thinking: 'THOUGHT_DETAIL_MARKER' }, { type: 'text', text: 'FINAL_ANSWER_MARKER' },
] })]);
await waitScreen('Thoughts · click to expand');
assert(!(await screen()).includes('THOUGHT_DETAIL_MARKER'));
const thinkingPosition = await page.evaluate(() => {
const t = window.browserPi.terminal;
const row = t.readScreen().split('\n').findIndex(line => line.includes('Thoughts · click to expand'));
const r = document.querySelector('.xterm-screen').getBoundingClientRect();
return { x: r.x + 8 * r.width / t.cols, y: r.y + (row + .5) * r.height / t.rows };
});
await page.mouse.click(thinkingPosition.x, thinkingPosition.y);
await waitScreen('THOUGHT_DETAIL_MARKER');
assert((await screen()).includes('FINAL_ANSWER_MARKER'));
result.checks.push('Reasoning is collapsed by default and expands through Pi’s native click target');
await fixture([user('Read todo'), ...tool('read', { path: '/workspace/todo' }, '# todo\n\n'), assistant('Verified.')]);
await waitScreen('Verified.');
const toolCells = await page.evaluate(() => window.browserPi.terminal.readCells());
const toolRows = toolCells.map(row => row.map(cell => cell?.text ?? '').join('').trimEnd());
const call = toolRows.findIndex(row => row.includes('read /workspace/todo'));
assert.equal(toolRows[call], ' read /workspace/todo');
assert.equal(toolRows[call + 1], ' # todo');
assert.equal(toolCells[call - 1][3].bg, 0x283228);
assert.equal(toolCells[call + 2][3].bg, 0x283228);
assert.equal(toolRows[call + 3], '');
assert.equal(toolRows[call + 4], ' Verified.');
result.checks.push('Upstream tool Box(1,1) padding and separator; no trailing empty output rows');
await page.screenshot({ path: `../validation/${label}-spacing.png`, fullPage: true });
const unicodePrefix = ' WIDE 世界 🦙 👩‍💻 e\u0301 ';
await fixture([user('Unicode'), assistant(unicodePrefix.trimStart() + 'END')]); await waitScreen('END');
const unicodeCells = await page.evaluate(() => window.browserPi.terminal.readCells());
const unicodeRow = unicodeCells.find(row => row.map(cell => cell?.text ?? '').join('').includes('WIDE'));
assert.equal(unicodeRow.findLastIndex(cell => cell.text === 'E'), visibleWidth(unicodePrefix));
assert.equal(unicodeRow.find(cell => cell.text === '👩‍💻').width, 2);
assert.equal(unicodeRow.find(cell => cell.text === '🦙').width, 2);
result.checks.push('CJK, emoji, ZWJ and combining accents agree with Pi cell widths');
const rich = [user('Show Markdown, file output and errors.'), assistant('# Markdown\n\nA **bold** paragraph with *italics*, `inline code`, and a [link](https://example.com/a/long/path).\n\n- First item\n- Second item\n\n> A wrapped quotation with several words and Unicode 世界.\n\n```js\nconst result = "' + 'a'.repeat(150) + '";\nconsole.log(result);\n```\n\n| Item | Value |\n| --- | --- |\n| Alpha | 123 |\n| Beta | 456 |'),
...tool('read', { path: '/workspace/deeply/nested/directory/a-long-file-name.md' }, Array.from({ length: 40 }, (_, i) => 'OUTPUT_ROW_' + i + ' value 世界').join('\n')),
...tool('read', { path: '/workspace/missing.txt' }, 'ENOENT: file does not exist', true), assistant('END_OF_TRANSCRIPT')];
await fixture(rich); await waitScreen('END_OF_TRANSCRIPT');
assert(!(await page.evaluate(() => window.browserPi.terminal.readDocument().join('\n'))).includes('OUTPUT_ROW_39'));
await page.keyboard.press('Control+o');
await page.waitForFunction(() => window.browserPi.terminal.readDocument().join('\n').includes('OUTPUT_ROW_39'));
await page.keyboard.press('Control+o');
result.checks.push('Long tool results collapse/expand without losing content');
for (const [width, height] of [[1440,1000],[1024,768],[851,640],[850,640],[390,844],[320,568],[390,420]]) {
const name = `${width}x${height}`;
await page.setViewportSize({ width, height });
await page.waitForTimeout(100);
await page.keyboard.press('End'); await waitScreen('END_OF_TRANSCRIPT');
await geometry(name);
const doc = await page.evaluate(() => ({ lines: window.browserPi.terminal.readDocument(), cols: window.browserPi.terminal.cols }));
for (const line of doc.lines) assert(visibleWidth(line) <= doc.cols, name + ': overwide document line');
const colors = await page.evaluate(() => window.browserPi.terminal.readCells());
for (const row of colors) for (const cell of row) if (cell && /[\p{L}\p{N}]/u.test(cell.text)) {
const l1 = luminance(cell.fg), l2 = luminance(cell.bg), contrast = (Math.max(l1,l2)+.05)/(Math.min(l1,l2)+.05);
assert(contrast >= 4.5, `Low text contrast ${contrast.toFixed(2)} for ${JSON.stringify(cell)}`);
}
if ([1440,390,320].includes(width)) await page.screenshot({ path: `../validation/${label}-${name}.png`, fullPage: true });
}
result.checks.push('Seven viewports: Markdown, tool cards, footer/controls, wrapping and 4.5:1 visible text contrast');
await page.setViewportSize({ width: 1440, height: 1000 });
const history = Array.from({ length: 60 }, (_, i) => [user('USER_TURN_' + i), assistant('ASSISTANT_TURN_' + i)]).flat();
await fixture(history); await waitScreen('ASSISTANT_TURN_59');
await page.keyboard.press('Home'); await page.waitForFunction(() => window.browserPi.terminal.viewportTop === 0);
const more = [...history, user('Stream a response'), assistant('STREAM_FIRST', { stopReason: undefined })];
await page.evaluate(messages => window.browserPi.terminal.setMessages(messages), more);
await page.waitForTimeout(80);
assert.equal(await page.evaluate(() => window.browserPi.terminal.viewportTop), 0);
more.at(-1).content[0].text = 'STREAM_FIRST\nSTREAM_LAST'; more.at(-1).stopReason = 'stop';
await page.evaluate(messages => window.browserPi.terminal.setMessages(messages), more);
await page.keyboard.press('End'); await waitScreen('STREAM_LAST');
result.checks.push('60-turn transcript and streamed updates preserve scroll position');
await page.locator('.xterm-helper-textarea').focus();
for (let i=0;i<12;i++) { await page.keyboard.type('Draft line ' + i); await page.keyboard.press('Shift+Enter'); }
await page.setViewportSize({ width: 390, height: 420 }); await page.waitForTimeout(120);
await geometry('multiline-keyboard-height');
assert((await screen()).includes('Draft line 11'), 'Multiline editor cursor row must stay visible');
result.checks.push('Multiline draft remains visible at keyboard-sized height');
result.mobileLimitation = 'Viewport/keyboard-height simulation on desktop Chromium; no physical mobile device.';
assert.equal(result.errors.length, 0);
result.passed = true; console.log(JSON.stringify({ passed: true, checks: result.checks }));
} catch (error) {
result.failure = error.stack; result.screen = await screen().catch(() => 'not initialized');
await page.screenshot({ path: `../validation/${label}-failure.png`, fullPage: true }); throw error;
} finally { await fs.writeFile(`../validation/${label}.json`, JSON.stringify(result,null,2)); await browser.close(); }