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() function sleep(ms: number): Promise { 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 { 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 { 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 { 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 ) })