mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
* feat(orchestration): inject the Orca session id into structured children and let the CLI act as it Every structured session's child (native Claude, native Codex, and the terminal view) carries ORCA_AGENT_SESSION_ID and reaches the Orca CLI. The CLI sends the id in the orchestration envelope; when present it is the caller, and a caller flag naming anyone else is refused before any request. The id is stripped from inherited PTY env and from the SSH host-CLI passthrough, and crosses into WSL so the host can refuse the cross-host claim. * test(orchestration): pin session id injection for native Claude, native Codex, the terminal view, WSL, PTY inheritance and SSH * test(orchestration): pin one caller precedence rule across every CLI verb that names its caller Adds the per-verb table (flagless acts as the session; a conflicting --from or --terminal is refused before any request; the session's own spellings are accepted), the enumerated guess population with its positive control, the structured worker's own handle, the identity-less refusal for an older child, the unchanged terminal agent, and the envelope. dispatch-show's --from only fills preview text, so it passes through unfenced and a session's flagless preview names the address the real dispatch writes. * refactor(orchestration): keep the identity-less marker reader to the marker; the id is checked first * test(orchestration): pin that a host refusal of the session surfaces verbatim from the CLI * fix(orchestration): keep the identity-less marker beside the id for CLIs that predate it A CLI older than the id, reached through a global install when a shell rc resets PATH, would otherwise guess a sibling's terminal in a chat that no longer carries the marker. It refuses on the marker instead; a current CLI checks the id first, so the marker never makes a session with an id identity-less. * fix(orchestration): refuse a conflicting --from on gate-list and task-list scoped by --run A --run listing needs no caller, so both handlers skipped the resolver and a --from naming another actor was dropped silently under a session. The conflict check now runs on that branch too; terminal callers are unchanged. * fix(orchestration): name this app's CLI by absolute path for a structured session's login shells A provider can run each command in a login shell: Codex runs zsh -lc, and the profile rebuilds PATH, putting a global install (possibly an older Orca) ahead of the directory Orca prepended. ORCA_CLI_COMMAND, which an agent resolves the CLI from first, is now the absolute launcher in that directory (the native launcher on Windows), so no shell's startup files can swap it. The PATH prepend stays for shells that read no profile. Found by the live coordinator run of the next PR. * test(orchestration): pin a structured worker's CLI command as this app's absolute launcher * test(orchestration): run the zsh login-shell arm in the real-shell lane that installs zsh The ordinary Linux unit lane has no /bin/zsh, so the zsh arm failed there with ENOENT. It moves to a live-shell file registered in the shell-contracts lane; the bash arm keeps running in every lane. The lane guard's detector now also sees a zsh spawned through the ProcessSpec program field, which is how this test escaped it. * fix(orchestration): omit a structured child's CLI command when no launcher resolves, and pin its instance A bare `orca` fallback named GNOME's screen reader on packaged Linux, and an inherited value named another app's CLI. The builder now deletes any inherited value, sets the absolute launcher only when one resolved, and pins ORCA_USER_DATA_PATH so a current CLI dials the instance that minted the id. Renames the marker reader to hasStructuredSessionMarker and records why the terminal view carries the id without the marker. * fix(terminal): name this app's CLI launcher by absolute path in every local terminal ORCA_CLI_COMMAND meant three things by lane: an absolute launcher for a structured session, a bare name for WSL, and nothing for any other terminal, so a structured session's terminal view lost it. Local terminals now get the same absolute launcher the structured lane gets; WSL keeps its guest command name, and a terminal whose launcher does not resolve still gets none. * feat(cli): hand a command to the session's own CLI when another Orca CLI was invoked A login shell can reorder PATH behind a global install, and an agent or its helper script can run bare `orca`, so the binary that answered depended on the agent following instructions. Orca's packaged launchers and bare-orca shims now export ORCA_CLI_SELF (outermost wins). At the CLI entry, when it names a different launcher than ORCA_CLI_COMMAND, the command re-runs once through the named launcher with ORCA_CLI_REEXEC=1 and exits with its status; both variables are consumed so no child inherits them. Dev launchers export no self on purpose, WSL and SSH names never qualify, and a launcher that cannot start leaves the command to run here. The Windows launcher no longer rewrites ORCA_CLI_COMMAND; the legacy ask protocol normalizes its resume command itself. * refactor(orchestration): declare which flag names the caller on each spec and refuse at the CLI entry Each handler hand-classified its --from/--terminal as the caller or a target, and the refusal of a conflicting caller flag ran inside the caller resolver plus two standalone calls for --run listings, so a new verb that read its flag raw would pass a sibling's handle to a pre-session host. Specs now declare identityFlagRoles, the CLI entry refuses a conflicting caller flag once from the spec, the resolver only applies the id-wins rule, and a test fails any orchestration verb that accepts --from or --terminal without classifying it. * perf(cli): keep the session caller check off the actor codec's module graph The check runs at the CLI entry for every command, and the actor codec pulls zod through the session record. Compare the session's own spellings as plain strings instead. * refactor(cli): spell a session's address from the one prefix constant, off the codec's module graph The Orca session address prefix moves to a leaf module with no imports, re-exported by the address codec, so the CLI entry check derives `session:<id>` from that constant instead of re-typing it and still stays off the codec's zod graph. Prose and test names say caller or Orca session id, not actor. * refactor(orchestration): drop the session id's terminal-view spawn now that the handoff is gone The terminal handoff was removed, so no terminal is ever a structured session: - delete the terminal-view identity env and its WSL passthrough, and their tests; - strip the session caller keys from every terminal's env unconditionally; - the CLI's own-address spelling moves beside the injected id in src/shared, with a test pinning it to the address the host's party resolver gives that session. * fix(terminal): run the Codex launch preflight through the CLI the terminal names Packaged Linux names the userData shim in ORCA_CLI_COMMAND, while the preflight ran the bundled launcher behind it. The CLI saw a different launcher and handed the preflight off to the shim, booting Electron twice before every codex launch. * revert(terminal): keep terminals on main's ORCA_CLI_COMMAND and Codex preflight Only a structured session needs an absolute ORCA_CLI_COMMAND; local terminals go back to naming none (WSL keeps its guest command), and the Codex launch preflight goes back to the bundled launcher. The CLI handoff is scoped to sessions, so a terminal's preflight can no longer be handed off and start Electron twice. This reverts commitd2cefb6c03and commitdd2853a5a9. * fix(cli): hand off to the session's CLI only inside a structured session The handoff ran whenever an Orca launcher's ORCA_CLI_SELF differed from an absolute ORCA_CLI_COMMAND, so any process with both - a terminal, a script - ran another install's CLI instead of the one invoked: a beta's --version lied, and an AppImage command from a terminal that outlived its Orca failed. It now requires the injected session id, the identity it exists to deliver. The launcher variables are still consumed in every process. * fix(cli): name the packaged Windows command after the handoff decision The launcher stopped writing orca/orca-ide over ORCA_CLI_COMMAND so the handoff could see a session's absolute launcher, which also changed what every Windows terminal's CLI read. The CLI entry now applies the launcher's rule itself once the handoff is decided, so terminals and the legacy ask resume command see exactly what they saw before, and the resume-command reader goes back to its original form. * refactor(cli): decide the session handoff from the CLI's own entry, not a launcher export Every packaged launcher, shim and dispatcher exported ORCA_CLI_SELF so the CLI could tell which launcher ran it, and compared that with the session's ORCA_CLI_COMMAND. Two launchers of the same app are different files, so a session that reached its own app through a global orca-ide on Linux still handed off and started Electron twice, and the export rode artifacts every terminal uses. A structured session now also names the JS entry its launcher runs (ORCA_SESSION_CLI_ENTRY), and the CLI compares its own argv entry with it: any launcher of the same app stays, another install hands off. The launcher scripts, Linux shim and dispatcher go back to main; the Windows launcher keeps only leaving ORCA_CLI_COMMAND for the CLI to name after the handoff decision. * refactor(cli): drop the session CLI handoff; the pinned instance and injected id already bind any current CLI Every current Orca CLI dials the instance ORCA_USER_DATA_PATH names and sends the injected session id in the orchestration envelope, so a bare `orca` that reaches another install's current CLI already acts as the session. An older CLI has no handoff code and refuses on the marker. The handoff only lined up versions between two current CLIs, and comparing two separately derived paths kept misfiring (an AppImage's mount against its registered extraction started the CLI twice on every call). Removes the re-exec, ORCA_SESSION_CLI_ENTRY and ORCA_CLI_REEXEC, and the CLI-side Windows command naming; the packaged Windows launcher rewrites ORCA_CLI_COMMAND again, as on main, inside its own process only. resolveHostCliEntryPath goes back to the SSH passthrough. * test(orchestration): say why the registered worker case pins the handle, now that every session's env is populated
553 lines
28 KiB
JavaScript
553 lines
28 KiB
JavaScript
import { existsSync, globSync, readFileSync } from 'node:fs'
|
|
import { parse } from 'yaml'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { UNIT_EXCLUDE } from './ci-unit-files.mjs'
|
|
import { mobileWebCheckArgs } from './run-mobile-web-app-checks.mjs'
|
|
import { MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV } from './mobile-web-app-bundle-dependencies.mjs'
|
|
|
|
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
|
const prTestLocWorkflow = parse(readFileSync('.github/workflows/pr-test-loc.yml', 'utf8'))
|
|
const trackingWorkflow = parse(readFileSync('.github/workflows/track-community-prs.yaml', 'utf8'))
|
|
const releasePolicyWorkflow = parse(readFileSync('.github/workflows/release-policy.yml', 'utf8'))
|
|
const issueLabelWorkflow = parse(readFileSync('.github/workflows/issue-os-labeler.yaml', 'utf8'))
|
|
const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
|
|
const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8'))
|
|
const dependencyAction = parse(
|
|
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
|
|
)
|
|
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
|
const pnpmWorkspace = parse(readFileSync('pnpm-workspace.yaml', 'utf8'))
|
|
const shellContractFiles = [
|
|
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
|
|
'src/main/daemon/shell-ready.test.ts',
|
|
'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts',
|
|
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
|
|
'src/main/pty/omp-shell-wrapper-alias-safety.test.ts',
|
|
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
|
|
'src/main/shell-startup-feature-channel.test.ts',
|
|
'src/main/zsh-scoped-histfile.live-shell.test.ts',
|
|
'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts',
|
|
'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts',
|
|
'src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts',
|
|
'src/shared/posix-command-path-lookup.test.ts'
|
|
]
|
|
const patchedNodePtyContractFiles = [
|
|
'src/main/daemon/node-pty-fd-leak.test.ts',
|
|
'src/shared/fish-query-reply-child-stdin.node-pty.test.ts'
|
|
]
|
|
const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles]
|
|
const testFilePatterns = [
|
|
'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
|
'src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
|
'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
|
'tests/tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}'
|
|
]
|
|
// Why the harness import counts: the zsh startup hook runs from a `precmd`, so
|
|
// its tests drive a real zsh through a PTY in zsh-startup-hook-pty-harness
|
|
// rather than calling spawnSync('zsh') themselves. Without this branch the rule
|
|
// silently stops noticing the very tests that need the lane's zsh install.
|
|
const realZshUsage =
|
|
/(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|program:\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath|from '[^']*zsh-startup-hook-pty-harness'/
|
|
|
|
describe('PR workflow parallelism', () => {
|
|
it('keeps lightweight orchestration jobs on the free slim runner', () => {
|
|
expect(workflow.jobs.code_paths['runs-on']).toBe('ubuntu-slim')
|
|
expect(workflow.jobs.typecheck['runs-on']).toBe('ubuntu-24.04-arm')
|
|
expect(workflow.jobs.verify['runs-on']).toBe('ubuntu-slim')
|
|
expect(prTestLocWorkflow.jobs.loc['runs-on']).toBe('ubuntu-slim')
|
|
expect(trackingWorkflow.jobs['track-community-pr']['runs-on']).toBe('ubuntu-slim')
|
|
expect(releasePolicyWorkflow.jobs.enforce['runs-on']).toBe('ubuntu-slim')
|
|
expect(issueLabelWorkflow.jobs['apply-os-label']['runs-on']).toBe('ubuntu-slim')
|
|
})
|
|
|
|
it('cancels superseded runs for the same pull request', () => {
|
|
expect(workflow.concurrency.group).toBe('pr-checks-${{ github.event.pull_request.number }}')
|
|
expect(workflow.concurrency['cancel-in-progress']).toBe(true)
|
|
expect(workflow.jobs.test.if).toContain('!cancelled()')
|
|
expect(workflow.jobs.test.if).not.toContain('always()')
|
|
expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}')
|
|
})
|
|
|
|
it('grants the PR workflow read-only repository access', () => {
|
|
expect(workflow.permissions).toEqual({ contents: 'read' })
|
|
})
|
|
|
|
it('runs all eight shards on ARM for PRs and both Node versions on x86 daily', () => {
|
|
const sharedTest = unitTestWorkflow.jobs.test
|
|
const testStep = sharedTest.steps.find((step) => step.name === 'Test shard')
|
|
const installStep = sharedTest.steps.find(
|
|
(step) => step.uses === './.github/actions/install-node-dependencies'
|
|
)
|
|
const primerInstall = workflow.jobs.test_native_cache.steps.find(
|
|
(step) => step.uses === './.github/actions/install-node-dependencies'
|
|
)
|
|
const nodeNextPrimerInstall = nodeNextWorkflow.jobs.test_native_cache.steps.find(
|
|
(step) => step.uses === './.github/actions/install-node-dependencies'
|
|
)
|
|
|
|
expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
|
|
expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
|
|
expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
|
|
expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['24', '26'])
|
|
expect(workflow.jobs.test.with.runner).toBe('ubuntu-24.04-arm')
|
|
expect(workflow.jobs.test_native_cache['runs-on']).toBe('ubuntu-24.04-arm')
|
|
expect(sharedTest['runs-on']).toBe('${{ inputs.runner }}')
|
|
expect(unitTestWorkflow.on.workflow_call.inputs.runner.default).toBe('ubuntu-latest')
|
|
expect(nodeNextWorkflow.jobs.test.with.runner).toBeUndefined()
|
|
expect(nodeNextWorkflow.jobs.test_native_cache['runs-on']).toBe('ubuntu-latest')
|
|
expect(nodeNextWorkflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26'])
|
|
const relay = unitTestWorkflow.jobs.relay_integration
|
|
expect(relay.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
|
|
expect(relay['runs-on']).toBe('ubuntu-latest')
|
|
expect(
|
|
relay.steps.find((step) => step.uses === './.github/actions/install-node-dependencies').with[
|
|
'node-version'
|
|
]
|
|
).toBe('${{ matrix.node }}')
|
|
expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
|
|
expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
|
|
expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
|
|
expect(sharedTest.strategy.matrix.shard).toBe('${{ fromJSON(needs.plan.outputs.shards) }}')
|
|
expect(sharedTest.needs).toBe('plan')
|
|
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
|
|
expect(installStep.with['cache-electron-package']).toBe('true')
|
|
expect(testStep.run).toContain('--shard=${{ matrix.shard.index }}/${{ matrix.shard.count }}')
|
|
for (const testFile of nativeShellContractFiles) {
|
|
expect(UNIT_EXCLUDE).toContain(testFile)
|
|
}
|
|
expect(primerInstall.with['native-runtime']).toBe('node')
|
|
expect(primerInstall.with['node-version']).toBe('24')
|
|
expect(workflow.jobs.test.needs).toContain('test_native_cache')
|
|
expect(nodeNextPrimerInstall.with['native-runtime']).toBe('node')
|
|
expect(nodeNextPrimerInstall.with['node-version']).toBe('${{ matrix.node }}')
|
|
expect(nodeNextWorkflow.jobs.test.needs).toEqual(['test_native_cache'])
|
|
})
|
|
|
|
it('runs real-shell coverage once outside the general shards', () => {
|
|
const shellStep = workflow.jobs.shell_contracts.steps.find(
|
|
(step) => step.name === 'Test real shell contracts'
|
|
)
|
|
const shellInstall = workflow.jobs.shell_contracts.steps.find(
|
|
(step) => step.uses === './.github/actions/install-node-dependencies'
|
|
)
|
|
// Why parsed rather than substring-matched: the step name changes as shells are
|
|
// added, and `includes('fish')` would also match a comment or a longer package.
|
|
const aptPackages = (step) =>
|
|
(step.run?.match(/apt-get install[^\n]*/)?.[0] ?? '')
|
|
.split(/\s+/)
|
|
.filter((token) => !['apt-get', 'install', 'sudo', ''].includes(token))
|
|
.filter((token) => !token.startsWith('-'))
|
|
const requiredShells = ['zsh', 'fish']
|
|
const jobsInstallingShells = Object.entries(workflow.jobs)
|
|
.filter(([, job]) =>
|
|
(job.steps ?? []).some((step) =>
|
|
aptPackages(step).some((packageName) => requiredShells.includes(packageName))
|
|
)
|
|
)
|
|
.map(([name]) => name)
|
|
|
|
expect(shellStep).toBeDefined()
|
|
expect(shellInstall).toBeDefined()
|
|
expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1')
|
|
// Why the whole workflow, not just the general shards: any other lane installing
|
|
// these shells would silently start running the real-shell tests twice.
|
|
expect(jobsInstallingShells).toEqual(['shell_contracts'])
|
|
// Why each shell is asserted: the live tests skip themselves when the binary is
|
|
// missing, so a dropped package silently empties this lane instead of failing it.
|
|
const shellPackages = workflow.jobs.shell_contracts.steps.flatMap(aptPackages)
|
|
for (const shell of requiredShells) {
|
|
expect(shellPackages).toContain(shell)
|
|
}
|
|
expect(shellInstall.with['native-runtime']).toBe('node')
|
|
for (const testFile of nativeShellContractFiles) {
|
|
expect(shellStep.run).toContain(testFile)
|
|
}
|
|
})
|
|
|
|
it('refreshes the apt index once while adding the fish PPA', () => {
|
|
const installStep = workflow.jobs.shell_contracts.steps.find(
|
|
(step) => step.name === 'Install zsh and fish'
|
|
)
|
|
// Comment lines mention both commands by name, so count the executed ones only.
|
|
const commands = installStep.run
|
|
.split('\n')
|
|
.filter((line) => !line.trim().startsWith('#'))
|
|
.join('\n')
|
|
const updates = commands.match(/apt-get update/g) ?? []
|
|
// Anchored to the start of a line so the retry message that names the command in
|
|
// prose is not mistaken for an invocation of it.
|
|
const addRepoCalls = commands
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter((line) => /^(sudo\s+)?add-apt-repository\b/.test(line))
|
|
|
|
// add-apt-repository refreshes every configured repo unless told not to, so an
|
|
// update on each side of it made this step pay for three full passes.
|
|
expect(updates).toHaveLength(1)
|
|
expect(addRepoCalls.length).toBeGreaterThan(0)
|
|
for (const call of addRepoCalls) {
|
|
expect(call.split(/\s+/)).toContain('-n')
|
|
}
|
|
// The one remaining update has to come after the PPA is on the list, or the fish
|
|
// index it exists to fetch would not be there yet.
|
|
expect(commands.lastIndexOf('add-apt-repository')).toBeLessThan(
|
|
commands.indexOf('apt-get update')
|
|
)
|
|
})
|
|
|
|
it('bounds the shell lane so a stalled apt mirror cannot hold the run open', () => {
|
|
const job = workflow.jobs.shell_contracts
|
|
// A passing run of this job takes ~4.5 minutes, almost all of it package download.
|
|
// Without a job bound a stalled mirror runs to GitHub's 6h default, and because this
|
|
// is a required check it holds the whole run open and blocks `gh run rerun --failed`.
|
|
expect(job['timeout-minutes']).toBeGreaterThan(0)
|
|
expect(job['timeout-minutes']).toBeLessThanOrEqual(30)
|
|
|
|
const installStep = job.steps.find((step) => step.name === 'Install zsh and fish')
|
|
// apt applies no wall-clock bound to a stalled mirror on its own. These turn an
|
|
// unbounded hang into a bounded, retried, legible failure.
|
|
expect(installStep.run).toMatch(/Acquire::http::Timeout/)
|
|
expect(installStep.run).toMatch(/Acquire::https::Timeout/)
|
|
expect(installStep.run).toMatch(/Acquire::Retries/)
|
|
// Retries multiply: a first attempt at 30s x 3 retries across every index file turned
|
|
// a dead mirror into a ~15 minute stall. One attempt, then move on.
|
|
expect(installStep.run).toMatch(/Acquire::Retries "1"/)
|
|
// Acquire timeouts are per-connection, so they cannot bound the command as a whole.
|
|
// Only a wall-clock bound can, and both apt invocations need one.
|
|
expect(installStep.run).toMatch(/timeout \d+ sudo apt-get update/)
|
|
expect(installStep.run).toMatch(/timeout \d+ sudo apt-get install/)
|
|
})
|
|
|
|
it('keeps every real-zsh test in the dedicated shell lane', () => {
|
|
const discoveredFiles = globSync(testFilePatterns)
|
|
// Why this file is excluded: it carries the detector pattern as a literal
|
|
// and would otherwise match itself.
|
|
.filter((testFile) => testFile !== 'config/scripts/pr-workflow-parallelism.test.mjs')
|
|
// TypeScript builds can leave an ignored JavaScript companion beside a source
|
|
// test. Inspect the source file once so generated output cannot duplicate it.
|
|
.filter(
|
|
(testFile) =>
|
|
!testFile.endsWith('.js') ||
|
|
!['.ts', '.tsx', '.mjs', '.cjs'].some((extension) =>
|
|
existsSync(testFile.replace(/\.js$/, extension))
|
|
)
|
|
)
|
|
.filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8')))
|
|
.sort()
|
|
|
|
expect(discoveredFiles).toEqual([...shellContractFiles].sort())
|
|
})
|
|
|
|
it('overlaps bundles with independent output directories', () => {
|
|
const buildStep = workflow.jobs.package.steps.find(
|
|
(step) => step.name === 'Build package inputs'
|
|
)
|
|
|
|
expect(buildStep.run).toContain('scripts=(build:relay build:electron-vite:parallel)')
|
|
expect(buildStep.run).toContain('pnpm run "$script" &')
|
|
expect(
|
|
workflow.jobs.package.steps.find(
|
|
(step) => step.name === 'Project web client from renderer build'
|
|
).run
|
|
).toBe('pnpm run build:web-from-renderer')
|
|
expect(packageJson.scripts['build:desktop']).toContain('pnpm run build:web-from-renderer')
|
|
expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer')
|
|
})
|
|
|
|
it('smokes managed-hook companions under their supported Node 18 runtime', () => {
|
|
const steps = workflow.jobs.managed_hook_node18.steps
|
|
const installIndex = steps.findIndex(
|
|
(step) => step.uses === './.github/actions/install-node-dependencies'
|
|
)
|
|
const buildIndex = steps.findIndex((step) => step.run === 'pnpm run build:relay')
|
|
const node18Index = steps.findIndex(
|
|
(step) => step.uses === 'actions/setup-node@v6' && step.with['node-version'] === '18'
|
|
)
|
|
const smokeIndex = steps.findIndex(
|
|
(step) => step.run === 'node config/scripts/smoke-managed-hook-runtime-node18.mjs'
|
|
)
|
|
|
|
expect(installIndex).toBeLessThan(buildIndex)
|
|
expect(buildIndex).toBeLessThan(node18Index)
|
|
expect(node18Index).toBeLessThan(smokeIndex)
|
|
})
|
|
|
|
it('restores the pnpm store before dependency installation', () => {
|
|
const steps = dependencyAction.runs.steps
|
|
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
|
|
const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js')
|
|
const requestedNodeIndex = steps.findIndex((step) => step.name === 'Setup requested Node.js')
|
|
|
|
expect(pnpmIndex).toBeLessThan(nodeIndex)
|
|
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
|
|
const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
|
|
expect(packageManagerVersion).toBe('12.0.0')
|
|
expect(steps[pnpmIndex].uses).toBe('pnpm/setup@v2')
|
|
expect(steps[pnpmIndex].with.version).toBeUndefined()
|
|
expect(steps[pnpmIndex].with.install).toBe(false)
|
|
const saveOutsidePrs = "${{ github.event_name != 'pull_request' && 'pnpm' || '' }}"
|
|
expect(steps[nodeIndex].with.cache).toBe(saveOutsidePrs)
|
|
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
|
|
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
|
|
expect(steps[requestedNodeIndex].with['node-version']).toBe('${{ inputs.node-version }}')
|
|
expect(steps[requestedNodeIndex].with.cache).toBe(saveOutsidePrs)
|
|
const restoreIndex = steps.findIndex(
|
|
(step) => step.name === 'Restore pnpm download store without saving'
|
|
)
|
|
expect(restoreIndex).toBeGreaterThan(requestedNodeIndex)
|
|
expect(restoreIndex).toBeLessThan(
|
|
steps.findIndex((step) => step.name === 'Install dependencies')
|
|
)
|
|
expect(steps[restoreIndex].uses).toBe('actions/cache/restore@v5')
|
|
})
|
|
|
|
it('uses the repository package-manager version for every direct pnpm setup', () => {
|
|
const directSetups = globSync('.github/workflows/*.yml').flatMap((workflowPath) => {
|
|
const parsed = parse(readFileSync(workflowPath, 'utf8'))
|
|
return Object.values(parsed.jobs ?? {}).flatMap((job) =>
|
|
(job.steps ?? [])
|
|
.filter((step) => step.uses === 'pnpm/setup@v2')
|
|
.map((step) => ({ workflowPath, step }))
|
|
)
|
|
})
|
|
|
|
expect(directSetups.length).toBeGreaterThan(0)
|
|
for (const { workflowPath, step } of directSetups) {
|
|
expect(step.with?.version, workflowPath).toBeUndefined()
|
|
expect(step.with?.install, workflowPath).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('restores Electron downloads before preparing the package runtime', () => {
|
|
const steps = workflow.jobs.package.steps
|
|
const cacheIndex = steps.findIndex((step) => step.name === 'Cache electron-builder downloads')
|
|
const installIndex = steps.findIndex(
|
|
(step) => step.uses === './.github/actions/install-node-dependencies'
|
|
)
|
|
|
|
expect(cacheIndex).toBeGreaterThanOrEqual(0)
|
|
expect(installIndex).toBeGreaterThanOrEqual(0)
|
|
expect(cacheIndex).toBeLessThan(installIndex)
|
|
})
|
|
|
|
it('prepares each native runtime before its consumers start', () => {
|
|
const installFor = (jobName) =>
|
|
workflow.jobs[jobName].steps.find(
|
|
(step) => step.uses === './.github/actions/install-node-dependencies'
|
|
)
|
|
const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find(
|
|
(step) => step.uses === './.github/actions/install-node-dependencies'
|
|
)
|
|
|
|
for (const jobName of ['typecheck', 'git_compatibility']) {
|
|
expect(installFor(jobName).with, jobName).toBeUndefined()
|
|
}
|
|
expect(installFor('xterm_patch_sync')).toBeUndefined()
|
|
expect(installFor('static_analysis').with['native-runtime']).toBe('node')
|
|
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
|
|
expect(sharedTestInstall.with['native-runtime']).toBe('node')
|
|
expect(installFor('package').with['native-runtime']).toBe('electron')
|
|
expect(installFor('package_windows').with['native-runtime']).toBe('node')
|
|
expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
|
|
expect(
|
|
workflow.jobs.package_windows.steps.find(
|
|
(step) => step.name === 'Save compiled Node native modules'
|
|
).if
|
|
).toBe("steps.deps.outputs.native-cache-hit != 'true'")
|
|
|
|
expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
|
|
expect(
|
|
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
|
|
).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'")
|
|
const dependencyInstall = dependencyAction.runs.steps.find(
|
|
(step) => step.name === 'Install dependencies'
|
|
)
|
|
// Why frozen: re-resolving the graph costs a minute per job and the `git diff`
|
|
// guard below already fails the run when the lockfile is stale, so the slow
|
|
// resolution can never legitimately change anything.
|
|
expect(dependencyInstall.run).toContain('--frozen-lockfile')
|
|
expect(dependencyInstall.run).not.toContain('--no-frozen-lockfile')
|
|
expect(dependencyInstall.run).toContain(
|
|
'git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml'
|
|
)
|
|
expect(dependencyInstall.run).toContain('--ignore-scripts')
|
|
expect(dependencyInstall.run).not.toContain('--os=')
|
|
expect(dependencyInstall.run).not.toContain('--cpu=')
|
|
expect(pnpmWorkspace.supportedArchitectures.os).toEqual(['current'])
|
|
expect(pnpmWorkspace.supportedArchitectures.cpu).toEqual(['current'])
|
|
const prepareRuntime = dependencyAction.runs.steps.find(
|
|
(step) => step.name === 'Prepare native runtime'
|
|
)
|
|
expect(prepareRuntime.if).toBe("inputs.native-runtime != 'none'")
|
|
expect(prepareRuntime.run).toContain('ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"')
|
|
})
|
|
|
|
it('reuses native preparation after the dependency action gate', () => {
|
|
const buildStep = workflow.jobs.package.steps.find(
|
|
(step) => step.name === 'Build package inputs'
|
|
)
|
|
const packageStep = workflow.jobs.package.steps.find(
|
|
(step) => step.name === 'Package unpacked app'
|
|
)
|
|
|
|
expect(buildStep.run).not.toContain('ensure:electron-runtime')
|
|
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
|
|
})
|
|
|
|
it('restores compiled native modules after the install that strips them', () => {
|
|
const steps = dependencyAction.runs.steps
|
|
const installIndex = steps.findIndex((step) => step.name === 'Install dependencies')
|
|
const cacheIndex = steps.findIndex((step) => step.name === 'Restore compiled native modules')
|
|
const prepareIndex = steps.findIndex((step) => step.name === 'Prepare native runtime')
|
|
|
|
// `--ignore-scripts` leaves no build/, so a restore before the install would be
|
|
// overwritten and one after the rebuild would never save a hit.
|
|
expect(installIndex).toBeLessThan(cacheIndex)
|
|
expect(cacheIndex).toBeLessThan(prepareIndex)
|
|
expect(steps[cacheIndex].if).toBe(
|
|
"inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'"
|
|
)
|
|
const restoreOnly = steps.find(
|
|
(step) => step.name === 'Restore compiled native modules without saving'
|
|
)
|
|
expect(restoreOnly.if).toBe(
|
|
"inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'"
|
|
)
|
|
expect(restoreOnly.uses).toBe('actions/cache/restore@v5')
|
|
// Native artifacts are ABI-bound: a key missing either dimension serves a build
|
|
// that cannot load, and ensure-native-runtime would recompile it anyway.
|
|
for (const cacheStep of [steps[cacheIndex], restoreOnly]) {
|
|
expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}')
|
|
expect(cacheStep.with.key).toContain('${{ runner.os }}')
|
|
expect(cacheStep.with.key).toContain('${{ runner.arch }}')
|
|
expect(cacheStep.with.key).toContain(
|
|
'steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version'
|
|
)
|
|
expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope')
|
|
expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch')
|
|
expect(cacheStep.with.key).toContain(
|
|
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
|
|
)
|
|
expect(cacheStep.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
|
|
expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
|
|
expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
|
|
expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
|
|
expect(cacheStep.with.path).toContain('native/windows-registry/build')
|
|
expect(cacheStep.with.path).toContain('@vscode+windows-process-tre*')
|
|
expect(cacheStep.with['restore-keys']).toBeUndefined()
|
|
}
|
|
expect(steps[cacheIndex].id).toBe('native-cache-restore')
|
|
expect(restoreOnly.id).toBe('native-cache-restore-only')
|
|
const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
|
|
expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
|
|
expect(cacheScope.run).toContain('/etc/os-release')
|
|
expect(dependencyAction.outputs['native-cache-scope'].value).toBe(
|
|
'${{ steps.native-cache-scope.outputs.scope }}'
|
|
)
|
|
expect(dependencyAction.outputs['native-cache-hit'].value).toContain(
|
|
'steps.native-cache-restore.outputs.cache-hit'
|
|
)
|
|
expect(dependencyAction.outputs['native-cache-hit'].value).toContain(
|
|
'steps.native-cache-restore-only.outputs.cache-hit'
|
|
)
|
|
const electronCache = steps.find((step) => step.name === 'Cache Electron package archive')
|
|
const electronCacheResolution = steps.find(
|
|
(step) => step.name === 'Resolve Electron package cache'
|
|
)
|
|
expect(electronCacheResolution.if).toBe(
|
|
"inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true'"
|
|
)
|
|
expect(electronCache.if).toBe(
|
|
"(github.event_name != 'pull_request' || runner.os != 'Linux') && steps.electron-package-cache.outputs.version != ''"
|
|
)
|
|
expect(electronCache.uses).toBe('actions/cache@v5')
|
|
expect(electronCache.with.key).toContain('steps.electron-package-cache.outputs.version')
|
|
expect(dependencyAction.inputs['cache-electron-package'].default).toBe('false')
|
|
})
|
|
|
|
it('reuses TypeScript incremental state across typecheck runs', () => {
|
|
const steps = workflow.jobs.typecheck.steps
|
|
const cacheIndex = steps.findIndex((step) => step.name === 'Cache TypeScript incremental state')
|
|
const checkIndex = steps.findIndex((step) => step.run === 'pnpm run typecheck')
|
|
|
|
expect(cacheIndex).toBeGreaterThanOrEqual(0)
|
|
expect(cacheIndex).toBeLessThan(checkIndex)
|
|
expect(steps[cacheIndex].with.path).toBe('config/*.tsbuildinfo')
|
|
// Why restore-keys matter here: the base SHA key is shared by every commit in a PR,
|
|
// but actions/cache keeps the first successful graph until the base or config changes.
|
|
expect(steps[cacheIndex].with['restore-keys']).toBeTruthy()
|
|
// The buildinfo is only reusable while the compiler options that produced it hold.
|
|
expect(steps[cacheIndex].with.key).toContain(
|
|
"hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json')"
|
|
)
|
|
expect(steps[cacheIndex].with.key).toContain('github.event.pull_request.base.sha')
|
|
expect(steps[cacheIndex].with['restore-keys']).not.toContain('tsbuildinfo-${{ runner.os }}-\n')
|
|
})
|
|
|
|
it('checks out full history without historical blobs', () => {
|
|
const fullHistoryCheckouts = Object.values(workflow.jobs)
|
|
.flatMap((job) => job.steps ?? [])
|
|
.filter(
|
|
(step) => step.uses?.startsWith('actions/checkout@') && step.with?.['fetch-depth'] === 0
|
|
)
|
|
|
|
expect(fullHistoryCheckouts.length).toBeGreaterThan(0)
|
|
for (const checkout of fullHistoryCheckouts) {
|
|
expect(checkout.with.filter).toBe('blob:none')
|
|
}
|
|
})
|
|
|
|
it('keeps verify as the aggregate required check', () => {
|
|
expect(workflow.jobs.verify.needs).toEqual([
|
|
'code_paths',
|
|
'static_analysis',
|
|
'typecheck',
|
|
'git_compatibility',
|
|
'codex_index_heal_contract',
|
|
'xterm_patch_sync',
|
|
'shell_contracts',
|
|
'test',
|
|
'orcad_browser',
|
|
'mobile_web_app',
|
|
'cross-version-wire',
|
|
'managed_hook_node18',
|
|
'package',
|
|
'package_windows'
|
|
])
|
|
const verifyStep = workflow.jobs.verify.steps.find(
|
|
(step) => step.name === 'Require successful checks'
|
|
)
|
|
expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}')
|
|
expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"')
|
|
// Why assert this one too: the browser provider test skips itself without
|
|
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
|
|
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
|
|
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
|
|
expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}')
|
|
expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"')
|
|
// Same reason as the browser provider: the render check fails loudly on a runner with no
|
|
// Chrome, which only guards the page if verify reads the job's result.
|
|
expect(verifyStep.env.MOBILE_WEB_APP).toBe('${{ needs.mobile_web_app.result }}')
|
|
expect(verifyStep.run).toContain('"$MOBILE_WEB_APP"')
|
|
})
|
|
|
|
it('makes the mobile_web_app job refuse to skip the tests it exists to run', () => {
|
|
// The bundling tests skip themselves without mobile/node_modules, which is what keeps the
|
|
// sharded `test` job green. Only this env var stops that skip from spreading to the one job
|
|
// that installs them, so a typo here would leave the whole job passing vacuously.
|
|
const step = workflow.jobs.mobile_web_app.steps.find(
|
|
(entry) => entry.name === 'Builder, override census and render checks'
|
|
)
|
|
expect(step.run).toContain('node config/scripts/run-mobile-web-app-checks.mjs')
|
|
expect(step.run).not.toContain('--prepare-route-snapshot')
|
|
expect(step.env[MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV]).toBe('1')
|
|
expect(mobileWebCheckArgs).toEqual([
|
|
'run',
|
|
'--config',
|
|
'config/vitest.config.ts',
|
|
'config/scripts/mobile-web-app-',
|
|
'config/scripts/build-mobile-web-app-bundle.test.mjs'
|
|
])
|
|
})
|
|
})
|