Files
orca/tests/tools/win-update-e2e/app-driver.mjs

513 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Drive packaged production DOM/ARIA without E2E-only stores. Prove input through command-written filesystem sentinels rather than canvas scraping.
// Marker canary attributes console flashes; ~500ms heartbeat proves the session remains active.
import { _electron as electron } from '@stablyai/playwright-test'
import { execFileSync } from 'node:child_process'
import { mkdirSync, realpathSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { seedFreshProfile } from './onboarding-profile.mjs'
import { isPidAlive } from './daemon-processes.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"]'
// Target :visible because hidden duplicate panes must not receive focus or measurements.
const TERMINAL_SURFACE_VISIBLE = '[data-terminal-tab-id]:visible'
const XTERM_CONTAINER_VISIBLE = '.xterm:visible'
const XTERM_INPUT = '.xterm-helper-textarea'
const RESTRICTED_E2E_ENV_KEYS = new Set([
'HOME',
'USERPROFILE',
'CODEX_HOME',
'ORCA_CODEX_HOME',
'ORCA_E2E_HOME_DIR',
'ORCA_E2E_USER_DATA_DIR'
])
// Launch with harness-owned userData for unique daemon scope; optional seedProfile writes only before FIRST launch, preserving later restore evidence.
export async function launchInstalledApp({
exePath,
userDataDir,
seedProfile = null,
extraEnv = {}
}) {
const {
ELECTRON_RUN_AS_NODE: _drop,
CODEX_HOME: _codexHome,
ORCA_CODEX_HOME: _orcaCodexHome,
...cleanEnv
} = process.env
void _drop
void _codexHome
void _orcaCodexHome
const restrictedExtraEnvKey = Object.keys(extraEnv).find((key) =>
RESTRICTED_E2E_ENV_KEYS.has(key.toUpperCase())
)
if (restrictedExtraEnvKey) {
throw new Error(`extraEnv.${restrictedExtraEnvKey} cannot override E2E home isolation`)
}
mkdirSync(userDataDir, { recursive: true })
if (seedProfile) {
seedFreshProfile(userDataDir, seedProfile)
}
// Isolate Codex home separately: relocating userData does not change Node home.
const requestedIsolatedHome = path.join(userDataDir, 'home')
mkdirSync(requestedIsolatedHome, { recursive: true })
// Canonicalize Windows 8.3 home aliases so Git worktree paths match Orca’s comparisons.
const isolatedHome = realpathSync.native(requestedIsolatedHome)
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.
...extraEnv,
ORCA_E2E_USER_DATA_DIR: userDataDir,
// Why: the driven app stays off the foreground so a local run doesn't steal focus.
ORCA_BACKGROUND_LAUNCH: '1',
HOME: isolatedHome,
USERPROFILE: isolatedHome,
ORCA_E2E_HOME_DIR: isolatedHome
}
})
// Kill launch tree if firstWindow fails; Electron may still run after the driver times out.
let page
try {
page = await app.firstWindow({ timeout: 120_000 })
await page.waitForLoadState('domcontentloaded')
} catch (err) {
const pid = await resolveElectronMainPid(app)
if (pid) {
try {
execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
} catch {
/* already gone */
}
}
throw err
}
return { app, page }
}
/** Resolve the packaged Electron main, optionally falling back to Playwright's child PID. */
export async function resolveElectronMainPid(
app,
{ allowLauncherFallback = true, timeoutMs = 5_000 } = {}
) {
let timeout
try {
// app.evaluate resolves authoritative main after packaged launcher re-exec; app.process may point at a dead stub.
const pid = await Promise.race([
app.evaluate(() => process.pid),
new Promise((_, reject) => {
// Bound main PID resolution because cleanup often has a wedged connection.
timeout = setTimeout(() => reject(new Error('main PID resolution timed out')), timeoutMs)
timeout.unref?.()
})
])
if (Number.isInteger(pid) && pid > 0) {
return pid
}
} catch {
/* the main connection may already be unavailable */
} finally {
clearTimeout(timeout)
}
// Crash proofs must reject unavailable main identity rather than kill a stub and falsely prove crash.
if (!allowLauncherFallback) {
return null
}
const fallbackPid = app.process()?.pid
return Number.isInteger(fallbackPid) && fallbackPid > 0 ? fallbackPid : null
}
// Best-effort screenshot/body/store-exposure diagnostics under dir; production omits stores and driver failures must retain original assertions.
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),
tabs: Array.from(document.querySelectorAll('[data-testid="sortable-tab"]'))
.map((el) => ({
id: el.getAttribute('data-tab-id'),
title: el.getAttribute('data-tab-title'),
ariaLabel: el.getAttribute('aria-label'),
selected: el.getAttribute('aria-selected')
}))
.slice(0, 40),
activeElement: document.activeElement
? {
tag: document.activeElement.tagName,
className: document.activeElement.getAttribute('class'),
ariaLabel: document.activeElement.getAttribute('aria-label')
}
: null
}))
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.
* An expected tab id prevents post-restore probes from accepting another tab. */
export async function waitForTerminalReady(page, timeoutMs = 60_000, terminalTabId = null) {
const selector = terminalTabId
? `[data-terminal-tab-id="${String(terminalTabId)}"]:visible`
: TERMINAL_SURFACE_VISIBLE
const surface = page.locator(selector).first()
await surface.waitFor({ state: 'visible', timeout: timeoutMs })
await surface.locator(XTERM_CONTAINER_VISIBLE).first().waitFor({
state: 'visible',
timeout: timeoutMs
})
}
// Create only on first launch when no terminal exists; post-update relaunch must wait for restore without masking failure with a new workspace.
export async function ensureTerminal(page, { allowCreate = true, timeoutMs = 60_000 } = {}) {
// Dismiss late feature tips without Escape so restored terminal receives no unsolicited keystroke.
await dismissKnownOverlays(page)
const visibleTerminal = page.locator(TERMINAL_SURFACE_VISIBLE).first()
if (await visibleTerminal.isVisible().catch(() => false)) {
await waitForTerminalReady(page, timeoutMs)
return
}
if (!allowCreate) {
// Restored-terminal timeout is restore failure, not an excuse to create.
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)
}
// Use seeded-project composer’s Blank Terminal path so probes reach a shell rather than an agent/native folder dialog.
async function createWorkspaceFromSeededRepo(page, timeoutMs) {
// Share one deadline across creation rather than reset each step’s budget.
const deadline = Date.now() + timeoutMs
const newWorkspace = page
.getByRole(NEW_WORKSPACE_BUTTON.role, { name: NEW_WORKSPACE_BUTTON.name })
.first()
if (!(await tryClickWithKnownOverlayRetry(page, newWorkspace, deadline - Date.now()))) {
// Keep locator diagnostics without spending another full click timeout.
await newWorkspace.click({ timeout: 1 })
}
const composer = page.getByRole('dialog', { name: 'Create worktree' }).last()
await composer.waitFor({ state: 'visible', timeout: Math.max(1, deadline - Date.now()) })
// Match Create by prefix for appended shortcut hints; fall back to Ctrl+Enter.
const created = await tryClickWithKnownOverlayRetry(
page,
composer.getByRole('button', { name: /^Create worktree/ }).last(),
Math.max(0, deadline - Date.now())
)
if (!created) {
await page.keyboard.press('Control+Enter')
}
}
const OVERLAY_DISMISS_LABELS = ['Got it', 'Dismiss setup scripts', 'Dismiss tip', 'Dismiss update']
const FEATURE_TIP_DIALOG_TITLES = [
'Chat UI is here',
'Let agents drive Orca with the Orca CLI',
'Search every agent session'
]
async function dismissKnownOverlays(page) {
let acted = false
for (const name of FEATURE_TIP_DIALOG_TITLES) {
const featureTip = page.getByRole('dialog', { name }).first()
if (!(await featureTip.isVisible().catch(() => false))) {
continue
}
// Why: a global "Close" role also matches the Windows/Linux title-bar button.
const dialogClose = featureTip.locator('[data-slot="dialog-close"]').first()
if (await dialogClose.isVisible().catch(() => false)) {
const clicked = await dialogClose
.click({ timeout: 3_000 })
.then(() => true)
.catch(() => false)
acted ||= clicked
}
}
for (const name of OVERLAY_DISMISS_LABELS) {
const btn = page.getByRole('button', { name }).first()
if (await btn.isVisible().catch(() => false)) {
const clicked = await btn
.click({ timeout: 3_000 })
.then(() => true)
.catch(() => false)
acted ||= clicked
}
}
return acted
}
async function tryClickWithKnownOverlayRetry(page, locator, timeoutMs) {
const deadline = Date.now() + timeoutMs
do {
const remainingMs = deadline - Date.now()
if (remainingMs <= 0) {
return false
}
try {
await locator.click({ timeout: Math.min(5_000, remainingMs) })
return true
} catch (error) {
if (page.isClosed()) {
throw error
}
await dismissKnownOverlays(page)
}
} while (Date.now() < deadline)
return false
}
// Dismiss sequential feature tips/setup/update overlays best-effort so they cannot intercept terminal input.
export async function dismissOverlays(page, rounds = 3) {
for (let i = 0; i < rounds; i++) {
const acted = await dismissKnownOverlays(page)
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))
)
}
// Click the live pane then focus its scoped textarea; offscreen textarea focus alone previously dropped input.
export async function focusActiveTerminal(page, terminalTabId = null) {
// Clear late feature tips before focusing so keystrokes reach the shell.
await dismissKnownOverlays(page)
const selector = terminalTabId
? `[data-terminal-tab-id="${String(terminalTabId)}"]:visible`
: TERMINAL_SURFACE_VISIBLE
const surface = page.locator(selector).first()
const click = surface.click({ position: { x: 24, y: 24 }, timeout: 15_000 })
// Fail if the expected restored tab vanishes rather than type into whatever retained focus.
await (terminalTabId ? click : click.catch(() => {}))
// Scope textarea to the visible pane, excluding hidden duplicates.
const input = surface.locator(XTERM_INPUT).last()
const focus = input.focus()
await (terminalTabId ? focus : focus.catch(() => {}))
return input
}
/** Type a line and submit it (Enter → \r submits in the shell). */
export async function typeLine(page, text, terminalTabId = null) {
await focusActiveTerminal(page, terminalTabId)
await page.keyboard.type(text)
await page.keyboard.press('Enter')
}
/** Send Ctrl+C to the active terminal. */
export async function sendCtrlC(page, terminalTabId = null) {
await focusActiveTerminal(page, terminalTabId)
await page.keyboard.press('Control+C')
}
// Escape backticks, quotes and $ through the outer PowerShell double-quoted argument to nested powershell.exe.
// Without $ escaping, while($true) becomes while(True), silently breaking loop/heartbeat probes.
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 title-canary/PID/500ms heartbeat marker; return command while caller reads its PID file.
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
}
// Read scrollback best-effort through exposed SerializeAddon, else DOM rows; WebGL without exposure may return empty.
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')
})
}
const appShutdownProcesses = {
isPidAlive,
killTree(pid) {
execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
}
}
/** Cleanup may kill the tree; survival proofs must disable that fallback. */
export async function closeApp(
app,
timeoutMs = 10_000,
{ allowForceKill = true, processes = appShutdownProcesses } = {}
) {
// A partially-created session (launch failed before assignment) passes undefined.
if (!app) {
return
}
const mainPid = await resolveElectronMainPid(app, { allowLauncherFallback: allowForceKill })
const launcher = app.process()
let closeStatus = 'not-started'
let closeTimeout
try {
if (!allowForceKill && mainPid === null) {
throw new Error('Cannot verify graceful close without the authoritative Electron PID')
}
closeStatus = 'pending'
const closePromise = app.close().then(
() => {
closeStatus = 'resolved'
},
(error) => {
closeStatus = 'rejected'
throw error
}
)
await Promise.race([
closePromise,
new Promise((_, reject) => {
closeTimeout = setTimeout(() => reject(new Error('close timeout')), timeoutMs)
closeTimeout.unref?.()
})
])
if (!allowForceKill && processes.isPidAlive(mainPid)) {
throw new Error('Playwright close resolved while the authoritative Electron PID remains live')
}
} catch (error) {
if (!allowForceKill) {
const evidence = {
closeStatus,
error: error instanceof Error ? error.message : String(error),
main: readClosePidEvidence(mainPid, processes),
launcher: {
...readClosePidEvidence(launcher?.pid, processes),
exitCode: launcher?.exitCode ?? null,
signalCode: launcher?.signalCode ?? null,
stdio:
launcher?.stdio.map((pipe, fd) => ({
fd,
destroyed: pipe?.destroyed ?? null,
readableEnded: pipe?.readableEnded ?? null
})) ?? []
}
}
// A surviving daemon can keep the launcher's pipes open after both app processes exit.
if (
closeStatus === 'pending' &&
evidence.main.state === 'exited' &&
evidence.launcher.state === 'exited'
) {
console.log(`[win-update-e2e] close-verified-by-pids: ${JSON.stringify(evidence)}`)
return
}
console.error(`[win-update-e2e] strict-close-failed: ${JSON.stringify(evidence)}`)
throw error
}
if (mainPid) {
try {
processes.killTree(mainPid)
} catch {
/* already gone */
}
}
} finally {
// Clear successful-close timers so shared harnesses do not retain timeout closures/handles.
clearTimeout(closeTimeout)
}
}
function readClosePidEvidence(pid, processes) {
if (!Number.isInteger(pid) || pid <= 0) {
return { pid: pid ?? null, state: 'unverifiable', error: 'PID unavailable' }
}
try {
return { pid, state: processes.isPidAlive(pid) ? 'live' : 'exited' }
} catch (error) {
return {
pid,
state: 'unverifiable',
error: error instanceof Error ? error.message : String(error)
}
}
}