Files
orca/config/scripts/development/launch/run-electron-vite-dev.test.ts
T
Neil b10c07fbc3 refactor(startup): organize remaining lifecycle and service owners (#41624)
* refactor(startup): organize remaining lifecycle and service owners

* fix(tests): bind quit mocks to concrete shutdown owners
2026-11-12 11:08:38 -08:00

352 lines
11 KiB
TypeScript

import { existsSync, mkdtempSync, readFileSync, readlinkSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawn, type ChildProcess } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
const processesToCleanUp = new Set<number>()
function sleep(ms: number): Promise<void> {
return new Promise((resolvePromise) => {
setTimeout(resolvePromise, ms)
})
}
function processExists(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error ? error.code : null
if (code === 'ESRCH') {
return false
}
throw error
}
}
async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
const start = Date.now()
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error('Timed out waiting for condition')
}
await sleep(50)
}
}
function readPidFile(pidFile: string): number[] {
return readFileSync(pidFile, 'utf8')
.trim()
.split(/\s+/)
.map((pid) => Number.parseInt(pid, 10))
.filter((pid) => Number.isFinite(pid))
}
function trackPidFile(pidFile: string): number[] {
const pids = readPidFile(pidFile)
for (const pid of pids) {
processesToCleanUp.add(pid)
}
return pids
}
function waitForExit(
child: ChildProcess,
timeoutMs = 5000
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve({ code: child.exitCode, signal: child.signalCode })
}
return new Promise((resolveExit, reject) => {
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error('Timed out waiting for dev wrapper exit'))
}, timeoutMs)
child.once('exit', (code, signal) => {
clearTimeout(timer)
resolveExit({ code, signal })
})
})
}
async function stopWrapper(
wrapper: ChildProcess
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
if (wrapper.pid) {
processesToCleanUp.add(wrapper.pid)
}
if (wrapper.exitCode === null && wrapper.signalCode === null) {
wrapper.kill('SIGINT')
}
const result = await waitForExit(wrapper)
if (wrapper.pid) {
processesToCleanUp.delete(wrapper.pid)
}
return result
}
async function stopWrapperAndTrackedPids(wrapper: ChildProcess, pids: number[]): Promise<void> {
await stopWrapper(wrapper)
await waitFor(() => pids.every((pid) => !processExists(pid)))
for (const pid of pids) {
processesToCleanUp.delete(pid)
}
}
function devWrapperTestEnv(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = { ...process.env }
for (const key of Object.keys(env)) {
if (key.startsWith('ORCA_DEV_')) {
delete env[key]
}
}
delete env.ELECTRON_EXEC_PATH
return {
...env,
ORCA_SKIP_DEV_STORAGE_RECLAIM: '1',
// Why: the runner otherwise creates this worktree's real per-worktree profile under appData.
ORCA_DEV_USER_DATA_PATH: join(tmpdir(), 'orca-dev-wrapper-test-profile'),
...extra
}
}
/** Electron.app clone, Swift builds and deep signing exceed 15s under suite contention; generous failure-only timeout covers preparation. */
const PREPARE_TIMEOUT_MS = 90_000
/** Retain wrapper stderr so preparation failures do not become unexplained timeouts. */
function spawnDevWrapper(
args: string[],
env: NodeJS.ProcessEnv
): { wrapper: ChildProcess; readOutput: () => string } {
const wrapper = spawn(process.execPath, args, {
cwd: resolve('.'),
env,
stdio: ['ignore', 'pipe', 'pipe']
})
let output = ''
const collect = (chunk: Buffer): void => {
output += chunk.toString()
}
wrapper.stdout?.on('data', collect)
wrapper.stderr?.on('data', collect)
return { wrapper, readOutput: () => output }
}
async function waitForEnvFile(envFile: string, readOutput: () => string): Promise<void> {
try {
await waitFor(() => {
try {
return readFileSync(envFile, 'utf8').trim().length > 0
} catch {
return false
}
}, PREPARE_TIMEOUT_MS)
} catch (error) {
throw new Error(
`${(error as Error).message}: the dev wrapper never wrote ${envFile}. Wrapper output:\n${readOutput() || '(none)'}`
)
}
}
describe('run-electron-vite-dev', () => {
afterEach(async () => {
for (const pid of processesToCleanUp) {
try {
process.kill(pid, 'SIGTERM')
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error ? error.code : null
if (code !== 'ESRCH') {
throw error
}
}
}
await sleep(100)
for (const pid of processesToCleanUp) {
try {
if (processExists(pid)) {
process.kill(pid, 'SIGKILL')
}
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error ? error.code : null
if (code !== 'ESRCH') {
throw error
}
}
}
processesToCleanUp.clear()
})
it.skipIf(process.platform === 'win32')(
'kills the descendant process tree on SIGINT',
async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-'))
const pidFile = join(tempDir, 'grandchild.pid')
const wrapperPath = resolve('config/scripts/development/launch/run-electron-vite-dev.mjs')
const fakeCliPath = resolve(
'config/scripts/development/launch/fixtures/fake-electron-vite-dev-cli.mjs'
)
const wrapper = spawn(process.execPath, [wrapperPath], {
cwd: resolve('.'),
env: devWrapperTestEnv({
ORCA_ELECTRON_VITE_CLI: fakeCliPath,
ORCA_SKIP_DEV_CLI_PREPARE: '1',
ORCA_SKIP_DEV_ELECTRON_APP_PREPARE: '1',
ORCA_SKIP_DEV_WEB_PREPARE: '1',
ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile
}),
stdio: 'ignore'
})
expect(wrapper.pid).toBeTypeOf('number')
processesToCleanUp.add(wrapper.pid!)
await waitFor(() => {
try {
return readFileSync(pidFile, 'utf8').trim().length > 0
} catch {
return false
}
})
const [fakeCliPid, grandchildPid] = trackPidFile(pidFile)
expect(Number.isFinite(fakeCliPid)).toBe(true)
expect(Number.isFinite(grandchildPid)).toBe(true)
expect(processExists(fakeCliPid)).toBe(true)
expect(processExists(grandchildPid)).toBe(true)
const { code } = await stopWrapper(wrapper)
expect(code).toBe(130)
await waitFor(() => !processExists(fakeCliPid) && !processExists(grandchildPid))
processesToCleanUp.delete(fakeCliPid)
processesToCleanUp.delete(grandchildPid)
}
)
it.skipIf(process.platform !== 'darwin')(
'rebuilds the copied Electron app when Chromium resources are missing',
async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-'))
const wrapperPath = resolve('config/scripts/development/launch/run-electron-vite-dev.mjs')
const fakeCliPath = resolve(
'config/scripts/development/launch/fixtures/fake-electron-vite-dev-cli.mjs'
)
const baseEnv = devWrapperTestEnv({
ORCA_ELECTRON_VITE_CLI: fakeCliPath,
ORCA_SKIP_DEV_CLI_PREPARE: '1',
ORCA_SKIP_DEV_WEB_PREPARE: '1',
ORCA_DEV_BRANCH: 'feature/rebuild-electron-app',
ORCA_DEV_WORKTREE_NAME: 'electron-app-rebuild'
})
async function runWrapper(runId: string): Promise<{ electronExecPath: string }> {
const pidFile = join(tempDir, `${runId}.pid`)
const envFile = join(tempDir, `${runId}.json`)
const { wrapper, readOutput } = spawnDevWrapper(
[wrapperPath, '--remote-debugging-port=9448'],
{
...baseEnv,
ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile,
ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile
}
)
expect(wrapper.pid).toBeTypeOf('number')
processesToCleanUp.add(wrapper.pid!)
await waitForEnvFile(envFile, readOutput)
const trackedPids = trackPidFile(pidFile)
const envSnapshot = JSON.parse(readFileSync(envFile, 'utf8')) as {
electronExecPath: string | null
}
expect(envSnapshot.electronExecPath).toBeTypeOf('string')
await stopWrapperAndTrackedPids(wrapper, trackedPids)
return { electronExecPath: envSnapshot.electronExecPath! }
}
let distDir: string | null = null
try {
const firstRun = await runWrapper('first')
const appPath = dirname(dirname(dirname(firstRun.electronExecPath)))
distDir = dirname(appPath)
const icuDataPath = join(
appPath,
'Contents',
'Frameworks',
'Electron Framework.framework',
'Resources',
'icudtl.dat'
)
expect(existsSync(icuDataPath)).toBe(true)
rmSync(icuDataPath, { force: true })
expect(existsSync(icuDataPath)).toBe(false)
const secondRun = await runWrapper('second')
expect(secondRun.electronExecPath).toBe(firstRun.electronExecPath)
expect(existsSync(icuDataPath)).toBe(true)
} finally {
if (distDir) {
rmSync(distDir, { recursive: true, force: true })
}
}
},
// Two full prepares, each budgeted at PREPARE_TIMEOUT_MS.
PREPARE_TIMEOUT_MS * 2 + 30_000
)
it.skipIf(process.platform !== 'darwin')(
'preserves relative Electron framework symlinks in the copied mac dev app',
async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-'))
const pidFile = join(tempDir, 'grandchild.pid')
const envFile = join(tempDir, 'env.json')
const wrapperPath = resolve('config/scripts/development/launch/run-electron-vite-dev.mjs')
const fakeCliPath = resolve(
'config/scripts/development/launch/fixtures/fake-electron-vite-dev-cli.mjs'
)
const { wrapper, readOutput } = spawnDevWrapper(
[wrapperPath, '--remote-debugging-port=9448'],
devWrapperTestEnv({
ORCA_ELECTRON_VITE_CLI: fakeCliPath,
ORCA_SKIP_DEV_CLI_PREPARE: '1',
ORCA_SKIP_DEV_WEB_PREPARE: '1',
ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile,
ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile,
ORCA_DEV_BRANCH: 'feature/framework-symlinks',
ORCA_DEV_WORKTREE_NAME: 'symlink-ui'
})
)
expect(wrapper.pid).toBeTypeOf('number')
processesToCleanUp.add(wrapper.pid!)
await waitForEnvFile(envFile, readOutput)
const trackedPids = trackPidFile(pidFile)
const envSnapshot = JSON.parse(readFileSync(envFile, 'utf8')) as {
electronExecPath: string | null
}
expect(envSnapshot.electronExecPath).toBeTypeOf('string')
const appPath = dirname(dirname(dirname(envSnapshot.electronExecPath!)))
const frameworkPath = join(appPath, 'Contents', 'Frameworks', 'Electron Framework.framework')
expect(readlinkSync(join(frameworkPath, 'Resources'))).toBe('Versions/Current/Resources')
expect(readlinkSync(join(frameworkPath, 'Electron Framework'))).toBe(
'Versions/Current/Electron Framework'
)
expect(readlinkSync(join(frameworkPath, 'Versions', 'Current'))).toBe('A')
await stopWrapperAndTrackedPids(wrapper, trackedPids)
},
PREPARE_TIMEOUT_MS + 30_000
)
})