Skip to content
Merged
6 changes: 6 additions & 0 deletions .github/workflows/node-server-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@ jobs:
env:
ORCA_BUN_ORCAD_SLOT: ${{ steps.bun-orcad.outputs.slot }}
BUN_EXECUTABLE: ${{ steps.bun-orcad.outputs.executable }}
- name: Build the host-target desktop server template
if: runner.os == 'Linux'
run: node config/scripts/build-orcad-template.mjs --targets linux-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}-glibc
- name: Verify a structured chat from the desktop server template
if: runner.os == 'Linux'
run: pnpm test:node-server --artifact src/main/orcad/orcad-packaged-structured-chat.integration.test.ts --maxWorkers=2
# Only a Windows runner compiles it; arm64 cross-compiles here.
# Before the Node 18 check below: the build script imports TypeScript, which Node 18 cannot load.
# orcad's instance lock and stop proof read a PID's creation time through it; the x64 binary this
Expand Down
7 changes: 5 additions & 2 deletions config/scripts/node-server-change-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ const ALWAYS_PREFIXES = [
'src/main/persistence/',
'src/main/sqlite/',
'src/main/orcad/',
'src/main/runtime/',
'src/main/claude/',
'src/main/daemon/pty-subprocess/',
'src/main/providers/',
'config/patches/',
Expand All @@ -61,7 +63,7 @@ const ALWAYS_PREFIXES = [
]

export function discoverNodeServerTests(root = ROOT) {
const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true })
const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true, template: true })
return globSync(
['src/**/*.test.{ts,tsx}', 'config/scripts/**/*.test.{ts,mjs}', 'tests/e2e/**/*.unit.test.ts'],
{ cwd: root }
Expand Down Expand Up @@ -150,11 +152,12 @@ export async function classifyNodeServerChanges(
if (changedFiles.length === 0) {
return { shouldRun: true, reason: 'No complete changed-file evidence' }
}
const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true })
const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true, template: true })
const forced = changedFiles.find(
(file) =>
ALWAYS_FILES.has(file) ||
(ALWAYS_PREFIXES.some((prefix) => file.startsWith(prefix)) && !isSourceUnitTest(file)) ||
/^config\/scripts\/[^/]*orcad[^/]*$/.test(file) ||
selectors.some((selector) => file.includes(selector))
)
if (forced) {
Expand Down
42 changes: 42 additions & 0 deletions config/scripts/node-server-packaged-chat-selection.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { readFileSync } from 'node:fs'
import { expect, it } from 'vitest'
import { parse } from 'yaml'
import { classifyNodeServerChanges } from './node-server-change-scope.mjs'
import { nodeServerTestPaths } from './node-server-test-paths.mjs'

it.each([
'src/main/orcad/orcad-packaged-structured-chat.integration.test.ts',
'src/main/runtime/new-launch-path.ts',
'src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs',
'config/scripts/orcad-entry-build.mjs',
'config/scripts/future-orcad-package.mjs'
])('runs the packaged structured chat when its inputs change: %s', async (file) => {
expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(true)
})

it('runs a real structured chat on Linux from the built desktop template', () => {
const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8'))
const steps = workflow.jobs.persistence.steps
const chat = steps.find((step) =>
step.run?.includes('orcad-packaged-structured-chat.integration.test.ts')
)
expect(chat.if).toBe("runner.os == 'Linux'")
expect(chat.run).toBe(
'pnpm test:node-server --artifact src/main/orcad/orcad-packaged-structured-chat.integration.test.ts --maxWorkers=2'
)
const template = steps.find((step) => step.run?.includes('build-orcad-template.mjs --targets'))
expect(template.if).toBe("runner.os == 'Linux'")
expect(template.run).toBe(
"node config/scripts/build-orcad-template.mjs --targets linux-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}-glibc"
)
expect(steps.indexOf(template)).toBeGreaterThan(
steps.findIndex((step) => step.run === 'pnpm build:orcad')
)
expect(steps.indexOf(chat)).toBeGreaterThan(steps.indexOf(template))
expect(template['continue-on-error']).toBeUndefined()
expect(chat['continue-on-error']).toBeUndefined()
// Default --artifact runs have no template, so a required-input failure would follow.
expect(nodeServerTestPaths({ artifact: true, crossRuntime: true })).not.toContain(
'src/main/orcad/orcad-packaged-structured-chat.integration.test.ts'
)
})
14 changes: 12 additions & 2 deletions config/scripts/node-server-test-paths.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@ export const CROSS_RUNTIME_TEST_PATHS = [
'src/main/persistence/profile-state/profile-state-cross-runtime.integration.test.ts'
]

export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = {}) {
// Need the built desktop server template; CI runs them by path after build-orcad-template.mjs.
export const TEMPLATE_TEST_PATHS = [
'src/main/orcad/orcad-packaged-structured-chat.integration.test.ts'
]

export function nodeServerTestPaths({
artifact = false,
crossRuntime = false,
template = false
} = {}) {
return [
'src/main/persistence/profile-state',
'src/main/persistence/loading-store/profile-state',
Expand Down Expand Up @@ -38,6 +47,7 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } =
'config/scripts/zip-extractor-command.test.mjs'
]
: []),
...(crossRuntime ? CROSS_RUNTIME_TEST_PATHS : [])
...(crossRuntime ? CROSS_RUNTIME_TEST_PATHS : []),
...(template ? TEMPLATE_TEST_PATHS : [])
]
}
26 changes: 24 additions & 2 deletions src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,35 @@
// { steps: Step[], controlResponses?: { [subtype]: <response> },
// controlResponseLeadingFrames?: { [subtype]: <frame>[] } } where a Step is
// { emit: <frame> } | { awaitUserMessage: true } | { stderr: <text> } |
// { awaitControlResponse: <request_id> } | { delayMs: <n> } | { exit: <code> }
// { awaitControlResponse: <request_id> } | { awaitFile: { path, timeoutMs } } |
// { delayMs: <n> } | { exit: <code> }
// ORCA_SDK_CONTRACT_REPORT_PATH — where argv/env observations are written
// ORCA_SDK_CONTRACT_IGNORE_SIGTERM — trap SIGTERM/SIGINT and outlive stdin close
// ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS — record control requests but never answer
// ORCA_SDK_CONTRACT_DESCENDANT — fork an idle grandchild and report its pid
import { spawn } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { createInterface } from 'node:readline'
import { setTimeout as delay } from 'node:timers/promises'

if (process.argv.includes('--version')) {
process.stdout.write('2.1.258 (Claude Code)\n')
process.exit(0)
}

const scenarioPath = process.env.ORCA_SDK_CONTRACT_SCENARIO_PATH
const reportPath = process.env.ORCA_SDK_CONTRACT_REPORT_PATH

const report = {
argv: process.argv.slice(1),
execPath: process.execPath,
cwd: process.cwd(),
env: Object.fromEntries(
Object.entries(process.env).filter(
([key]) =>
key.startsWith('ORCA_') || ['HOME', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR'].includes(key)
)
),
controlRequests: [],
controlResponses: [],
userMessages: [],
Expand Down Expand Up @@ -136,6 +150,14 @@ for (const step of scenario.steps) {
await waitFor('user')
} else if (step.awaitControlResponse !== undefined) {
await waitFor('control_response', step.awaitControlResponse)
} else if (step.awaitFile) {
const deadline = Date.now() + step.awaitFile.timeoutMs
while (!existsSync(step.awaitFile.path)) {
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for test acknowledgement: ${step.awaitFile.path}`)
}
await delay(10)
}
} else if (step.delayMs) {
await new Promise((resolve) => setTimeout(resolve, step.delayMs))
} else if (step.exit !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,13 @@ describe('SubagentTracker', () => {
groupOf: (id) => (id.startsWith('old-') ? `old-turn-${id.slice(4)}` : null),
claimGroup: (groupId) => ({
entries: [
{ id: `old-${groupId.slice(9)}`, label: 'Old', state: 'completed', startedAt: 1, settledAt: 2 }
{
id: `old-${groupId.slice(9)}`,
label: 'Old',
state: 'completed',
startedAt: 1,
settledAt: 2
}
],
placement: groupId
})
Expand Down
7 changes: 4 additions & 3 deletions src/main/orcad/orcad-node-slot-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ export async function writeNodeSlotFixture(
return { slotDir, runtime }
}

/** Hard-links the built `out/orcad` slot under `<root>/orcad-test` with its pinned runtime beside it. */
/** Installs a built or materialized slot under `<root>/orcad-test` with its pinned runtime beside it. */
export function installPackagedOrcadSlotForTests(
root: string,
pinnedNode: string
pinnedNode: string,
sourceDir = resolve('out/orcad')
): { slotDir: string; runtime: string } {
const linkOrCopy = (from: string, to: string): void => {
try {
Expand All @@ -96,7 +97,7 @@ export function installPackagedOrcadSlotForTests(
}
}
const slotDir = join(root, 'orcad-test')
installTree(resolve('out/orcad'), slotDir)
installTree(sourceDir, slotDir)
const target = hostServerTarget()
const runtime = resolve(
slotDir,
Expand Down
Loading
Loading