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