File size: 12,139 Bytes
39371ea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | 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(); }
|