mirror of
https://github.com/stablyai/orca
synced 2026-04-21 22:27:17 +00:00
* fix: address review findings
* fix: improve error handling in terminal e2e helper JSON parsing
* fix: remove unused catch parameter in terminal e2e helper
* fix: use nullish coalescing assignment for LANG default in PTY spawn
The object spread approach ({LANG: default, ...process.env}) produced
undefined on CI (Ubuntu/Node 22) despite working locally. Switch to
explicit ??= assignment after the spread for reliable cross-platform
behavior.
240 lines
7.7 KiB
JavaScript
240 lines
7.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Terminal E2E helpers for agent-browser + CDP testing against a running Orca
|
|
* dev build. Encapsulates patterns discovered during manual terminal testing:
|
|
*
|
|
* - CDP key events do NOT work with xterm.js (canvas-based renderer)
|
|
* - ClipboardEvent paste simulation does NOT work
|
|
* - Direct PTY write via `window.api.pty.write(id, data)` DOES work
|
|
* - PTY IDs are sequential integers starting from 1
|
|
* - The visible terminal's PTY ID must be discovered (not guessed)
|
|
*
|
|
* Usage:
|
|
* import { OrcaTerminal } from './terminal-e2e-helpers.mjs'
|
|
*
|
|
* const term = new OrcaTerminal(9444) // CDP port
|
|
* await term.connect()
|
|
* const ptyId = await term.discoverActivePtyId()
|
|
* await term.send(ptyId, 'echo hello\r')
|
|
* const screenshot = await term.screenshot('/tmp/test.png')
|
|
* await term.waitForOutput(ptyId, 'hello')
|
|
*
|
|
* Or run directly:
|
|
* node config/scripts/terminal-e2e-helpers.mjs --port 9444 --command 'echo hello'
|
|
*/
|
|
|
|
import { execFileSync } from 'child_process'
|
|
|
|
const AGENT_BROWSER = 'agent-browser'
|
|
|
|
/** Thin wrapper around `agent-browser --cdp <port> <subcommand>`. */
|
|
function ab(port, args) {
|
|
const result = execFileSync(AGENT_BROWSER, ['--cdp', String(port), ...args], {
|
|
encoding: 'utf-8',
|
|
timeout: 15_000
|
|
})
|
|
return result.trim()
|
|
}
|
|
|
|
/** Run JS in the renderer via `agent-browser eval`. */
|
|
function evalInRenderer(port, js) {
|
|
return ab(port, ['eval', js])
|
|
}
|
|
|
|
export class OrcaTerminal {
|
|
/** @param {number} cdpPort — the --remote-debugging-port used when launching Orca */
|
|
constructor(cdpPort) {
|
|
this.port = cdpPort
|
|
}
|
|
|
|
/** Verify connection by taking a snapshot. */
|
|
connect() {
|
|
ab(this.port, ['snapshot', '-i'])
|
|
}
|
|
|
|
/**
|
|
* Discover the PTY ID of the currently visible terminal pane.
|
|
*
|
|
* Why: PTY IDs are opaque sequential integers and the mapping from
|
|
* visible tab → PTY ID isn't exposed in the DOM. We send a unique
|
|
* marker to candidate IDs and see which one appears in the active pane.
|
|
*
|
|
* @param {number} maxId — highest PTY ID to probe (default 10)
|
|
* @returns {string} the PTY ID string
|
|
*/
|
|
discoverActivePtyId(maxId = 10) {
|
|
const marker = `__PTY_PROBE_${Date.now()}__`
|
|
|
|
// Send Ctrl+C + marker echo to each candidate PTY
|
|
const js = `
|
|
(function() {
|
|
for (let i = 1; i <= ${maxId}; i++) {
|
|
window.api.pty.write(String(i), '\\x03\\x15echo ' + '${marker}_' + i + '\\r');
|
|
}
|
|
return 'probed 1-${maxId}';
|
|
})()
|
|
`
|
|
evalInRenderer(this.port, js)
|
|
|
|
// Wait for output to render
|
|
execFileSync('sleep', ['1.5'])
|
|
|
|
// Read the visible xterm buffer to find which marker appeared
|
|
const bufferJs = `
|
|
(function() {
|
|
const xterms = document.querySelectorAll('.xterm');
|
|
const visible = Array.from(xterms).find(x => x.offsetParent !== null);
|
|
if (!visible) return JSON.stringify({error: 'no visible xterm'});
|
|
// Read the screen buffer's text via the DOM text layer or serialize addon
|
|
// xterm renders to canvas, so read from the buffer API
|
|
// We check if the serialize addon exposed the buffer text
|
|
const screen = visible.querySelector('.xterm-screen');
|
|
// Fallback: read textContent from the accessibility tree
|
|
const accessibilityEl = visible.querySelector('.xterm-accessibility');
|
|
const text = accessibilityEl?.textContent || '';
|
|
return JSON.stringify({text: text.slice(-2000)});
|
|
})()
|
|
`
|
|
const bufferResult = evalInRenderer(this.port, bufferJs)
|
|
|
|
// Parse the marker from the buffer
|
|
let parsed
|
|
try {
|
|
parsed = JSON.parse(bufferResult.replace(/^"|"$/g, '').replace(/\\"/g, '"'))
|
|
} catch {
|
|
throw new Error(`discoverActivePtyId: failed to parse buffer result: ${bufferResult}`)
|
|
}
|
|
if (parsed.error) {
|
|
throw new Error(`discoverActivePtyId: ${parsed.error}`)
|
|
}
|
|
|
|
// Find all markers, take the last one (most recent = the visible terminal)
|
|
const markerRe = new RegExp(`${marker}_(\\d+)`, 'g')
|
|
const matches = [...parsed.text.matchAll(markerRe)]
|
|
if (matches.length === 0) {
|
|
// Fallback: take screenshot and try OCR-free approach by probing write
|
|
throw new Error(
|
|
'discoverActivePtyId: no marker found in buffer. ' +
|
|
'The accessibility tree may be disabled. ' +
|
|
'Try using probePtyIdWithScreenshot() instead.'
|
|
)
|
|
}
|
|
return matches.at(-1)[1]
|
|
}
|
|
|
|
/**
|
|
* Alternative PTY discovery: send markers, take a screenshot, and let the
|
|
* caller visually identify which PTY responded.
|
|
*
|
|
* @param {number} maxId — highest PTY ID to probe
|
|
* @param {string} screenshotPath — where to save the screenshot
|
|
*/
|
|
probePtyIdWithScreenshot(maxId = 10, screenshotPath = '/tmp/orca-pty-probe.png') {
|
|
for (let i = 1; i <= maxId; i++) {
|
|
evalInRenderer(
|
|
this.port,
|
|
`window.api.pty.write('${i}', '\\x03\\x15echo PTY_ID_${i}\\r')`
|
|
)
|
|
}
|
|
execFileSync('sleep', ['2'])
|
|
this.screenshot(screenshotPath)
|
|
return screenshotPath
|
|
}
|
|
|
|
/**
|
|
* Send text to a specific PTY.
|
|
*
|
|
* @param {string} ptyId — the PTY ID (from discoverActivePtyId)
|
|
* @param {string} text — text to send (use \r for Enter, \x03 for Ctrl+C, etc.)
|
|
*/
|
|
send(ptyId, text) {
|
|
// Escape for JS string literal inside eval
|
|
const escaped = text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r/g, '\\r')
|
|
evalInRenderer(this.port, `window.api.pty.write('${ptyId}', '${escaped}')`)
|
|
}
|
|
|
|
/**
|
|
* Send a shell command and press Enter.
|
|
*
|
|
* @param {string} ptyId
|
|
* @param {string} command — the shell command (Enter is appended)
|
|
*/
|
|
exec(ptyId, command) {
|
|
this.send(ptyId, `${command}\r`)
|
|
}
|
|
|
|
/** Send Ctrl+C to a PTY. */
|
|
interrupt(ptyId) {
|
|
this.send(ptyId, '\x03')
|
|
}
|
|
|
|
/** Clear the current line (Ctrl+U). */
|
|
clearLine(ptyId) {
|
|
this.send(ptyId, '\x15')
|
|
}
|
|
|
|
/**
|
|
* Take a screenshot of the Orca window.
|
|
*
|
|
* @param {string} path — output file path
|
|
* @returns {string} the screenshot path
|
|
*/
|
|
screenshot(path = '/tmp/orca-terminal.png') {
|
|
ab(this.port, ['screenshot', path])
|
|
return path
|
|
}
|
|
|
|
/**
|
|
* Open a new terminal tab in Orca.
|
|
* @returns {void}
|
|
*/
|
|
newTerminal() {
|
|
ab(this.port, ['click', '@e7']) // "New terminal (Cmd+T)" button
|
|
execFileSync('sleep', ['2'])
|
|
}
|
|
|
|
/**
|
|
* Read the LANG value from a PTY's shell environment.
|
|
*
|
|
* @param {string} ptyId
|
|
* @returns {string} the LANG value
|
|
*/
|
|
readLang(ptyId) {
|
|
this.exec(ptyId, 'echo __LANG__=$LANG')
|
|
execFileSync('sleep', ['1'])
|
|
// Screenshot and return for inspection
|
|
return this.screenshot('/tmp/orca-lang-check.png')
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI entrypoint
|
|
// ---------------------------------------------------------------------------
|
|
if (process.argv[1]?.endsWith('terminal-e2e-helpers.mjs')) {
|
|
const args = process.argv.slice(2)
|
|
const portIdx = args.indexOf('--port')
|
|
const cmdIdx = args.indexOf('--command')
|
|
const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 9444
|
|
const command = cmdIdx >= 0 ? args[cmdIdx + 1] : null
|
|
|
|
const term = new OrcaTerminal(port)
|
|
console.log('Connecting to Orca on CDP port', port, '...')
|
|
term.connect()
|
|
console.log('Connected.')
|
|
|
|
if (args.includes('--discover')) {
|
|
const screenshotPath = term.probePtyIdWithScreenshot()
|
|
console.log('Sent PTY probes. Check screenshot:', screenshotPath)
|
|
}
|
|
|
|
if (command) {
|
|
const ptyId = args[args.indexOf('--pty') + 1]
|
|
if (!ptyId) {
|
|
console.error('--command requires --pty <id>. Use --discover first to find PTY IDs.')
|
|
process.exit(1)
|
|
}
|
|
term.exec(ptyId, command)
|
|
const shot = term.screenshot()
|
|
console.log('Executed. Screenshot:', shot)
|
|
}
|
|
}
|