358 lines
14 KiB
JavaScript
358 lines
14 KiB
JavaScript
// Drive the installed, packaged Orca app with Playwright's Electron driver.
|
|
//
|
|
// This targets a PRODUCTION build, so it must NOT depend on the e2e-only store
|
|
// exposure (window.__store / window.__paneManagers) — those exist only under a
|
|
// `--mode e2e` / VITE_EXPOSE_STORE build. Everything here uses ARIA/DOM
|
|
// selectors that ship in production (matching tests/e2e/helpers/terminal.ts and
|
|
// terminal-attention.spec.ts) and proves interactivity through filesystem
|
|
// sentinels rather than by reading the WebGL-rendered xterm buffer:
|
|
// - typed commands write a marker FILE; the harness checks the file. This
|
|
// proves keystrokes reached the shell AND executed — stronger, and robust,
|
|
// than scraping canvas-rendered terminal text.
|
|
//
|
|
// The long-running marker also sets a unique window-title canary and writes a
|
|
// heartbeat file every ~500ms: the canary lets the window watch attribute any
|
|
// real console flash to our child, and the heartbeat proves the session is
|
|
// live and streaming.
|
|
|
|
import { _electron as electron } from '@stablyai/playwright-test'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import { seedFreshProfile } from './onboarding-profile.mjs'
|
|
|
|
const NEW_TAB_BUTTON = { role: 'button', name: 'New tab' }
|
|
const NEW_TERMINAL_ITEM = /New Terminal/i
|
|
const NEW_WORKSPACE_BUTTON = { role: 'button', name: 'New workspace' }
|
|
const SORTABLE_TAB = '[data-testid="sortable-tab"]'
|
|
// Why: the layout mounts hidden duplicate panes; only the visible one is the
|
|
// live terminal, so target `:visible` to avoid focusing/measuring a hidden copy.
|
|
const TERMINAL_SURFACE_VISIBLE = '[data-terminal-tab-id]:visible'
|
|
const XTERM_CONTAINER_VISIBLE = '.xterm:visible'
|
|
const XTERM_INPUT = '.xterm-helper-textarea'
|
|
|
|
/**
|
|
* Launch the installed Orca.exe. Pointing userDataDir at a harness-owned temp
|
|
* dir isolates this run's daemon (its socket/token path becomes unique), so
|
|
* daemon lookups never collide with other Orca installs/daemons on the box.
|
|
* Pass `seedProfile` (a buildFreshProfile object) to write orca-data.json
|
|
* BEFORE this launch — do so only on the FIRST launch, never before the
|
|
* post-update relaunch, or the persisted session under test is destroyed.
|
|
*/
|
|
export async function launchInstalledApp({
|
|
exePath,
|
|
userDataDir,
|
|
seedProfile = null,
|
|
extraEnv = {}
|
|
}) {
|
|
const { ELECTRON_RUN_AS_NODE: _drop, ...cleanEnv } = process.env
|
|
mkdirSync(userDataDir, { recursive: true })
|
|
if (seedProfile) {
|
|
seedFreshProfile(userDataDir, seedProfile)
|
|
}
|
|
const app = await electron.launch({
|
|
executablePath: exePath,
|
|
args: [],
|
|
env: {
|
|
...cleanEnv,
|
|
// Packaged main honors ORCA_E2E_USER_DATA_DIR to relocate userData
|
|
// (logs/daemon/terminal-history) under a controlled dir.
|
|
ORCA_E2E_USER_DATA_DIR: userDataDir,
|
|
...extraEnv
|
|
}
|
|
})
|
|
const page = await app.firstWindow({ timeout: 120_000 })
|
|
await page.waitForLoadState('domcontentloaded')
|
|
return { app, page }
|
|
}
|
|
|
|
/**
|
|
* Best-effort diagnostics dump when driving fails: a screenshot, the visible
|
|
* body text, and whether the e2e store is exposed (it is not in production
|
|
* builds). Written under `dir` so CI can upload it and reveal the actual
|
|
* post-launch DOM state. Never throws.
|
|
*/
|
|
export async function captureFailureDiagnostics(page, dir, label) {
|
|
const out = {}
|
|
try {
|
|
mkdirSync(dir, { recursive: true })
|
|
} catch {
|
|
return out
|
|
}
|
|
try {
|
|
await page.screenshot({
|
|
path: path.join(dir, `${label}.png`),
|
|
fullPage: false,
|
|
timeout: 10_000
|
|
})
|
|
out.screenshot = `${label}.png`
|
|
} catch {
|
|
/* renderer may be unresponsive */
|
|
}
|
|
try {
|
|
const info = await page.evaluate(() => ({
|
|
hasStore: typeof window.__store,
|
|
title: document.title,
|
|
url: location.href,
|
|
bodyText: (document.body?.innerText ?? '').slice(0, 4000),
|
|
testIds: Array.from(document.querySelectorAll('[data-testid]'))
|
|
.map((el) => el.getAttribute('data-testid'))
|
|
.filter((v, i, a) => v && a.indexOf(v) === i)
|
|
.slice(0, 80),
|
|
buttons: Array.from(document.querySelectorAll('button,[role="button"]'))
|
|
.map((el) => (el.getAttribute('aria-label') || el.textContent || '').trim())
|
|
.filter((v, i, a) => v && a.indexOf(v) === i)
|
|
.slice(0, 60)
|
|
}))
|
|
writeFileSync(path.join(dir, `${label}.json`), JSON.stringify(info, null, 2))
|
|
out.info = info
|
|
} catch {
|
|
/* renderer may be unresponsive */
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Wait until the visible terminal surface and its xterm container are mounted. */
|
|
export async function waitForTerminalReady(page, timeoutMs = 60_000) {
|
|
await page
|
|
.locator(TERMINAL_SURFACE_VISIBLE)
|
|
.first()
|
|
.waitFor({ state: 'visible', timeout: timeoutMs })
|
|
await page
|
|
.locator(XTERM_CONTAINER_VISIBLE)
|
|
.first()
|
|
.waitFor({ state: 'visible', timeout: timeoutMs })
|
|
}
|
|
|
|
/**
|
|
* Get the app to an interactive terminal.
|
|
* - `allowCreate` true (first launch): if no terminal is visible, create a
|
|
* workspace from the seeded repo (or a new tab if a workspace already
|
|
* exists) — the drivable composer, not the native folder dialog.
|
|
* - `allowCreate` false (post-update relaunch): the session should be
|
|
* RESTORED, so only wait for the restored terminal — never create a second
|
|
* workspace (which would mask a broken restore).
|
|
*/
|
|
export async function ensureTerminal(page, { allowCreate = true, timeoutMs = 60_000 } = {}) {
|
|
const visibleTerminal = page.locator(TERMINAL_SURFACE_VISIBLE).first()
|
|
if (await visibleTerminal.isVisible().catch(() => false)) {
|
|
await waitForTerminalReady(page, timeoutMs)
|
|
return
|
|
}
|
|
if (!allowCreate) {
|
|
// Wait for the restored terminal to appear; a timeout here is a real
|
|
// (asserted) failure of session restore, not a driving gap.
|
|
await waitForTerminalReady(page, timeoutMs)
|
|
return
|
|
}
|
|
const newTab = page.getByRole(NEW_TAB_BUTTON.role, { name: NEW_TAB_BUTTON.name }).first()
|
|
if (await newTab.isVisible().catch(() => false)) {
|
|
await createTerminalTab(page)
|
|
return
|
|
}
|
|
await createWorkspaceFromSeededRepo(page, timeoutMs)
|
|
await waitForTerminalReady(page, timeoutMs)
|
|
}
|
|
|
|
/**
|
|
* Drive the "New workspace" composer to create a worktree from the single
|
|
* seeded project. The composer is in-app DOM (unlike the native Add-Project
|
|
* dialog): open it, choose the "Blank Terminal" mode so the worktree opens a
|
|
* plain terminal (not an agent), then submit "Create worktree".
|
|
*/
|
|
async function createWorkspaceFromSeededRepo(page, timeoutMs) {
|
|
await page
|
|
.getByRole(NEW_WORKSPACE_BUTTON.role, { name: NEW_WORKSPACE_BUTTON.name })
|
|
.first()
|
|
.click({ timeout: timeoutMs })
|
|
// Choose the plain-terminal mode (best-effort — if it is already the default
|
|
// or the label differs, the create below still produces a worktree).
|
|
await page
|
|
.getByRole('button', { name: 'Blank Terminal' })
|
|
.first()
|
|
.click({ timeout: 15_000 })
|
|
.catch(() => {})
|
|
// Submit. The create button's accessible name carries the shortcut hint
|
|
// ("Create worktreeCtrl"), so match by prefix; fall back to the documented
|
|
// Ctrl+Enter shortcut if the button is not directly clickable.
|
|
const created = await page
|
|
.getByRole('button', { name: /^Create worktree/ })
|
|
.last()
|
|
.click({ timeout: 15_000 })
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
if (!created) {
|
|
await page.keyboard.press('Control+Enter')
|
|
}
|
|
}
|
|
|
|
const OVERLAY_DISMISS_LABELS = ['Got it', 'Dismiss setup scripts', 'Dismiss tip', 'Dismiss update']
|
|
|
|
/**
|
|
* Best-effort dismissal of the modals/banners that appear after creating a
|
|
* worktree (a full-screen "Got it" feature-tip modal, the setup-script prompt,
|
|
* update banner) and intercept all input over the terminal. Loops because tips
|
|
* can appear in sequence. Never throws.
|
|
*/
|
|
export async function dismissOverlays(page, rounds = 3) {
|
|
for (let i = 0; i < rounds; i++) {
|
|
let acted = false
|
|
for (const name of OVERLAY_DISMISS_LABELS) {
|
|
const btn = page.getByRole('button', { name }).first()
|
|
if (await btn.isVisible().catch(() => false)) {
|
|
await btn.click({ timeout: 3_000 }).catch(() => {})
|
|
acted = true
|
|
}
|
|
}
|
|
await page.keyboard.press('Escape').catch(() => {})
|
|
if (!acted) {
|
|
return
|
|
}
|
|
await page.waitForTimeout(400)
|
|
}
|
|
}
|
|
|
|
/** Create a new terminal tab via the New tab menu. Returns the count after. */
|
|
export async function createTerminalTab(page) {
|
|
await dismissOverlays(page, 1)
|
|
const before = await page.locator(SORTABLE_TAB).count()
|
|
await page
|
|
.getByRole(NEW_TAB_BUTTON.role, { name: NEW_TAB_BUTTON.name })
|
|
.first()
|
|
.click({ force: true })
|
|
await page.getByRole('menuitem', { name: NEW_TERMINAL_ITEM }).first().click({ force: true })
|
|
await page.waitForFunction(
|
|
({ selector, prev }) => document.querySelectorAll(selector).length > prev,
|
|
{ selector: SORTABLE_TAB, prev: before },
|
|
{ timeout: 10_000 }
|
|
)
|
|
await waitForTerminalReady(page)
|
|
return page.locator(SORTABLE_TAB).count()
|
|
}
|
|
|
|
/** Cheap session identifiers: the rendered tab ids. */
|
|
export async function listTabIds(page) {
|
|
return page
|
|
.locator(SORTABLE_TAB)
|
|
.evaluateAll((tabs) =>
|
|
tabs.map((t) => t.getAttribute('data-tab-id')).filter((id) => Boolean(id))
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Focus the live terminal so keystrokes reach the shell. Clicking the visible
|
|
* xterm surface is what actually gives xterm keyboard focus — focusing the
|
|
* off-screen helper textarea alone does not, which is why typed input was being
|
|
* dropped. Click the pane, then focus the helper textarea as a belt-and-braces.
|
|
*/
|
|
export async function focusActiveTerminal(page) {
|
|
// A feature-tip modal can appear late and swallow keystrokes; clear any before
|
|
// focusing so typed commands actually reach the shell.
|
|
for (const name of OVERLAY_DISMISS_LABELS) {
|
|
const btn = page.getByRole('button', { name }).first()
|
|
if (await btn.isVisible().catch(() => false)) {
|
|
await btn.click({ timeout: 2_000 }).catch(() => {})
|
|
}
|
|
}
|
|
const surface = page.locator(TERMINAL_SURFACE_VISIBLE).first()
|
|
await surface.click({ position: { x: 24, y: 24 }, timeout: 15_000 }).catch(() => {})
|
|
// Scope the helper textarea to the visible surface so focus can't land on a
|
|
// hidden duplicate pane's textarea (which would silently swallow keystrokes).
|
|
const input = surface.locator(XTERM_INPUT).last()
|
|
await input.focus().catch(() => {})
|
|
return input
|
|
}
|
|
|
|
/** Type a line and submit it (Enter → \r submits in the shell). */
|
|
export async function typeLine(page, text) {
|
|
await focusActiveTerminal(page)
|
|
await page.keyboard.type(text)
|
|
await page.keyboard.press('Enter')
|
|
}
|
|
|
|
/** Send Ctrl+C to the active terminal. */
|
|
export async function sendCtrlC(page) {
|
|
await focusActiveTerminal(page)
|
|
await page.keyboard.press('Control+C')
|
|
}
|
|
|
|
/**
|
|
* Run a PowerShell command inside the active terminal by invoking a nested
|
|
* powershell.exe. The command is wrapped in double quotes for the OUTER
|
|
* interactive shell (also pwsh), which would otherwise expand `$var`, `$(...)`
|
|
* and consume backticks before the nested shell sees them — so escape backticks,
|
|
* quotes, and `$`. Without the `$` escape, `while($true)` reaches the nested
|
|
* shell as `while(True)` and never runs (the bug that silently broke every
|
|
* loop/heartbeat probe while simple `$`-free commands worked).
|
|
*/
|
|
export async function runShellCommand(page, psCommand) {
|
|
const escaped = psCommand.replace(/`/g, '``').replace(/"/g, '`"').replace(/\$/g, '`$')
|
|
await typeLine(page, `powershell.exe -NoProfile -NonInteractive -Command "${escaped}"`)
|
|
}
|
|
|
|
/**
|
|
* Start the long-running marker in the active terminal: sets the canary window
|
|
* title, records its own PID, and heartbeats a file every 500ms. Returns the
|
|
* command string (the caller reads the pid file to learn the marker PID).
|
|
*/
|
|
export async function startMarker(page, { canary, pidFile, heartbeatFile }) {
|
|
const script = [
|
|
`$host.UI.RawUI.WindowTitle='${canary}'`,
|
|
`Set-Content -LiteralPath '${pidFile}' -Value $PID`,
|
|
`while($true){ [System.IO.File]::WriteAllText('${heartbeatFile}',(Get-Date).ToString('o')); Start-Sleep -Milliseconds 500 }`
|
|
].join('; ')
|
|
await runShellCommand(page, script)
|
|
return script
|
|
}
|
|
|
|
/**
|
|
* Best-effort read of the active terminal's visible text, for the cold-restore
|
|
* scrollback fidelity check. Prefers the SerializeAddon when the build happens
|
|
* to expose paneManagers; falls back to DOM rows (populated only under the DOM
|
|
* renderer, so this may be empty under WebGL — hence best-effort).
|
|
*/
|
|
export async function readTerminalTextBestEffort(page) {
|
|
return page.evaluate(() => {
|
|
const managers = window.__paneManagers
|
|
if (managers && typeof managers.forEach === 'function') {
|
|
let out = ''
|
|
managers.forEach((m) => {
|
|
const pane = m.getActivePane?.() ?? m.getPanes?.()[0]
|
|
const text = pane?.serializeAddon?.serialize?.()
|
|
if (text) {
|
|
out += text
|
|
}
|
|
})
|
|
if (out) {
|
|
return out
|
|
}
|
|
}
|
|
return Array.from(document.querySelectorAll('.xterm-rows'))
|
|
.map((el) => el.textContent ?? '')
|
|
.join('\n')
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Close the app gracefully; force-kill its process tree on timeout. Mirrors
|
|
* tests/e2e/helpers/electron-process-shutdown.ts so the daemon (detached) is
|
|
* left alive exactly as a normal quit would.
|
|
*/
|
|
export async function closeApp(app, timeoutMs = 10_000) {
|
|
const proc = app.process()
|
|
try {
|
|
await Promise.race([
|
|
app.close(),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('close timeout')), timeoutMs))
|
|
])
|
|
} catch {
|
|
if (proc?.pid) {
|
|
try {
|
|
execFileSync('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore' })
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
}
|
|
}
|
|
}
|