216 lines
8.2 KiB
TypeScript
216 lines
8.2 KiB
TypeScript
import { spawn, type ChildProcess } from 'node:child_process'
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { _electron as electron, type ElectronApplication } from '@stablyai/playwright-test'
|
|
import { test, expect, forwardElectronProcessLogs } from './helpers/orca-app'
|
|
import { TEST_REPO_PATH_FILE } from './global-setup'
|
|
import { getE2ECompletedOnboardingProfile } from './helpers/e2e-completed-onboarding-profile'
|
|
import { getOrcaElectronLaunchArgs } from './helpers/electron-launch-args'
|
|
import { cleanupE2EDaemons, closeElectronAppForE2E } from './helpers/electron-process-shutdown'
|
|
import {
|
|
assertElectronResolvedIsolatedHome,
|
|
createElectronHomeIsolation,
|
|
type ElectronHomeIsolation
|
|
} from './helpers/electron-home-isolation'
|
|
import {
|
|
discoverActivePtyId,
|
|
execInTerminal,
|
|
getTerminalContent,
|
|
waitForActiveTerminalManager,
|
|
waitForPaneCount,
|
|
waitForTerminalOutput
|
|
} from './helpers/terminal'
|
|
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
|
import { RuntimeClient } from '../../src/cli/runtime/client'
|
|
import type {
|
|
RuntimeStatus,
|
|
RuntimeTerminalCreate,
|
|
RuntimeTerminalRead
|
|
} from '../../src/shared/runtime-types'
|
|
import { PROTOCOL_VERSION } from '../../src/main/daemon/types'
|
|
|
|
const electronPackageDir = path.join(process.cwd(), 'node_modules', 'electron')
|
|
const electronPath = path.join(
|
|
electronPackageDir,
|
|
'dist',
|
|
readFileSync(path.join(electronPackageDir, 'path.txt'), 'utf8').trim()
|
|
)
|
|
|
|
function createHeadlessLaunchIsolation(userDataDir: string): ElectronHomeIsolation {
|
|
const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env
|
|
void _unused
|
|
return createElectronHomeIsolation({
|
|
inheritedEnv: cleanEnv,
|
|
launchEnv: {
|
|
NODE_ENV: 'development',
|
|
ORCA_E2E_HEADLESS: '1',
|
|
// Why: production builds always use the lock; this opt-in makes the dev
|
|
// E2E bundle exercise the same second-instance ownership path.
|
|
ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1'
|
|
},
|
|
extraEnv: {},
|
|
userDataDir,
|
|
codexRealHomeEnabled: false
|
|
})
|
|
}
|
|
|
|
function readDaemonPid(userDataDir: string): number {
|
|
const raw = readFileSync(
|
|
path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`),
|
|
'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
|
|
}
|
|
|
|
async function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
|
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
return true
|
|
}
|
|
return await new Promise((resolve) => {
|
|
const onExit = (): void => {
|
|
clearTimeout(timeout)
|
|
resolve(true)
|
|
}
|
|
const timeout = setTimeout(() => {
|
|
child.off('exit', onExit)
|
|
resolve(false)
|
|
}, timeoutMs)
|
|
child.once('exit', onExit)
|
|
})
|
|
}
|
|
|
|
test.describe.configure({ mode: 'serial' })
|
|
|
|
test('promotes the headless owner without replacing its daemon terminal', async (// oxlint-disable-next-line no-empty-pattern -- This lifecycle test owns both launches and intentionally opts out of the default app fixture.
|
|
{}, testInfo) => {
|
|
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim()
|
|
if (!repoPath || !existsSync(repoPath)) {
|
|
test.skip(true, 'Global setup did not produce a seeded test repo')
|
|
return
|
|
}
|
|
|
|
const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js')
|
|
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-serve-promotion-'))
|
|
const homeIsolation = createHeadlessLaunchIsolation(userDataDir)
|
|
const env = homeIsolation.env
|
|
let serveApp: ElectronApplication | null = null
|
|
let activatingProcess: ChildProcess | null = null
|
|
|
|
writeFileSync(
|
|
path.join(userDataDir, 'orca-data.json'),
|
|
`${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n`
|
|
)
|
|
|
|
try {
|
|
serveApp = await electron.launch({
|
|
args: [...getOrcaElectronLaunchArgs(mainPath, false), '--serve', '--serve-no-pairing'],
|
|
env
|
|
})
|
|
const resolvedHome = await serveApp.evaluate(({ app }) => app.getPath('home'))
|
|
assertElectronResolvedIsolatedHome(resolvedHome, homeIsolation)
|
|
// Why: this spec bypasses the app fixture, so opt into its gated Electron log capture for CI failures.
|
|
forwardElectronProcessLogs(serveApp, testInfo)
|
|
const ownerPid = serveApp.process().pid
|
|
const client = new RuntimeClient(userDataDir, 5_000)
|
|
|
|
await expect
|
|
.poll(async () => (await client.getCliStatus()).result.app.desktopWindowStatus, {
|
|
timeout: 60_000,
|
|
message: 'headless serve never became safely openable'
|
|
})
|
|
.toBe('openable')
|
|
|
|
const beforeStatus = await client.call<RuntimeStatus>('status.get')
|
|
const daemonPidBefore = readDaemonPid(userDataDir)
|
|
await client.call('repo.add', { path: repoPath, kind: 'git' })
|
|
const created = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
|
|
worktree: `path:${repoPath}`,
|
|
title: 'Serve promotion continuity'
|
|
})
|
|
const terminal = created.result.terminal
|
|
if (!terminal.ptyId) {
|
|
throw new Error('Headless terminal did not expose its daemon PTY id')
|
|
}
|
|
|
|
const beforeMarker = `SERVE_PROMOTION_BEFORE_${Date.now()}`
|
|
await client.call('terminal.send', {
|
|
terminal: terminal.handle,
|
|
text: `echo ${beforeMarker}`,
|
|
enter: true
|
|
})
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const response = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', {
|
|
terminal: terminal.handle,
|
|
limit: 200
|
|
})
|
|
return response.result.terminal.tail.join('\n')
|
|
},
|
|
{ timeout: 15_000 }
|
|
)
|
|
.toContain(beforeMarker)
|
|
|
|
const forwardAppLogs = process.env.ORCA_E2E_FORWARD_APP_LOGS === '1'
|
|
activatingProcess = spawn(electronPath, getOrcaElectronLaunchArgs(mainPath, false), {
|
|
env,
|
|
stdio: forwardAppLogs ? 'pipe' : 'ignore'
|
|
})
|
|
activatingProcess.on('error', (error) => {
|
|
console.error('[e2e] activating process failed to spawn:', error)
|
|
})
|
|
if (forwardAppLogs) {
|
|
const prefix = '[e2e] activating process'
|
|
activatingProcess.stdout?.on('data', (chunk: Buffer) => {
|
|
console.log(`${prefix} stdout: ${chunk.toString().trimEnd()}`)
|
|
})
|
|
activatingProcess.stderr?.on('data', (chunk: Buffer) => {
|
|
console.error(`${prefix} stderr: ${chunk.toString().trimEnd()}`)
|
|
})
|
|
activatingProcess.on('exit', (code, signal) => {
|
|
console.log(`${prefix} exit: code=${code ?? 'null'} signal=${signal ?? 'null'}`)
|
|
})
|
|
}
|
|
|
|
const page = await serveApp.firstWindow({ timeout: 60_000 })
|
|
await page.waitForLoadState('domcontentloaded')
|
|
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
|
|
await waitForSessionReady(page)
|
|
await waitForActiveWorktree(page)
|
|
await ensureTerminalVisible(page)
|
|
await waitForActiveTerminalManager(page, 30_000)
|
|
await waitForPaneCount(page, 1, 30_000)
|
|
|
|
const promotedPtyId = await discoverActivePtyId(page)
|
|
const afterStatus = await client.call<RuntimeStatus>('status.get')
|
|
expect(serveApp.process().pid).toBe(ownerPid)
|
|
expect(afterStatus.result.runtimeId).toBe(beforeStatus.result.runtimeId)
|
|
expect(afterStatus.result.desktopWindowStatus).toBe('available')
|
|
expect(promotedPtyId).toBe(terminal.ptyId)
|
|
expect(readDaemonPid(userDataDir)).toBe(daemonPidBefore)
|
|
expect(await waitForProcessExit(activatingProcess, 10_000)).toBe(true)
|
|
await waitForTerminalOutput(page, beforeMarker, 30_000)
|
|
|
|
const afterMarker = `SERVE_PROMOTION_AFTER_${Date.now()}`
|
|
await execInTerminal(page, promotedPtyId, `echo ${afterMarker}`)
|
|
await waitForTerminalOutput(page, afterMarker, 15_000)
|
|
await expect(page.locator('.xterm:visible').first()).toBeVisible()
|
|
expect(await getTerminalContent(page)).toContain(beforeMarker)
|
|
} finally {
|
|
if (activatingProcess && activatingProcess.exitCode === null) {
|
|
activatingProcess.kill('SIGKILL')
|
|
await waitForProcessExit(activatingProcess, 5_000)
|
|
}
|
|
if (serveApp) {
|
|
await closeElectronAppForE2E(serveApp)
|
|
}
|
|
await cleanupE2EDaemons(userDataDir)
|
|
rmSync(userDataDir, { recursive: true, force: true })
|
|
}
|
|
})
|