mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
539 lines
19 KiB
TypeScript
539 lines
19 KiB
TypeScript
import { execFile, spawn } from 'node:child_process'
|
|
import { globSync } from 'node:fs'
|
|
import {
|
|
copyFile,
|
|
cp,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
rm,
|
|
stat,
|
|
symlink,
|
|
writeFile
|
|
} from 'node:fs/promises'
|
|
import { createRequire } from 'node:module'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join, sep } from 'node:path'
|
|
import { promisify } from 'node:util'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { createPackageWithOptions, statFile } from '@electron/asar'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { runProcess } from '@orca/process-host'
|
|
import { stagePackagedProcessHost } from '../../../config/scripts/packaged-process-host-fixture.mjs'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
const execFileAsync = promisify(execFile)
|
|
const itRunsUnixShell = process.platform === 'win32' ? it.skip : it
|
|
const unixTerminationSignals = ['SIGINT', 'SIGTERM'] as const
|
|
const builderConfig = require('../../../config/electron-builder.config.cjs') as {
|
|
files?: string[]
|
|
asarUnpack?: string[]
|
|
}
|
|
const linuxLauncherAsset = new URL('../../../resources/linux/bin/orca-ide', import.meta.url)
|
|
const darwinLauncherAsset = new URL('../../../resources/darwin/bin/orca', import.meta.url)
|
|
const unixLauncherFixtures = [
|
|
{
|
|
name: 'Linux',
|
|
asset: linuxLauncherAsset,
|
|
appDir: ['Orca'],
|
|
launcher: ['resources', 'bin', 'orca-ide'],
|
|
executable: ['orca-ide'],
|
|
cli: ['resources', 'app.asar.unpacked', 'out', 'cli', 'index.js']
|
|
},
|
|
{
|
|
name: 'macOS',
|
|
asset: darwinLauncherAsset,
|
|
appDir: ['Orca.app'],
|
|
launcher: ['Contents', 'Resources', 'bin', 'orca'],
|
|
executable: ['Contents', 'MacOS', 'Orca'],
|
|
cli: ['Contents', 'Resources', 'app.asar.unpacked', 'out', 'cli', 'index.js']
|
|
}
|
|
] as const
|
|
|
|
describe('packaged CLI assets', () => {
|
|
it('ships embedded skill guides with the CLI instead of source Markdown', () => {
|
|
// Why: `skills get` must work from the packaged CLI without falling back to
|
|
// authoring-only files that do not exist in installed applications.
|
|
expect(builderConfig.asarUnpack).toContain('out/cli/**')
|
|
expect(builderConfig.files).toContain('!skill-guides{,/**/*}')
|
|
})
|
|
|
|
it('loads worktree link validation from the installed CLI', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-cli-gitlab-package-'))
|
|
const projectDir = fileURLToPath(new URL('../../../', import.meta.url))
|
|
const source = join(root, 'app-source')
|
|
const archive = join(root, 'resources', 'app.asar')
|
|
try {
|
|
const compile = await runProcess({
|
|
program: process.execPath,
|
|
args: [
|
|
join(projectDir, 'node_modules', 'typescript', 'bin', 'tsc'),
|
|
'-p',
|
|
join(projectDir, 'config', 'tsconfig.build.cli.json'),
|
|
'--outDir',
|
|
join(source, 'out'),
|
|
'--composite',
|
|
'false',
|
|
'--incremental',
|
|
'false',
|
|
// This fixture checks the emitted package; the CLI typecheck runs separately.
|
|
'--noCheck'
|
|
],
|
|
cwd: projectDir,
|
|
timeoutMs: 60_000
|
|
})
|
|
expect(compile.code, compile.stdout + compile.stderr).toBe(0)
|
|
expect(globSync('**/*.{test,spec}.{js,jsx,cjs,mjs}', { cwd: join(source, 'out') })).toEqual(
|
|
[]
|
|
)
|
|
await mkdir(dirname(archive), { recursive: true })
|
|
await writeFile(join(source, 'out', 'package.json'), JSON.stringify({ type: 'commonjs' }))
|
|
for (const name of ['zod', 'tweetnacl']) {
|
|
await cp(
|
|
join(projectDir, 'node_modules', name),
|
|
join(root, 'resources', 'node_modules', name),
|
|
{
|
|
recursive: true,
|
|
dereference: true
|
|
}
|
|
)
|
|
}
|
|
await stagePackagedProcessHost(join(root, 'resources'))
|
|
await createPackageWithOptions(source, archive, {
|
|
unpack: `{${builderConfig.asarUnpack?.map((pattern) => join(source, pattern).split(sep).join('/')).join(',')}}`
|
|
})
|
|
expect(statFile(archive, join('out', 'cli', 'index.js')).unpacked).toBe(true)
|
|
const result = await runProcess({
|
|
program: process.execPath,
|
|
args: [
|
|
join(`${archive}.unpacked`, 'out', 'cli', 'index.js'),
|
|
'worktree',
|
|
'create',
|
|
'--repo',
|
|
'id:repo',
|
|
'--name',
|
|
'packaged-link',
|
|
'--no-parent',
|
|
'--pr',
|
|
'0',
|
|
'--json'
|
|
],
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
ORCA_BACKGROUND_LAUNCH: '1',
|
|
NODE_PATH: '',
|
|
NODE_OPTIONS: '',
|
|
ORCA_USER_DATA_PATH: join(root, 'user-data'),
|
|
HOME: join(root, 'home')
|
|
},
|
|
timeoutMs: 10_000
|
|
})
|
|
expect(result.code, result.stderr).toBe(1)
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
ok: false,
|
|
error: { code: 'invalid_argument', message: 'Pass a positive safe integer for --pr.' }
|
|
})
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
itRunsUnixShell('keeps the Linux launcher executable in packaged resources', async () => {
|
|
const launcherStats = await stat(linuxLauncherAsset)
|
|
expect(launcherStats.mode & 0o111).not.toBe(0)
|
|
})
|
|
|
|
it('retries an incomplete listener-state write', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-listener-state-'))
|
|
const statePath = join(root, 'listener-state.json')
|
|
const expectedState = { pid: 1234, port: 5678 }
|
|
await writeFile(statePath, '{"pid":', 'utf8')
|
|
const completedWrite = new Promise<void>((resolve, reject) => {
|
|
setTimeout(() => {
|
|
writeFile(statePath, JSON.stringify(expectedState), 'utf8').then(resolve, reject)
|
|
}, 50)
|
|
})
|
|
|
|
try {
|
|
await expect(waitForListenerState(statePath)).resolves.toEqual(expectedState)
|
|
} finally {
|
|
await completedWrite
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it.each([
|
|
{ state: { pid: '1234', port: 5678 }, reason: 'non-numeric pid' },
|
|
{ state: { pid: 0, port: 5678 }, reason: 'non-positive pid' },
|
|
{ state: { pid: 1234, port: '5678' }, reason: 'non-numeric port' },
|
|
{ state: { pid: 1234, port: 65_536 }, reason: 'out-of-range port' }
|
|
])('rejects $reason in listener state', async ({ state }) => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-listener-state-'))
|
|
const statePath = join(root, 'listener-state.json')
|
|
try {
|
|
await writeFile(statePath, JSON.stringify(state), 'utf8')
|
|
await expect(waitForListenerState(statePath)).rejects.toThrow(
|
|
'Invalid launcher listener state'
|
|
)
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
itRunsUnixShell.each(unixLauncherFixtures)(
|
|
'delivers Unix termination signals to the $name executable and releases its listener',
|
|
async (launcherFixture) => {
|
|
for (const signal of unixTerminationSignals) {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-unix-cli-signal-'))
|
|
const appDir = join(root, ...launcherFixture.appDir)
|
|
const launcherPath = join(appDir, ...launcherFixture.launcher)
|
|
const electronPath = join(appDir, ...launcherFixture.executable)
|
|
const cliPath = join(appDir, ...launcherFixture.cli)
|
|
const statePath = join(root, `${signal}-listener-state.json`)
|
|
let executablePid: number | null = null
|
|
let launcher: ReturnType<typeof spawn> | null = null
|
|
try {
|
|
await mkdir(dirname(launcherPath), { recursive: true })
|
|
await mkdir(dirname(electronPath), { recursive: true })
|
|
await mkdir(dirname(cliPath), { recursive: true })
|
|
await copyFile(launcherFixture.asset, launcherPath)
|
|
await writeFile(cliPath, '', 'utf8')
|
|
await writeFile(
|
|
electronPath,
|
|
`#!/usr/bin/env node
|
|
const fs = require('node:fs')
|
|
const net = require('node:net')
|
|
const server = net.createServer()
|
|
const shutdown = () => server.close(() => process.exit(0))
|
|
process.on('SIGINT', shutdown)
|
|
process.on('SIGTERM', shutdown)
|
|
server.listen(0, '127.0.0.1', () => {
|
|
fs.writeFileSync(process.env.ORCA_TEST_LISTENER_STATE, JSON.stringify({
|
|
pid: process.pid,
|
|
port: server.address().port
|
|
}))
|
|
})
|
|
`,
|
|
{ encoding: 'utf8', mode: 0o755 }
|
|
)
|
|
|
|
launcher = spawn(launcherPath, [], {
|
|
env: { ...process.env, ORCA_TEST_LISTENER_STATE: statePath },
|
|
stdio: 'ignore'
|
|
})
|
|
const state = await waitForListenerState(statePath)
|
|
executablePid = state.pid
|
|
const launcherPid = launcher.pid
|
|
const launcherExitPromise = waitForChildExit(launcher, 5_000)
|
|
launcher.kill(signal)
|
|
const launcherExit = await launcherExitPromise
|
|
const exitCode = launcherExit?.[0]
|
|
const exitSignal = launcherExit?.[1]
|
|
|
|
expect.soft(executablePid).toBe(launcherPid)
|
|
expect.soft(launcherExit).not.toBeNull()
|
|
expect.soft(exitCode).toBe(0)
|
|
expect.soft(exitSignal).toBeNull()
|
|
expect.soft(await waitForPortRelease(state.port)).toBe(true)
|
|
} finally {
|
|
if (launcher?.pid) {
|
|
await terminateSyntheticProcess(launcher.pid)
|
|
}
|
|
if (executablePid && executablePid !== launcher?.pid) {
|
|
await terminateSyntheticProcess(executablePid)
|
|
}
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
itRunsUnixShell(
|
|
'runs the Linux launcher from its packaged path and installed symlink',
|
|
async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-linux-cli-'))
|
|
try {
|
|
const appDir = join(root, 'Orca')
|
|
const resourcesDir = join(appDir, 'resources')
|
|
const launcherDir = join(resourcesDir, 'bin')
|
|
const cliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
|
|
const launcherPath = join(launcherDir, 'orca-ide')
|
|
const electronPath = join(appDir, 'orca-ide')
|
|
const cliPath = join(cliDir, 'index.js')
|
|
|
|
await mkdir(launcherDir, { recursive: true })
|
|
await mkdir(cliDir, { recursive: true })
|
|
await copyFile(linuxLauncherAsset, launcherPath)
|
|
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
|
|
await writeFile(cliPath, '', 'utf8')
|
|
await writeFile(
|
|
electronPath,
|
|
`#!/usr/bin/env bash
|
|
printf 'electron=%s\\n' "$0"
|
|
printf 'run_as_node=%s\\n' "\${ELECTRON_RUN_AS_NODE-}"
|
|
printf 'arg=%s\\n' "$@"
|
|
`,
|
|
{ encoding: 'utf8', mode: 0o755 }
|
|
)
|
|
|
|
const direct = await execFileAsync(launcherPath, ['--help'])
|
|
expect(direct.stdout).toContain(`electron=${electronPath}`)
|
|
expect(direct.stdout).toContain('run_as_node=1')
|
|
expect(direct.stdout).toContain(`arg=${cliPath}`)
|
|
expect(direct.stdout).toContain('arg=--help')
|
|
|
|
const homeDir = join(root, 'home')
|
|
const commandDir = join(homeDir, '.local', 'bin')
|
|
const commandPath = join(commandDir, 'orca-ide')
|
|
await mkdir(commandDir, { recursive: true })
|
|
await mkdir(join(homeDir, 'orca'), { recursive: true })
|
|
await symlink(launcherPath, commandPath)
|
|
|
|
const symlinked = await execFileAsync(commandPath, ['--help'], {
|
|
env: { ...process.env, HOME: homeDir }
|
|
})
|
|
expect(symlinked.stdout).toContain(`electron=${electronPath}`)
|
|
expect(symlinked.stdout).toContain('run_as_node=1')
|
|
expect(symlinked.stdout).toContain(`arg=${cliPath}`)
|
|
expect(symlinked.stdout).toContain('arg=--help')
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
}
|
|
)
|
|
|
|
// Observe each launcher's actual child env and argv, including arguments with spaces.
|
|
itRunsUnixShell.each(unixLauncherFixtures)(
|
|
'sanitizes node env and forwards argv verbatim through the $name launcher',
|
|
async (launcherFixture) => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-unix-cli-env-'))
|
|
try {
|
|
const appDir = join(root, ...launcherFixture.appDir)
|
|
const launcherPath = join(appDir, ...launcherFixture.launcher)
|
|
const electronPath = join(appDir, ...launcherFixture.executable)
|
|
const cliPath = join(appDir, ...launcherFixture.cli)
|
|
|
|
await mkdir(dirname(launcherPath), { recursive: true })
|
|
await mkdir(dirname(electronPath), { recursive: true })
|
|
await mkdir(dirname(cliPath), { recursive: true })
|
|
await copyFile(launcherFixture.asset, launcherPath)
|
|
await writeFile(cliPath, '', 'utf8')
|
|
await writeFile(
|
|
electronPath,
|
|
`#!/usr/bin/env bash
|
|
node -e 'console.log(JSON.stringify({
|
|
argv: process.argv.slice(1),
|
|
runAsNode: process.env.ELECTRON_RUN_AS_NODE,
|
|
nodeOptions: process.env.NODE_OPTIONS ?? null,
|
|
orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null,
|
|
nodeReplExternalModule: process.env.NODE_REPL_EXTERNAL_MODULE ?? null,
|
|
orcaNodeReplExternalModule: process.env.ORCA_NODE_REPL_EXTERNAL_MODULE ?? null
|
|
}))' -- "$@"
|
|
`,
|
|
{ encoding: 'utf8', mode: 0o755 }
|
|
)
|
|
|
|
const result = await execFileAsync(launcherPath, ['--help', 'two words'], {
|
|
env: {
|
|
...process.env,
|
|
ELECTRON_RUN_AS_NODE: '0',
|
|
NODE_OPTIONS: '--trace-warnings',
|
|
NODE_REPL_EXTERNAL_MODULE: 'external-loader'
|
|
}
|
|
})
|
|
const payload = JSON.parse(result.stdout) as {
|
|
argv: string[]
|
|
runAsNode: string
|
|
nodeOptions: string | null
|
|
orcaNodeOptions: string | null
|
|
nodeReplExternalModule: string | null
|
|
orcaNodeReplExternalModule: string | null
|
|
}
|
|
|
|
expect(payload.argv).toEqual([cliPath, '--help', 'two words'])
|
|
expect(payload.runAsNode).toBe('1')
|
|
// Why: Electron's node bootstrap must not inherit these, but the CLI
|
|
// still needs to see what the user set.
|
|
expect(payload.nodeOptions).toBeNull()
|
|
expect(payload.orcaNodeOptions).toBe('--trace-warnings')
|
|
expect(payload.nodeReplExternalModule).toBeNull()
|
|
expect(payload.orcaNodeReplExternalModule).toBe('external-loader')
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
}
|
|
)
|
|
|
|
itRunsUnixShell('keeps Linux serve on the CLI entrypoint in node mode', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-linux-cli-serve-'))
|
|
try {
|
|
const appDir = join(root, 'Orca')
|
|
const resourcesDir = join(appDir, 'resources')
|
|
const launcherDir = join(resourcesDir, 'bin')
|
|
const cliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
|
|
const launcherPath = join(launcherDir, 'orca-ide')
|
|
const appRunPath = join(appDir, 'AppRun')
|
|
const electronPath = join(appDir, 'orca-ide')
|
|
const cliPath = join(cliDir, 'index.js')
|
|
const statePath = join(root, 'launch-state.json')
|
|
|
|
await mkdir(launcherDir, { recursive: true })
|
|
await mkdir(cliDir, { recursive: true })
|
|
await copyFile(linuxLauncherAsset, launcherPath)
|
|
await writeFile(cliPath, '', 'utf8')
|
|
// An accidental AppRun handoff would skip CLI validation and fail this contract.
|
|
await writeFile(
|
|
appRunPath,
|
|
`#!/usr/bin/env bash
|
|
printf 'unexpected AppRun handoff\n' >&2
|
|
exit 97
|
|
`,
|
|
{ encoding: 'utf8', mode: 0o755 }
|
|
)
|
|
await writeFile(
|
|
electronPath,
|
|
`#!/usr/bin/env node
|
|
require('node:fs').writeFileSync(process.env.ORCA_TEST_LAUNCH_STATE, JSON.stringify({
|
|
argv: process.argv.slice(2),
|
|
runAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null
|
|
}))
|
|
`,
|
|
{ encoding: 'utf8', mode: 0o755 }
|
|
)
|
|
|
|
await execFileAsync(launcherPath, ['serve', '--recipe-json', '--project-root', '/tmp/repo'], {
|
|
env: { ...process.env, ORCA_TEST_LAUNCH_STATE: statePath }
|
|
})
|
|
const payload = JSON.parse(await readFile(statePath, 'utf8')) as {
|
|
argv: string[]
|
|
runAsNode: string | null
|
|
}
|
|
expect(payload.argv).toEqual([
|
|
cliPath,
|
|
'serve',
|
|
'--recipe-json',
|
|
'--project-root',
|
|
'/tmp/repo'
|
|
])
|
|
expect(payload.runAsNode).toBe('1')
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
async function waitForListenerState(path: string): Promise<{ pid: number; port: number }> {
|
|
const deadline = Date.now() + 5_000
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const state: unknown = JSON.parse(await readFile(path, 'utf8'))
|
|
if (
|
|
typeof state !== 'object' ||
|
|
state === null ||
|
|
!('pid' in state) ||
|
|
typeof state.pid !== 'number' ||
|
|
!Number.isInteger(state.pid) ||
|
|
state.pid <= 0 ||
|
|
!('port' in state) ||
|
|
typeof state.port !== 'number' ||
|
|
!Number.isInteger(state.port) ||
|
|
state.port <= 0 ||
|
|
state.port > 65_535
|
|
) {
|
|
throw new Error('Invalid launcher listener state')
|
|
}
|
|
return { pid: state.pid, port: state.port }
|
|
} catch (error) {
|
|
if (!(error instanceof SyntaxError) && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
throw error
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
}
|
|
}
|
|
throw new Error('Timed out waiting for launcher listener state')
|
|
}
|
|
|
|
async function waitForPortRelease(port: number): Promise<boolean> {
|
|
const { createConnection } = await import('node:net')
|
|
const deadline = Date.now() + 2_000
|
|
while (Date.now() < deadline) {
|
|
const connected = await new Promise<boolean>((resolve) => {
|
|
const socket = createConnection({ host: '127.0.0.1', port })
|
|
socket.once('connect', () => {
|
|
socket.destroy()
|
|
resolve(true)
|
|
})
|
|
socket.once('error', () => resolve(false))
|
|
})
|
|
if (!connected) {
|
|
return true
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
}
|
|
return false
|
|
}
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function waitForChildExit(
|
|
child: ReturnType<typeof spawn>,
|
|
timeoutMs: number
|
|
): Promise<readonly [number | null, NodeJS.Signals | null] | null> {
|
|
return new Promise((resolve) => {
|
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
resolve([child.exitCode, child.signalCode])
|
|
return
|
|
}
|
|
const onExit = (code: number | null, signal: NodeJS.Signals | null): void => {
|
|
clearTimeout(timeout)
|
|
resolve([code, signal])
|
|
}
|
|
const timeout = setTimeout(() => {
|
|
child.off('exit', onExit)
|
|
resolve(null)
|
|
}, timeoutMs)
|
|
child.once('exit', onExit)
|
|
})
|
|
}
|
|
|
|
async function terminateSyntheticProcess(pid: number): Promise<void> {
|
|
if (!isProcessAlive(pid)) {
|
|
return
|
|
}
|
|
try {
|
|
process.kill(pid, 'SIGTERM')
|
|
} catch {
|
|
return
|
|
}
|
|
if (await waitForProcessExit(pid, 1_000)) {
|
|
return
|
|
}
|
|
try {
|
|
process.kill(pid, 'SIGKILL')
|
|
} catch {
|
|
return
|
|
}
|
|
if (!(await waitForProcessExit(pid, 1_000))) {
|
|
throw new Error(`Failed to terminate synthetic launcher process ${pid}`)
|
|
}
|
|
}
|
|
|
|
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
if (!isProcessAlive(pid)) {
|
|
return true
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
}
|
|
return !isProcessAlive(pid)
|
|
}
|