import { chromium } from 'playwright'; import fs from 'node:fs/promises'; import assert from 'node:assert/strict'; const url = process.argv[2] ?? process.env.APP_URL ?? 'http://127.0.0.1:4173/'; const label = process.argv[3] ?? process.env.TEST_LABEL ?? 'agent-terminal'; const result = { date: new Date().toISOString(), url, errors: [], requests: [], checks: [] }; 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); console.log('PAGEERROR', error.stack); }); page.on('console', message => { if (message.type() === 'error') console.log('CONSOLE', message.text().slice(0, 500)); }); context.on('request', request => result.requests.push(request.url())); const screen = () => page.evaluate(() => window.browserPi.terminal.readScreen()); const text = () => page.evaluate(() => window.browserPi.terminal.text); async function type(value) { await page.locator('.xterm-helper-textarea').focus(); await page.keyboard.type(value); } async function waitScreen(value) { await page.waitForFunction(value => window.browserPi.terminal.readScreen().includes(value), value); } try { await page.goto(url); await page.waitForFunction(() => window.browserPi); await page.evaluate(() => window.browserPi.ready); await waitScreen('browser workspace'); result.initial = await screen(); assert(result.initial.includes('/load')); result.checks.push('Original Pi TUI rendered through xterm'); await type('/help'); await page.keyboard.press('Enter'); await waitScreen('Ctrl+O'); result.checks.push('Enter submits /help without loading a model'); await page.keyboard.press('Control+Shift+f'); await type('browser'); await page.waitForFunction(() => window.browserPi.terminal.text === '' && /\d+\/\d+/.test(window.browserPi.terminal.readScreen())); await page.keyboard.press('Escape'); await page.waitForTimeout(30); result.checks.push('Pi fullscreen transcript search preserves the editor draft'); await type('one'); await page.keyboard.press('Shift+Enter'); await type('two'); assert.equal(await text(), 'one\ntwo'); await page.keyboard.press('Backspace'); await page.keyboard.press('Control+a'); await type('X'); assert.equal(await text(), 'one\nXtw'); await page.keyboard.press('Control+u'); await page.keyboard.press('Control+k'); await page.keyboard.press('Backspace'); await page.keyboard.press('Control+u'); assert.equal(await text(), ''); result.checks.push('Pi multiline editor: Shift+Enter, cursor, deletion, Ctrl+U/K'); await page.keyboard.press('Control+p'); assert.equal(await text(), '/help'); await page.keyboard.press('Control+n'); assert.equal(await text(), ''); result.checks.push('Pi prompt history'); await type('/he'); await page.keyboard.press('Tab'); await page.waitForFunction(() => window.browserPi.terminal.text === '/help '); await page.keyboard.press('Enter'); await waitScreen('Ctrl+O'); result.checks.push('Pi autocomplete: /he → /help'); await type("!printf 'terminal-works\\n' > terminal.txt; cat terminal.txt"); await page.keyboard.press('Escape'); // Dismiss optional workspace completion. await page.waitForTimeout(30); // A lone ESC is delimited by Pi's 10ms input timer. await page.keyboard.press('Enter'); await waitScreen('[exit 0]'); await page.waitForFunction(() => !window.browserPi.busy); const state = await page.evaluate(() => window.browserPi.rpc('snapshot')); assert.equal(state.files['/workspace/terminal.txt'], 'terminal-works\n'); assert((await screen()).includes('[exit 0]')); result.checks.push('xterm keyboard → Pi editor → real just-bash → workspace file'); // Model/file text must not be able to clear the terminal or inject OSC URLs. await type("!printf 'before\\033[2Jafter\\n'"); await page.locator('#send').click(); await waitScreen('beforeafter'); result.checks.push('Shell escape sequences render as inert text'); // Paste uses xterm's actual bracketed-paste path through Pi's StdinBuffer. await context.grantPermissions(['clipboard-read', 'clipboard-write']); const largePaste = '!cat > pasted.txt <<\'EOF\'\n' + Array.from({ length: 15 }, (_, i) => 'row-' + i).join('\n') + '\nEOF'; await page.evaluate(text => navigator.clipboard.writeText(text), largePaste); await page.locator('.xterm-helper-textarea').focus(); await page.keyboard.press('Control+v'); await page.waitForFunction(text => window.browserPi.terminal.text === text, largePaste); await page.locator('#send').click(); await page.waitForFunction(() => document.querySelector('#files').textContent.includes('pasted.txt')); const pastedState = await page.evaluate(() => window.browserPi.rpc('snapshot')); assert.equal(pastedState.files['/workspace/pasted.txt'], Array.from({ length: 15 }, (_, i) => 'row-' + i).join('\n') + '\n'); result.checks.push('Large Pi paste markers expand before Send'); const pasted = 'hello 世界 🦙\nline two'; await page.evaluate(text => navigator.clipboard.writeText(text), pasted); await page.locator('.xterm-helper-textarea').focus(); await page.keyboard.press('Control+v'); await page.waitForFunction(text => window.browserPi.terminal.text === text, pasted); result.checks.push('Unicode multiline clipboard paste'); await page.locator('#newline').click(); assert.equal(await text(), pasted + '\n'); result.checks.push('Touch-accessible newline button'); // Resize while preserving a multiline draft and Pi's own viewport renderer. const desktop = await page.evaluate(() => ({ cols: window.browserPi.terminal.cols, rows: window.browserPi.terminal.rows })); await page.screenshot({ path: `../validation/${label}-desktop.png`, fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await page.waitForFunction(cols => window.browserPi.terminal.cols < cols, desktop.cols); assert.equal(await text(), pasted + '\n'); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); result.sizes = { desktop, mobile: await page.evaluate(() => ({ cols: window.browserPi.terminal.cols, rows: window.browserPi.terminal.rows })) }; assert(result.sizes.mobile.cols >= 30); assert(result.sizes.mobile.rows >= 15); await page.screenshot({ path: `../validation/${label}-mobile.png`, fullPage: true }); result.checks.push('390px layout and live terminal resize preserve the draft'); result.mobileLimitation = 'Desktop Chromium viewport simulation; physical mobile keyboards and GPUs not tested.'; assert.equal(result.errors.length, 0); assert.equal(result.requests.filter(url => /^https?:/.test(url) && new URL(url).origin !== new URL(result.url).origin).length, 0); result.passed = true; console.log(JSON.stringify({ passed: true, checks: result.checks, sizes: result.sizes })); } catch (error) { result.failure = error.stack; result.screen = await screen().catch(() => 'App failed before initialization'); 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(); }