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