orca/tests/e2e/agent-session-live-force-ex...

241 lines
7.8 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { ChildProcess } from 'node:child_process'
import type { ElectronApplication } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { TEST_REPO_PATH_FILE } from './global-setup'
import {
execInTerminal,
waitForActivePaneHookDescriptor,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForPaneCount,
waitForTerminalOutput
} from './helpers/terminal'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
import { PROTOCOL_VERSION } from '../../src/main/daemon/types'
const PROVIDER_SESSION_ID = 'e2e-live-force-exit-session'
type PersistedWorkspaceSession = {
tabsByWorktree?: Record<string, { id?: unknown; ptyId?: unknown }[]>
terminalLayoutsByTabId?: Record<string, unknown>
activeWorktreeIdsOnShutdown?: unknown
sleepingAgentSessionsByPaneKey?: Record<
string,
{
providerSession?: { id?: unknown }
launchConfig?: {
agentCommand?: string
agentArgs?: string
agentEnv?: Record<string, string>
}
}
>
}
type PersistedData = {
workspaceSession?: PersistedWorkspaceSession
}
function dataFilePath(userDataDir: string): string {
return path.join(userDataDir, 'orca-data.json')
}
function readPersistedData(userDataDir: string): PersistedData {
return JSON.parse(readFileSync(dataFilePath(userDataDir), 'utf8')) as PersistedData
}
function writePersistedData(userDataDir: string, data: PersistedData): void {
writeFileSync(dataFilePath(userDataDir), `${JSON.stringify(data, null, 2)}\n`, 'utf8')
}
function daemonPidPath(userDataDir: string): string {
return path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`)
}
function readDaemonPid(userDataDir: string): number {
const raw = readFileSync(daemonPidPath(userDataDir), 'utf8')
const parsed = JSON.parse(raw) as { pid?: unknown }
if (typeof parsed.pid !== 'number') {
throw new Error(`Daemon pid file did not contain a numeric pid: ${raw}`)
}
return parsed.pid
}
function hasExited(proc: ChildProcess): boolean {
return proc.exitCode !== null || proc.signalCode !== null
}
function waitForExit(proc: ChildProcess, timeoutMs = 5000): Promise<void> {
if (hasExited(proc)) {
return Promise.resolve()
}
return new Promise((resolve) => {
const timeout = setTimeout(resolve, timeoutMs)
timeout.unref?.()
proc.once('exit', () => {
clearTimeout(timeout)
resolve()
})
})
}
async function forceKillElectronApp(app: ElectronApplication): Promise<void> {
const proc = app.process()
if (!proc.pid || hasExited(proc)) {
return
}
try {
if (process.platform === 'win32') {
execFileSync('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore' })
} else {
process.kill(proc.pid, 'SIGKILL')
}
} catch {
// Already gone.
}
await waitForExit(proc)
}
function killPid(pid: number): void {
try {
if (process.platform === 'win32') {
execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
return
}
process.kill(pid, 'SIGKILL')
} catch {
// Already gone.
}
}
function stripPersistedPtyOwnership(userDataDir: string): void {
const data = readPersistedData(userDataDir)
const session = data.workspaceSession
if (!session) {
throw new Error('Expected persisted workspace session')
}
for (const tabs of Object.values(session.tabsByWorktree ?? {})) {
for (const tab of tabs) {
tab.ptyId = null
}
}
// Why: this models the updater/crash artifact from #6370: the UI tab and
// live resume record survive, but no pane has the old stable leaf key or
// daemon session to own resume.
session.terminalLayoutsByTabId = {}
session.activeWorktreeIdsOnShutdown = []
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
if (record.providerSession?.id === PROVIDER_SESSION_ID) {
// Why: the e2e proof should verify Orca launches the resumed command,
// not depend on a developer machine having a real Codex CLI installed.
record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} }
}
}
writePersistedData(userDataDir, data)
}
function persistedLiveRecordExists(userDataDir: string): boolean {
const records = readPersistedData(userDataDir).workspaceSession?.sleepingAgentSessionsByPaneKey
return Object.values(records ?? {}).some(
(record) => record.providerSession?.id === PROVIDER_SESSION_ID
)
}
test.describe.configure({ mode: 'serial' })
test('resumes a live agent record after force-exit restart when pane PTY ownership is gone', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
if (!repoPath || !existsSync(repoPath)) {
test.skip(true, 'Global setup did not produce a seeded test repo')
return
}
const session = createRestartSession(testInfo)
let firstApp: ElectronApplication | null = null
let secondApp: ElectronApplication | null = null
try {
const firstLaunch = await session.launch()
firstApp = firstLaunch.app
const page = firstLaunch.page
const worktreeId = await attachRepoAndOpenTerminal(page, repoPath)
await waitForSessionReady(page)
await waitForActiveWorktree(page)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
await waitForPaneCount(page, 1, 30_000)
const descriptor = await waitForActivePaneHookDescriptor(page)
const ptyId = await waitForActivePanePtyId(page)
const marker = `AGENT_LIVE_FORCE_EXIT_${Date.now()}`
await execInTerminal(page, ptyId, `echo ${marker}`)
await waitForTerminalOutput(page, marker)
await page.evaluate(
({ paneKey, worktreeId: wtId, providerSessionId }) => {
window.__store
?.getState()
.setAgentStatus(
paneKey,
{ state: 'working', prompt: 'finish the task', agentType: 'codex' },
'Codex',
undefined,
{ worktreeId: wtId },
{ providerSession: { key: 'session_id', id: providerSessionId } }
)
},
{
paneKey: descriptor.paneKey,
worktreeId: descriptor.worktreeId,
providerSessionId: PROVIDER_SESSION_ID
}
)
await expect
.poll(() => persistedLiveRecordExists(session.userDataDir), {
timeout: 15_000,
message: 'Live sleeping-agent record was not persisted before force exit'
})
.toBe(true)
const daemonPid = readDaemonPid(session.userDataDir)
await forceKillElectronApp(firstApp)
firstApp = null
killPid(daemonPid)
stripPersistedPtyOwnership(session.userDataDir)
const secondLaunch = await session.launch()
secondApp = secondLaunch.app
await waitForSessionReady(secondLaunch.page)
await expect
.poll(
async () => secondLaunch.page.evaluate(() => window.__store?.getState().activeWorktreeId),
{ timeout: 15_000 }
)
.toBe(worktreeId)
await ensureTerminalVisible(secondLaunch.page)
await waitForActiveTerminalManager(secondLaunch.page, 30_000)
await waitForTerminalOutput(secondLaunch.page, PROVIDER_SESSION_ID, 30_000)
const terminalTabCount = await secondLaunch.page.evaluate(
(wtId) => (window.__store?.getState().tabsByWorktree[wtId] ?? []).length,
worktreeId
)
expect(terminalTabCount).toBe(2)
} finally {
if (secondApp) {
await session.close(secondApp)
}
if (firstApp) {
await forceKillElectronApp(firstApp)
}
await session.dispose()
}
})