Files
2026-07-13 13:05:33 +08:00

182 lines
5.7 KiB
TypeScript

import { execFile, spawn, type ChildProcess } from 'node:child_process'
import { access, mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const RUNTIME_METADATA_FILE = 'orca-runtime.json'
let orcaDevUserDataPath: string | null = null
let orcaServeProcess: ChildProcess | null = null
let orcaServeStdout = ''
let orcaServeStderr = ''
export type CliResult = {
stdout: string
stderr: string
}
type RunOrcaCliOptions = {
retryMissingRuntimeMetadata?: boolean
}
export async function runOrcaCli(
args: string[],
options: RunOrcaCliOptions = {}
): Promise<CliResult> {
try {
return await runOrcaCliOnce(args)
} catch (error) {
if (
options.retryMissingRuntimeMetadata !== false &&
isMissingRuntimeMetadataError(args, error)
) {
// Why: Windows CI can let the dev runtime exit while launching the
// fixture app; reopen once so the desktop action gets a live runtime.
await ensureOrcaRuntimeLaunched()
return await runOrcaCliOnce(args)
}
throw error
}
}
async function runOrcaCliOnce(args: string[]): Promise<CliResult> {
const devCli = join(process.cwd(), 'config/scripts/orca-dev.mjs')
const command = process.env.ORCA_COMPUTER_CLI ?? process.execPath
const cliArgs = process.env.ORCA_COMPUTER_CLI ? args : [devCli, ...args]
const env = { ...process.env }
if (!process.env.ORCA_COMPUTER_CLI && !env.ORCA_DEV_USER_DATA_PATH) {
env.ORCA_DEV_USER_DATA_PATH = await getComputerE2eOrcaDevUserDataPath()
}
try {
const result = await execFileAsync(command, cliArgs, {
env,
maxBuffer: 20 * 1024 * 1024
})
return { stdout: result.stdout, stderr: result.stderr }
} catch (error) {
if (error && typeof error === 'object' && 'stdout' in error && 'stderr' in error) {
const output = error as { message: string; stdout: string; stderr: string }
throw new Error(`${output.message}\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}`)
}
throw error
}
}
export async function ensureOrcaRuntimeLaunched(): Promise<void> {
if (!process.env.ORCA_COMPUTER_CLI && process.platform === 'win32') {
await ensureOrcaRuntimeServed()
return
}
await runOrcaCli(['open', '--json'], { retryMissingRuntimeMetadata: false })
await waitForOrcaRuntimeReady()
}
export async function stopOrcaRuntime(): Promise<void> {
const processToStop = orcaServeProcess
if (!processToStop?.pid) {
return
}
orcaServeProcess = null
if (process.platform === 'win32') {
try {
await execFileAsync('taskkill.exe', ['/PID', String(processToStop.pid), '/T', '/F'])
} catch {
// The foreground test runtime may already have exited.
}
return
}
processToStop.kill()
}
export function parseJsonOutput<T>(stdout: string): T {
return JSON.parse(stdout) as T
}
async function getComputerE2eOrcaDevUserDataPath(): Promise<string> {
if (!orcaDevUserDataPath) {
// Why: the shared orca-dev profile can keep an older runtime alive across
// local test runs, making computer-use E2E exercise stale provider code.
orcaDevUserDataPath = await mkdtemp(join(tmpdir(), 'orca-computer-runtime-'))
}
return orcaDevUserDataPath
}
async function waitForOrcaRuntimeReady(): Promise<void> {
const userDataPath = await getComputerE2eOrcaDevUserDataPath()
const metadataPath = join(userDataPath, RUNTIME_METADATA_FILE)
const deadline = Date.now() + 15000
let lastError: unknown = null
while (Date.now() < deadline) {
try {
await access(metadataPath)
const status = parseJsonOutput<{
result: { runtime: { reachable: boolean } }
}>((await runOrcaCli(['status', '--json'], { retryMissingRuntimeMetadata: false })).stdout)
if (status.result.runtime.reachable) {
return
}
} catch (error) {
lastError = error
}
await delay(250)
}
const detail = [
lastError instanceof Error ? `Last error: ${lastError.message}` : null,
orcaServeStdout.trim() ? `serve stdout: ${orcaServeStdout.trim()}` : null,
orcaServeStderr.trim() ? `serve stderr: ${orcaServeStderr.trim()}` : null
]
.filter(Boolean)
.join(' ')
throw new Error(`Orca runtime metadata was not ready at ${metadataPath}.${detail}`)
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function ensureOrcaRuntimeServed(): Promise<void> {
if (!orcaServeProcess || orcaServeProcess.exitCode !== null) {
const devCli = join(process.cwd(), 'config/scripts/orca-dev.mjs')
const env = {
...process.env,
ORCA_DEV_USER_DATA_PATH: await getComputerE2eOrcaDevUserDataPath()
}
orcaServeStdout = ''
orcaServeStderr = ''
orcaServeProcess = spawn(process.execPath, [devCli, 'serve', '--no-pairing', '--json'], {
env,
windowsHide: true
})
orcaServeProcess.stdout?.on('data', (chunk) => {
orcaServeStdout += String(chunk)
})
orcaServeProcess.stderr?.on('data', (chunk) => {
orcaServeStderr += String(chunk)
})
orcaServeProcess.once('exit', () => {
orcaServeProcess = null
})
process.once('exit', () => {
orcaServeProcess?.kill()
})
}
await waitForOrcaRuntimeReady()
}
function isMissingRuntimeMetadataError(args: string[], error: unknown): boolean {
if (args[0] !== 'computer') {
return false
}
if (!error || typeof error !== 'object' || !('message' in error)) {
return false
}
const message = String((error as { message?: unknown }).message)
return (
message.includes('"code": "runtime_unavailable"') &&
message.includes('Could not read Orca runtime metadata')
)
}