diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 7bf1d8cd43b..e5ad42084ed 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -80,7 +80,9 @@ runs: # PR-local stores compete with reusable build caches for the repository quota. - name: Resolve pnpm download store id: pnpm-store - if: github.event_name == 'pull_request' + if: >- + github.event_name == 'pull_request' && + (runner.os != 'Windows' || runner.arch != 'X64' || !contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) shell: bash env: LOCKFILE_HASH: ${{ hashFiles(inputs.cache-dependency-path) }} @@ -92,8 +94,11 @@ runs: printf 'arch=%s\n' "$(node -p 'require("node:os").arch()')" >> "$GITHUB_OUTPUT" # Match setup-node's key and path so existing default-branch stores remain reusable. + # Hosted Windows x64 mixed installs cost less than restoring their root/mobile store. - name: Restore pnpm download store without saving - if: github.event_name == 'pull_request' + if: >- + github.event_name == 'pull_request' && + (runner.os != 'Windows' || runner.arch != 'X64' || !contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) uses: actions/cache/restore@v5 with: path: ${{ steps.pnpm-store.outputs.path }} @@ -237,9 +242,9 @@ runs: node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} - # pnpm's bundled gyp_main.py is not executable on fresh Linux runners. + # pnpm's bundled gyp_main.py is not executable; Electron rebuild uses its own node-gyp API. - name: Use external node-gyp - if: runner.os == 'Linux' && inputs.native-runtime != 'none' + if: runner.os == 'Linux' && inputs.native-runtime == 'node' shell: bash env: NATIVE_RUNTIME: ${{ inputs.native-runtime }} diff --git a/.github/actions/prepare-linux-package-fixture/action.yml b/.github/actions/prepare-linux-package-fixture/action.yml index a84568b3e06..e3f297f4b10 100644 --- a/.github/actions/prepare-linux-package-fixture/action.yml +++ b/.github/actions/prepare-linux-package-fixture/action.yml @@ -32,7 +32,7 @@ runs: run: | set -euo pipefail case "$FIXTURE" in - headless-serve-shutdown|cli-launch-contract) ;; + headless-serve-shutdown|cli-launch-contract|daemon-shutdown-descendants) ;; *) echo "Unsupported package fixture: $FIXTURE" >&2; exit 1 ;; esac archive="$RUNNER_TEMP/orca-package-fixtures/$FIXTURE.tar" diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index 7309f7e3912..4d69cd1c104 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -13,6 +13,7 @@ on: - '.github/actions/prepare-linux-package-fixture/**' - 'config/docker/headless-serve-shutdown/**' - 'config/docker/cli-launch-contract/**' + - 'config/docker/daemon-shutdown-descendants/**' - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' @@ -29,6 +30,7 @@ on: - '.github/actions/prepare-linux-package-fixture/**' - 'config/docker/headless-serve-shutdown/**' - 'config/docker/cli-launch-contract/**' + - 'config/docker/daemon-shutdown-descendants/**' - 'config/scripts/ci-cache-warmup-workflow.test.mjs' permissions: @@ -121,6 +123,10 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false + - uses: ./.github/actions/prepare-linux-package-fixture + with: + fixture: daemon-shutdown-descendants + save-cache: ${{ github.event_name != 'pull_request' }} - uses: ./.github/actions/prepare-linux-package-fixture with: fixture: headless-serve-shutdown diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4177b141d8a..8dad06a1faf 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -17,6 +17,16 @@ on: description: JSON array of changed specs; empty runs the full suite required: false type: string + run_changed_e2e: + description: PR detector found specs requiring the general changed-spec lane. + required: false + type: boolean + default: true + needs_build: + description: PR detector found an Electron consumer requiring build and native cache. + required: false + type: boolean + default: true ssh_source_changed: description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane' required: false @@ -38,6 +48,7 @@ on: jobs: build: name: build e2e app + if: inputs.test_files == '' || github.event_name != 'pull_request' || inputs.needs_build runs-on: ubuntu-latest timeout-minutes: 10 @@ -97,6 +108,7 @@ jobs: # this immutable cache instead of compiling the same ABI concurrently. prepare-native-cache: name: prepare Electron native cache + if: inputs.test_files == '' || github.event_name != 'pull_request' || inputs.needs_build runs-on: ubuntu-latest timeout-minutes: 15 @@ -240,7 +252,7 @@ jobs: changed-e2e: name: changed e2e specs needs: [build, prepare-native-cache] - if: inputs.test_files != '' + if: inputs.test_files != '' && (github.event_name != 'pull_request' || inputs.run_changed_e2e) runs-on: ubuntu-latest timeout-minutes: 45 @@ -271,44 +283,47 @@ jobs: env: TEST_FILES_JSON: ${{ inputs.test_files }} run: | - # Why the native IME spec is dropped: it test.skip()s itself without - # ORCA_E2E_NATIVE_IBUS_HANGUL, which this lane cannot set because it has no ibus - # session. Running it here reported a green skip as coverage. - mapfile -t TEST_FILES < <(jq -r '.[] | select( - . != "tests/e2e/local-ssh-browser-routing.spec.ts" and - . != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and - . != "tests/e2e/pty-input-write-queue-ssh.spec.ts" and - . != "tests/e2e/ssh-ai-vault-session-history.spec.ts" and - . != "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts" and - . != "tests/e2e/ssh-cold-activation-restore.spec.ts" and - . != "tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts" and - . != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and - . != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and - . != "tests/e2e/ssh-docker-half-open-link.spec.ts" and - . != "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts" and - . != "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts" and - . != "tests/e2e/ssh-docker-relay-stall-credential.spec.ts" and - . != "tests/e2e/ssh-docker-resource-accumulation.spec.ts" and - . != "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts" and - . != "tests/e2e/ssh-external-image-preview.spec.ts" and - . != "tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts" and - . != "tests/e2e/ssh-pi-compatible-agent-title.spec.ts" and - . != "tests/e2e/ssh-port-forward-lifecycle.spec.ts" and - . != "tests/e2e/ssh-reconnect-tab-destruction.spec.ts" and - . != "tests/e2e/ssh-restart-tab-accumulation.spec.ts" and - . != "tests/e2e/ssh-skill-installation.spec.ts" and - . != "tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts" and - . != "tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts" and - . != "tests/e2e/terminal-inline-images-ssh.spec.ts" and - . != "tests/e2e/ssh-docker-watcher-isolation.spec.ts" and - . != "tests/e2e/ssh-terminal-parking.spec.ts" and - . != "tests/e2e/terminal-retention-budget.spec.ts" and - . != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and - . != "tests/e2e/paired-startup-exec-readiness.spec.ts" and - . != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and - . != "tests/e2e/ssh-localhost.spec.ts" and - . != "tests/e2e/terminal-ibus-hangul-native.spec.ts" - )' <<<"$TEST_FILES_JSON") + if [ -f config/scripts/ci-e2e-job-selection.mjs ]; then + printf '%s\n' "$TEST_FILES_JSON" | node config/scripts/ci-e2e-job-selection.mjs > "$RUNNER_TEMP/general-e2e-specs" + else + # Dispatching an older ref retains its existing dedicated-owner exclusions. + jq -r '.[] | select( + . != "tests/e2e/local-ssh-browser-routing.spec.ts" and + . != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and + . != "tests/e2e/pty-input-write-queue-ssh.spec.ts" and + . != "tests/e2e/ssh-ai-vault-session-history.spec.ts" and + . != "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts" and + . != "tests/e2e/ssh-cold-activation-restore.spec.ts" and + . != "tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts" and + . != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and + . != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and + . != "tests/e2e/ssh-docker-half-open-link.spec.ts" and + . != "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts" and + . != "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts" and + . != "tests/e2e/ssh-docker-relay-stall-credential.spec.ts" and + . != "tests/e2e/ssh-docker-resource-accumulation.spec.ts" and + . != "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts" and + . != "tests/e2e/ssh-external-image-preview.spec.ts" and + . != "tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts" and + . != "tests/e2e/ssh-pi-compatible-agent-title.spec.ts" and + . != "tests/e2e/ssh-port-forward-lifecycle.spec.ts" and + . != "tests/e2e/ssh-reconnect-tab-destruction.spec.ts" and + . != "tests/e2e/ssh-restart-tab-accumulation.spec.ts" and + . != "tests/e2e/ssh-skill-installation.spec.ts" and + . != "tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts" and + . != "tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts" and + . != "tests/e2e/terminal-inline-images-ssh.spec.ts" and + . != "tests/e2e/ssh-docker-watcher-isolation.spec.ts" and + . != "tests/e2e/ssh-terminal-parking.spec.ts" and + . != "tests/e2e/terminal-retention-budget.spec.ts" and + . != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and + . != "tests/e2e/paired-startup-exec-readiness.spec.ts" and + . != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and + . != "tests/e2e/ssh-localhost.spec.ts" and + . != "tests/e2e/terminal-ibus-hangul-native.spec.ts" + )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" + fi + mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" if [ "${#TEST_FILES[@]}" -eq 0 ]; then echo "Changed specs are all owned by dedicated lanes." exit 0 @@ -417,10 +432,9 @@ jobs: mv test-results "e2e-traces/watcher-isolation" fi - # Why always(): this lane gates SSH parking/retention plus startup-exec - # readiness across live SSH, headed paired, and headless serve topologies. + # Keep every SSH journey after a test failure; stop work on superseded commits. - name: Run Docker SSH terminal parking + startup readiness E2E - if: always() && matrix.shard == 1 + if: '!cancelled() && matrix.shard == 1' run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking - name: Keep terminal-parking traces @@ -433,7 +447,7 @@ jobs: # Separate VMs isolate destructive SSH fixtures while keeping one worker per shard. - name: Run remaining Docker SSH E2E - if: always() + if: '!cancelled()' run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker --shard=${{ matrix.shard }}/4 - name: Keep remaining-ssh-docker traces diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8844a69a595..a58ef75d4bd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -51,6 +51,8 @@ jobs: package_windows: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }} e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }} test_files: ${{ steps.e2e_filter.outputs.test_files }} + e2e_run_changed: ${{ steps.e2e_filter.outputs.e2e_run_changed }} + e2e_needs_build: ${{ steps.e2e_filter.outputs.e2e_needs_build }} ssh_source_changed: ${{ steps.e2e_filter.outputs.ssh_source_changed }} native_ime_source_changed: ${{ steps.e2e_filter.outputs.native_ime_source_changed }} wsl_source_changed: ${{ steps.e2e_filter.outputs.wsl_source_changed }} @@ -74,6 +76,7 @@ jobs: /config/scripts/check-readme-local-links.mjs /config/scripts/pr-code-change-scope.mjs /config/scripts/pr-e2e-source-routing.mjs + /config/scripts/ci-e2e-job-selection.mjs /config/scripts/pr-ready-check-reuse.mjs sparse-checkout-cone-mode: false persist-credentials: false @@ -133,6 +136,7 @@ jobs: SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)" echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT" echo "SSH source changed: $SSH_SOURCE_CHANGED" + printf '%s\n' "$TEST_FILES_JSON" | E2E_SSH_SOURCE_CHANGED="$SSH_SOURCE_CHANGED" node config/scripts/ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT" # Why its own signal: the real-IME lane is a whole ibus session, not a spec, so it must # trigger on IME source rather than on a spec name in some route's list. NATIVE_IME_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --native-ime-source)" @@ -918,6 +922,12 @@ jobs: restore-keys: | electron-builder-linux- + - uses: ./.github/actions/prepare-linux-package-fixture + id: daemon-fixture-cache + background: true + with: + fixture: daemon-shutdown-descendants + - uses: ./.github/actions/install-node-dependencies with: native-runtime: electron @@ -930,9 +940,12 @@ jobs: - uses: ./.github/actions/install-mobile-dependencies # The checkout must pass without depending on a historical baseline that leaks. + - wait: daemon-fixture-cache + - name: Verify Linux daemon shutdown descendant cleanup env: ORCA_BACKGROUND_LAUNCH: '1' + ORCA_DAEMON_SHUTDOWN_FIXTURE_CACHE_IMAGE: ${{ steps.daemon-fixture-cache.outputs.image }} run: node config/scripts/run-daemon-shutdown-descendants-docker.mjs # Why --no-file-parallelism: every file here launches a full Electron stack twice, and each @@ -1228,6 +1241,8 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} test_files: ${{ needs.code_paths.outputs.test_files }} ssh_source_changed: ${{ needs.code_paths.outputs.ssh_source_changed }} + run_changed_e2e: ${{ needs.code_paths.outputs.e2e_run_changed != 'false' }} + needs_build: ${{ needs.code_paths.outputs.e2e_needs_build != 'false' }} # Why this is not in verify's needs: it is the first PR-gate run of a harness whose reliability # is only known from nightly main runs (20/20 green, 2026-08-09..2026-08-29, p50 3m25s). It diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs index ce628cc7c45..584c37f6740 100644 --- a/config/scripts/ci-dependency-download-cache.test.mjs +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' @@ -29,12 +30,14 @@ describe('CI dependency download caches', () => { ]) }) - it('restores PR stores with setup-node keys without registering a post-job save', () => { + it('restores PR stores except measured Windows mixed installs, without a post-job save', () => { const resolve = action.runs.steps.find((step) => step.id === 'pnpm-store') const restore = action.runs.steps.find( (step) => step.name === 'Restore pnpm download store without saving' ) - expect(resolve.if).toBe("github.event_name == 'pull_request'") + expect(resolve.if).toBe( + "github.event_name == 'pull_request' && (runner.os != 'Windows' || runner.arch != 'X64' || !contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml'))" + ) expect(restore.if).toBe(resolve.if) expect(restore.uses).toBe('actions/cache/restore@v5') expect(restore.with.path).toBe('${{ steps.pnpm-store.outputs.path }}') @@ -53,6 +56,49 @@ describe('CI dependency download caches', () => { expect(saves[0].if).toContain("github.ref == 'refs/heads/main'") expect(saves[0].if).toContain("github.event_name != 'pull_request'") expect(saves[0].with.path).toBe('${{ steps.verification-cache.outputs.path }}') + const windows = workflow('pr').jobs.package_windows.steps.find((step) => + step.uses?.includes('install-node-dependencies') + ) + expect(windows.with['cache-dependency-path'].trim().split('\n')).toEqual([ + 'pnpm-lock.yaml', + 'mobile/pnpm-lock.yaml' + ]) + }) + + it.each([ + ['Windows x64 mixed PR', 'pull_request', 'Windows', 'X64', true, false, ''], + ['Windows ARM64 mixed PR', 'pull_request', 'Windows', 'ARM64', true, true, ''], + ['Windows x86 mixed PR', 'pull_request', 'Windows', 'X86', true, true, ''], + ['Windows x64 root-only PR', 'pull_request', 'Windows', 'X64', false, true, ''], + ['Linux x64 mixed PR', 'pull_request', 'Linux', 'X64', true, true, ''], + ['Linux ARM64 mixed PR', 'pull_request', 'Linux', 'ARM64', true, true, ''], + ['macOS ARM64 mixed PR', 'pull_request', 'macOS', 'ARM64', true, true, ''], + ['Windows x64 mixed push', 'push', 'Windows', 'X64', true, false, 'pnpm'], + ['Windows x64 mixed manual run', 'workflow_dispatch', 'Windows', 'X64', true, false, 'pnpm'] + ])('%s keeps its scoped store policy', (_name, event, os, arch, mixed, restore, cache) => { + const context = { + github: { event_name: event }, + runner: { os, arch }, + inputs: { + 'cache-dependency-path': mixed ? 'pnpm-lock.yaml\nmobile/pnpm-lock.yaml' : 'pnpm-lock.yaml' + }, + contains: (value, search) => value.toLowerCase().includes(search.toLowerCase()) + } + const evaluate = (expression) => + runInNewContext( + expression.replaceAll('inputs.cache-dependency-path', 'inputs["cache-dependency-path"]'), + context + ) + for (const step of action.runs.steps.filter( + (step) => + step.id === 'pnpm-store' || step.name === 'Restore pnpm download store without saving' + )) { + expect(evaluate(step.if)).toBe(restore) + } + for (const step of action.runs.steps.filter((step) => step.uses === 'actions/setup-node@v6')) { + expect(evaluate(step.with.cache.slice(3, -2))).toBe(cache) + expect(step.with['package-manager-cache']).toBe(false) + } }) it('restores Windows packaging downloads from the release cache without a PR upload', () => { diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs new file mode 100644 index 00000000000..6126e753b5d --- /dev/null +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -0,0 +1,103 @@ +import { pathToFileURL } from 'node:url' + +export const DOCKER_SSH_E2E_SPECS = [ + 'tests/e2e/local-ssh-browser-routing.spec.ts', + 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts', + 'tests/e2e/pty-input-write-queue-ssh.spec.ts', + 'tests/e2e/ssh-ai-vault-session-history.spec.ts', + 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', + 'tests/e2e/ssh-cold-activation-restore.spec.ts', + 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts', + 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts', + 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts', + 'tests/e2e/ssh-docker-half-open-link.spec.ts', + 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts', + 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', + 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts', + 'tests/e2e/ssh-docker-resource-accumulation.spec.ts', + 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', + 'tests/e2e/ssh-external-image-preview.spec.ts', + 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts', + 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts', + 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', + 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', + 'tests/e2e/ssh-restart-tab-accumulation.spec.ts', + 'tests/e2e/ssh-skill-installation.spec.ts', + 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts', + 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts', + 'tests/e2e/terminal-inline-images-ssh.spec.ts', + 'tests/e2e/ssh-docker-watcher-isolation.spec.ts', + 'tests/e2e/ssh-terminal-parking.spec.ts', + 'tests/e2e/terminal-retention-budget.spec.ts', + 'tests/e2e/ssh-startup-exec-readiness.spec.ts', + 'tests/e2e/paired-startup-exec-readiness.spec.ts' +] + +export const NODE_NETWORK_E2E_SPEC = + 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts' +export const LOCALHOST_SSH_E2E_SPEC = 'tests/e2e/ssh-localhost.spec.ts' +export const NATIVE_IME_E2E_SPEC = 'tests/e2e/terminal-ibus-hangul-native.spec.ts' +export const DEDICATED_E2E_SPECS = [ + ...DOCKER_SSH_E2E_SPECS, + NODE_NETWORK_E2E_SPEC, + LOCALHOST_SSH_E2E_SPEC, + NATIVE_IME_E2E_SPEC +] +const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) +const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) + +export function selectGeneralE2eSpecs(specs) { + return specs.filter((spec) => !dedicatedSpecs.has(spec)) +} + +function parseSpecs(input) { + const specs = JSON.parse(input) + if (!Array.isArray(specs) || specs.some((spec) => typeof spec !== 'string' || !spec)) { + throw new Error('Expected a JSON array of nonempty E2E spec paths') + } + return specs +} + +export function classifyE2eJobs(input, sshSourceChanged = 'false') { + const conservative = { e2e_run_changed: true, e2e_needs_build: true } + let specs + try { + specs = parseSpecs(input) + } catch { + return conservative + } + // Empty evidence keeps allocations; the consumer still validates its input. + if (specs.length === 0) { + return conservative + } + const runChanged = selectGeneralE2eSpecs(specs).length > 0 + return { + e2e_run_changed: runChanged, + e2e_needs_build: + runChanged || + sshSourceChanged !== 'false' || + specs.some((spec) => dockerSpecs.has(spec) || spec === LOCALHOST_SSH_E2E_SPEC) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + let input = '' + process.stdin.setEncoding('utf8') + for await (const chunk of process.stdin) { + input += chunk + } + if (process.argv.includes('--job-outputs')) { + for (const [name, value] of Object.entries( + classifyE2eJobs(input, process.env.E2E_SSH_SOURCE_CHANGED ?? 'false') + )) { + process.stdout.write(`${name}=${value}\n`) + } + } else { + for (const spec of selectGeneralE2eSpecs(parseSpecs(input))) { + if (/[\r\n]/.test(spec)) { + throw new Error('E2E spec paths cannot contain newlines') + } + process.stdout.write(`${spec}\n`) + } + } +} diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs new file mode 100644 index 00000000000..cfe71813409 --- /dev/null +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -0,0 +1,147 @@ +import { readFileSync } from 'node:fs' +import { expect, it } from 'vitest' +import { parse } from 'yaml' +import { runProcess } from '../../src/shared/child-process/run-process' +import { + classifyE2eJobs, + DEDICATED_E2E_SPECS, + DOCKER_SSH_E2E_SPECS, + LOCALHOST_SSH_E2E_SPEC, + NATIVE_IME_E2E_SPEC, + NODE_NETWORK_E2E_SPEC, + selectGeneralE2eSpecs +} from './ci-e2e-job-selection.mjs' +import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' + +const workflow = parse(readFileSync('.github/workflows/e2e.yml', 'utf8')) +const prWorkflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) +const classify = (specs, ssh = 'false') => classifyE2eJobs(JSON.stringify(specs), ssh) + +it('skips the general consumer only when every requested spec has a dedicated owner', () => { + for (const spec of DEDICATED_E2E_SPECS) { + expect(classify([spec]).e2e_run_changed, spec).toBe(false) + } + expect(classify(DEDICATED_E2E_SPECS).e2e_run_changed).toBe(false) + const future = 'tests/e2e/future-unclassified.spec.ts' + expect(classify([future])).toEqual({ e2e_run_changed: true, e2e_needs_build: true }) + expect(selectGeneralE2eSpecs([...DEDICATED_E2E_SPECS, future])).toEqual([future]) + expect(classify([...DEDICATED_E2E_SPECS, future]).e2e_run_changed).toBe(true) +}) + +it('keeps Electron build and native prerequisites for every consumer requiring them', () => { + for (const spec of [...DOCKER_SSH_E2E_SPECS, LOCALHOST_SSH_E2E_SPEC]) { + expect(classify([spec]), spec).toEqual({ + e2e_run_changed: false, + e2e_needs_build: true + }) + } + for (const spec of [NODE_NETWORK_E2E_SPEC, NATIVE_IME_E2E_SPEC]) { + expect(classify([spec]), spec).toEqual({ + e2e_run_changed: false, + e2e_needs_build: false + }) + expect(classify([spec], 'true').e2e_needs_build).toBe(true) + } + expect(workflow.jobs['ssh-browser-network-route'].needs).toBeUndefined() + for (const name of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation', 'ssh-localhost']) { + expect(workflow.jobs[name].needs, name).toEqual(['build', 'prepare-native-cache']) + } +}) + +it('retains allocations when selection or SSH evidence is incomplete', () => { + for (const input of ['', '[]', 'null', '{}', '[null]', '[""]', 'malformed']) { + expect(classifyE2eJobs(input), input).toEqual({ + e2e_run_changed: true, + e2e_needs_build: true + }) + } + expect(classify([NODE_NETWORK_E2E_SPEC], '').e2e_needs_build).toBe(true) +}) + +it('preserves the requested specs across source-routed and mixed selections', () => { + for (const files of [ + ['src/main/ssh/connection.ts'], + ['src/main/browser/ssh-browser-network-execution-route.ts'], + ['src/main/agent-hooks/server.ts'], + ['src/shared/terminal-unicode-provider.ts'], + ['tests/e2e/ssh-localhost.spec.ts', 'tests/e2e/future-unclassified.spec.ts'] + ]) { + const specs = selectPrE2eSpecs(files) + const general = selectGeneralE2eSpecs(specs) + const dedicated = specs.filter((spec) => DEDICATED_E2E_SPECS.includes(spec)) + expect([...general, ...dedicated].sort(), files.join(', ')).toEqual(specs) + expect(new Set([...general, ...dedicated]).size).toBe(specs.length) + } +}) + +it('applies allocation hints only to PRs and retains other callers and full references', () => { + for (const name of ['run_changed_e2e', 'needs_build']) { + expect(workflow.on.workflow_call.inputs[name]).toMatchObject({ + type: 'boolean', + required: false, + default: true + }) + } + for (const name of ['build', 'prepare-native-cache']) { + expect(workflow.jobs[name].if).toBe( + "inputs.test_files == '' || github.event_name != 'pull_request' || inputs.needs_build" + ) + } + expect(workflow.jobs.e2e.if).toBe("inputs.test_files == ''") + const changed = workflow.jobs['changed-e2e'] + expect(changed.if).toContain("github.event_name != 'pull_request' || inputs.run_changed_e2e") + const command = changed.steps.find((step) => step.name === 'Run changed E2E specs').run + expect(command).toContain('node config/scripts/ci-e2e-job-selection.mjs >') + expect(command).not.toContain('mapfile -t TEST_FILES < <(') + const fallback = [...command.matchAll(/\. != "([^"]+)"/g)].map((match) => match[1]) + expect(fallback).toEqual(DEDICATED_E2E_SPECS) +}) + +it('publishes conservative hints for malformed evidence and refuses a malformed consumer list', async () => { + const program = 'config/scripts/ci-e2e-job-selection.mjs' + const consumer = await runProcess({ + program: process.execPath, + args: [program], + input: 'malformed', + timeoutMs: 10000 + }) + expect(consumer.code).not.toBe(0) + const hints = await runProcess({ + program: process.execPath, + args: [program, '--job-outputs'], + input: 'malformed', + timeoutMs: 10000 + }) + expect(hints.code, hints.stderr).toBe(0) + expect(hints.stdout).toBe('e2e_run_changed=true\ne2e_needs_build=true\n') +}) + +it('classifies PR consumers in the existing detector and passes conservative allocation hints', () => { + const detector = prWorkflow.jobs.code_paths + expect(detector.steps[0].with['sparse-checkout']).toContain( + '/config/scripts/ci-e2e-job-selection.mjs' + ) + for (const name of ['e2e_run_changed', 'e2e_needs_build']) { + expect(detector.outputs[name]).toBe(`\${{ steps.e2e_filter.outputs.${name} }}`) + } + const command = detector.steps.find((step) => step.id === 'e2e_filter').run + expect(command).toContain('E2E_SSH_SOURCE_CHANGED="$SSH_SOURCE_CHANGED"') + expect(command).toContain('ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT"') + expect(prWorkflow.jobs.e2e.with.run_changed_e2e).toBe( + "${{ needs.code_paths.outputs.e2e_run_changed != 'false' }}" + ) + expect(prWorkflow.jobs.e2e.with.needs_build).toBe( + "${{ needs.code_paths.outputs.e2e_needs_build != 'false' }}" + ) +}) + +it('runs remaining SSH tests after real failures and stops them when a run is cancelled', () => { + const steps = workflow.jobs['ssh-docker-watcher-isolation'].steps + expect(steps.find((step) => step.name === 'Run remaining Docker SSH E2E').if).toBe('!cancelled()') + expect( + steps.find((step) => step.name === 'Run Docker SSH terminal parking + startup readiness E2E').if + ).toBe('!cancelled() && matrix.shard == 1') + for (const step of steps.filter((step) => step.name?.startsWith('Keep '))) { + expect(step.if).toContain('always()') + } +}) diff --git a/config/scripts/ci-native-toolchain.test.mjs b/config/scripts/ci-native-toolchain.test.mjs index e35437da77c..b9d7be83707 100644 --- a/config/scripts/ci-native-toolchain.test.mjs +++ b/config/scripts/ci-native-toolchain.test.mjs @@ -17,7 +17,7 @@ describe('CI native toolchain preparation', () => { expect(toolchain.env.NATIVE_CACHE_HIT).toContain(`steps.${id}.outputs.cache-hit`) } expect(index).toBeLessThan(steps.findIndex((step) => step.name === 'Prepare native runtime')) - expect(toolchain.if).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'") + expect(toolchain.if).toBe("runner.os == 'Linux' && inputs.native-runtime == 'node'") }) // The action's toolchain workaround only runs in Linux Bash. @@ -25,9 +25,7 @@ describe('CI native toolchain preparation', () => { ['node', 'true', '0', false], ['node', 'true', '1', true], ['node', 'false', '0', true], - ['node', '', '0', true], - ['electron', 'true', '0', true], - ['electron', 'false', '0', true] + ['node', '', '0', true] ])('runtime=%s cache=%s probe=%s installs=%s', (runtime, hit, probeStatus, installs) => { const directory = mkdtempSync(join(tmpdir(), 'orca-ci-native-toolchain-')) const log = join(directory, 'commands') @@ -66,4 +64,13 @@ describe('CI native toolchain preparation', () => { rmSync(directory, { recursive: true, force: true }) } }) + + it('prepares Electron native modules through the existing rebuild path without the global toolchain', () => { + const preparation = steps.find((step) => step.name === 'Prepare native runtime') + expect(preparation.if).toBe("inputs.native-runtime != 'none'") + expect(preparation.env.NATIVE_RUNTIME).toBe('${{ inputs.native-runtime }}') + expect(preparation.run).toBe( + 'node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"' + ) + }) }) diff --git a/config/scripts/linux-package-fixture-cache.test.mjs b/config/scripts/linux-package-fixture-cache.test.mjs index 1247d70bbca..0a1e62fb63a 100644 --- a/config/scripts/linux-package-fixture-cache.test.mjs +++ b/config/scripts/linux-package-fixture-cache.test.mjs @@ -18,7 +18,13 @@ afterEach(() => { } }) -function exercise({ hit = false, save = false, loadFails = false, inspectFails = false } = {}) { +function exercise({ + hit = false, + save = false, + loadFails = false, + inspectFails = false, + fixture = 'cli-launch-contract' +} = {}) { directory = mkdtempSync(join(tmpdir(), 'orca-package-cache-')) const bin = join(directory, 'bin') mkdirSync(bin) @@ -48,7 +54,7 @@ function exercise({ hit = false, save = false, loadFails = false, inspectFails = RUNNER_TEMP: directory, GITHUB_OUTPUT: output, COMMAND_LOG: commandLog, - FIXTURE: 'cli-launch-contract', + FIXTURE: fixture, CACHE_HIT: String(hit), SAVE_CACHE: String(save), LOAD_FAILS: String(loadFails), @@ -81,6 +87,12 @@ describe.runIf(process.platform !== 'win32')('fixture cache fallbacks', () => { expect(result.commands).not.toMatch(/(?:build|save) /) }) + it('loads the daemon shutdown fixture without running package or source tests in the cache action', () => { + const result = exercise({ hit: true, fixture: 'daemon-shutdown-descendants' }) + expect(result.output).toBe('image=orca-package-fixture-daemon-shutdown-descendants:cache\n') + expect(result.commands).not.toMatch(/(?:build|save|run) /) + }) + it('builds and exports inline cache only when the warmer has no usable image', () => { const result = exercise({ save: true }) expect(result.commands).toContain( @@ -105,6 +117,7 @@ it('uses exact context/platform keys and the same restore-only action in package step.uses?.endsWith('/prepare-linux-package-fixture') ) expect(prepared.map((step) => step.with)).toEqual([ + { fixture: 'daemon-shutdown-descendants' }, { fixture: 'headless-serve-shutdown' }, { fixture: 'cli-launch-contract' } ]) diff --git a/config/scripts/package-linux-formats-appimage.mjs b/config/scripts/package-linux-formats-appimage.mjs new file mode 100644 index 00000000000..a6a3ae6e7b6 --- /dev/null +++ b/config/scripts/package-linux-formats-appimage.mjs @@ -0,0 +1,64 @@ +import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' +import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' + +const require = createRequire(import.meta.url) + +async function getPinnedTools(version) { + const { getAppImageTools } = require('app-builder-lib/out/toolsets/linux.js') + return getAppImageTools(version, require('builder-util').Arch.x64) +} + +export async function preparePrAppImageTools({ + directory: requestedDirectory, + configuration = require('../electron-builder-pr-linux.config.cjs'), + getTools = getPinnedTools, + platform = process.platform, + architecture = process.arch +}) { + if (platform !== 'linux' || architecture !== 'x64') { + throw new Error('PR AppImage compression requires a Linux x64 host') + } + if (configuration.toolsets?.appimage !== '1.0.3') { + throw new Error('PR AppImage compression requires pinned AppImage toolset 1.0.3') + } + if ( + configuration.compression === 'store' || + (configuration.appImage?.compression && configuration.appImage.compression !== 'zstd') + ) { + throw new Error('PR AppImage compression requires the existing zstd configuration') + } + // Resolve the original override before installing the private, child-only overlay. + const tools = await getTools(configuration.toolsets.appimage) + const version = runProcessSync({ + program: tools.mksquashfs, + args: ['-version'], + timeoutMs: 10_000, + maxOutputBytes: 64 * 1024 + }) + if ( + version.code !== 0 || + version.timedOut || + version.outputTruncated || + !/^mksquashfs version 4\.6\.1(?:\s|$)/m.test(version.stdout) + ) { + throw new Error( + `PR AppImage compression requires mksquashfs 4.6.1: ${describeProcessFailure(version)}` + ) + } + const directory = resolve(requestedDirectory) + mkdirSync(directory) + symlinkSync(tools.desktopFileValidate, join(directory, 'desktop-file-validate')) + symlinkSync(dirname(tools.runtime), join(directory, 'runtimes')) + symlinkSync(dirname(tools.runtimeLibraries), join(directory, 'lib')) + writeFileSync( + join(directory, 'mksquashfs'), + '#!/usr/bin/env bash\nset -euo pipefail\nexec "$ORCA_PR_APPIMAGE_MKSQUASHFS" "$@" -Xcompression-level 3\n', + { mode: 0o755 } + ) + return { + APPIMAGE_TOOLS_PATH: directory, + ORCA_PR_APPIMAGE_MKSQUASHFS: tools.mksquashfs + } +} diff --git a/config/scripts/package-linux-formats-appimage.test.mjs b/config/scripts/package-linux-formats-appimage.test.mjs new file mode 100644 index 00000000000..3fb845ab31f --- /dev/null +++ b/config/scripts/package-linux-formats-appimage.test.mjs @@ -0,0 +1,220 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { preparePrAppImageTools } from './package-linux-formats-appimage.mjs' +import { runProcessSync } from './script-child-process.mjs' + +const require = createRequire(import.meta.url) +const configuration = { toolsets: { appimage: '1.0.3' } } +let root + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca pr AppImage tools-')) +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +it.each([ + ['linux', 'arm64'], + ['darwin', 'x64'], + ['win32', 'x64'] +])('refuses unmeasured %s/%s hosts before resolving tools', async (platform, architecture) => { + const getTools = vi.fn() + await expect( + preparePrAppImageTools({ + directory: join(root, 'overlay'), + configuration, + getTools, + platform, + architecture + }) + ).rejects.toThrow('requires a Linux x64 host') + expect(getTools).not.toHaveBeenCalled() +}) + +it.each([ + [{ toolsets: { appimage: '1.0.2' } }, 'pinned AppImage toolset 1.0.3'], + [{ ...configuration, appImage: { compression: 'gzip' } }, 'existing zstd configuration'], + [{ ...configuration, compression: 'store' }, 'existing zstd configuration'] +])('rejects unsupported configuration %j before resolving tools', async (value, message) => { + const getTools = vi.fn() + await expect( + preparePrAppImageTools({ + directory: join(root, 'overlay'), + configuration: value, + getTools, + platform: 'linux', + architecture: 'x64' + }) + ).rejects.toThrow(message) + expect(getTools).not.toHaveBeenCalled() +}) + +describe.skipIf(process.platform === 'win32')('real executable AppImage tool overlay', () => { + let original + let overlay + + beforeEach(() => { + original = join(root, 'custom tools with spaces') + overlay = join(root, 'private tools') + mkdirSync(join(original, 'runtimes'), { recursive: true }) + mkdirSync(join(original, 'lib', 'x64'), { recursive: true }) + writeFileSync(join(original, 'runtimes', 'runtime-x64'), 'unchanged static runtime') + writeFileSync(join(original, 'lib', 'x64', 'lib.so'), 'unchanged runtime library') + writeFileSync(join(original, 'desktop-file-validate'), '#!/usr/bin/env bash\nexit 0\n', { + mode: 0o755 + }) + writeFileSync( + join(original, 'mksquashfs'), + [ + '#!/usr/bin/env node', + 'if (process.argv[2] === "-version") {', + ' console.log(`mksquashfs version ${process.env.ORCA_TEST_SQUASHFS_VERSION ?? "4.6.1"}`)', + '} else { console.log(JSON.stringify(process.argv.slice(2))) }', + 'process.exitCode = Number(process.env.ORCA_TEST_SQUASHFS_EXIT ?? 0)', + '' + ].join('\n'), + { mode: 0o755 } + ) + vi.stubEnv('APPIMAGE_TOOLS_PATH', original) + }) + + function prepare(options = {}) { + return preparePrAppImageTools({ + directory: overlay, + configuration, + platform: 'linux', + architecture: 'x64', + ...options + }) + } + + it('honors a custom toolset, preserving every original argument and runtime byte', async () => { + const originalProgram = join(original, 'mksquashfs') + const programBytes = readFileSync(originalProgram) + const environment = await prepare() + const args = [ + 'app spaces \' " $HOME $(touch expanded) `touch expanded`', + 'output\nwith newline', + '-offset', + '944632', + '-comp', + 'zstd' + ] + const result = runProcessSync({ + program: join(overlay, 'mksquashfs'), + args, + cwd: root, + env: { ...process.env, ...environment }, + timeoutMs: 10_000 + }) + expect(result.code).toBe(0) + expect(JSON.parse(result.stdout)).toEqual([...args, '-Xcompression-level', '3']) + expect(existsSync(join(root, 'expanded'))).toBe(false) + expect(statSync(join(overlay, 'mksquashfs')).mode & 0o777).toBe(0o755) + expect(readFileSync(originalProgram)).toEqual(programBytes) + expect(process.env.APPIMAGE_TOOLS_PATH).toBe(original) + expect(environment.ORCA_PR_APPIMAGE_MKSQUASHFS).toBe(originalProgram) + + vi.stubEnv('APPIMAGE_TOOLS_PATH', environment.APPIMAGE_TOOLS_PATH) + const tools = await require('app-builder-lib/out/toolsets/linux.js').getAppImageTools( + '1.0.3', + require('builder-util').Arch.x64 + ) + for (const [actual, expected] of [ + [tools.desktopFileValidate, join(original, 'desktop-file-validate')], + [tools.runtime, join(original, 'runtimes', 'runtime-x64')], + [tools.runtimeLibraries, join(original, 'lib', 'x64')] + ]) { + expect(realpathSync(actual)).toBe(realpathSync(expected)) + } + expect(readFileSync(tools.runtime, 'utf8')).toBe('unchanged static runtime') + expect(readFileSync(join(tools.runtimeLibraries, 'lib.so'), 'utf8')).toBe( + 'unchanged runtime library' + ) + }) + + it('quotes an original executable path containing shell metacharacters', async () => { + const tools = await require('app-builder-lib/out/toolsets/linux.js').getAppImageTools( + '1.0.3', + require('builder-util').Arch.x64 + ) + const program = join(root, 'tool \' " $(touch expanded) `touch expanded`') + writeFileSync(program, readFileSync(tools.mksquashfs), { mode: 0o755 }) + const environment = await prepare({ getTools: async () => ({ ...tools, mksquashfs: program }) }) + const result = runProcessSync({ + program: join(overlay, 'mksquashfs'), + args: ['app', 'output', '-comp', 'zstd'], + cwd: root, + env: { ...process.env, ...environment }, + timeoutMs: 10_000 + }) + expect(result.code).toBe(0) + expect(JSON.parse(result.stdout)).toEqual([ + 'app', + 'output', + '-comp', + 'zstd', + '-Xcompression-level', + '3' + ]) + expect(existsSync(join(root, 'expanded'))).toBe(false) + }) + + it('normalizes a relative overlay into an absolute builder override', async () => { + const environment = await prepare({ directory: relative(process.cwd(), overlay) }) + expect(environment.APPIMAGE_TOOLS_PATH).toBe(overlay) + expect(existsSync(join(overlay, 'mksquashfs'))).toBe(true) + }) + + it('propagates the original executable failure without a fallback', async () => { + const environment = await prepare() + vi.stubEnv('ORCA_TEST_SQUASHFS_EXIT', '17') + const result = runProcessSync({ + program: join(overlay, 'mksquashfs'), + args: ['app', 'output', '-comp', 'zstd'], + env: { ...process.env, ...environment }, + timeoutMs: 10_000 + }) + expect(result.code).toBe(17) + }) + + it.each([ + ['ORCA_TEST_SQUASHFS_VERSION', '4.7.0'], + ['ORCA_TEST_SQUASHFS_EXIT', '2'] + ])( + 'rejects an unsupported original tool (%s=%s) without creating an overlay', + async (key, value) => { + vi.stubEnv(key, value) + await expect(prepare()).rejects.toThrow('requires mksquashfs 4.6.1') + expect(existsSync(overlay)).toBe(false) + } + ) + + it('keeps invalid custom toolset errors instead of downloading another toolset', async () => { + vi.stubEnv('APPIMAGE_TOOLS_PATH', join(root, 'missing custom tools')) + await expect(prepare()).rejects.toThrow(/APPIMAGE_TOOLS_PATH|AppImage tool/) + expect(existsSync(overlay)).toBe(false) + }) + + it('preserves upstream rejection of unsafe custom override paths', async () => { + vi.stubEnv('APPIMAGE_TOOLS_PATH', join(root, 'tools $(touch expanded)')) + await expect(prepare()).rejects.toThrow('APPIMAGE_TOOLS_PATH contains shell-unsafe characters') + expect(existsSync(overlay)).toBe(false) + expect(existsSync(join(root, 'expanded'))).toBe(false) + }) +}) diff --git a/config/scripts/package-linux-formats.mjs b/config/scripts/package-linux-formats.mjs index 56c5ed4562f..a759a86d7a3 100644 --- a/config/scripts/package-linux-formats.mjs +++ b/config/scripts/package-linux-formats.mjs @@ -14,6 +14,7 @@ import { import { join, resolve } from 'node:path' import { copyPrivateTree } from './space-sharing-copy.mjs' import { spawnProcess } from './script-child-process.mjs' +import { preparePrAppImageTools } from './package-linux-formats-appimage.mjs' const require = createRequire(import.meta.url) const formats = ['AppImage', 'deb', 'rpm'] @@ -39,12 +40,12 @@ export function linuxFormatArguments({ format, appDirectory, outputDirectory }) ] } -function runElectronBuilder(args) { +function runElectronBuilder(args, environment) { return new Promise((resolveBuild, reject) => { const child = spawnProcess({ program: process.execPath, args: [require.resolve('electron-builder/cli.js'), ...args], - env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ...environment }, stdio: 'inherit' }) child.once('error', reject) @@ -61,6 +62,7 @@ function runElectronBuilder(args) { export async function packageLinuxFormats({ preparedDirectory = resolve('dist/linux-unpacked'), outputDirectory = resolve('dist'), + prepareAppImageTools = preparePrAppImageTools, runBuilder = runElectronBuilder } = {}) { const marker = join(preparedDirectory, 'resources/package-type') @@ -82,8 +84,13 @@ export async function packageLinuxFormats({ console.log( `[linux-package] ${format} copied in ${Math.round(performance.now() - startedFormatAt)}ms` ) + const environment = + format === 'AppImage' + ? await prepareAppImageTools({ directory: join(staging, format, 'tools') }) + : {} await runBuilder( - linuxFormatArguments({ format, appDirectory, outputDirectory: formatOutput }) + linuxFormatArguments({ format, appDirectory, outputDirectory: formatOutput }), + environment ) const artifacts = readdirSync(formatOutput).filter((name) => name.endsWith(`.${format}`)) const artifactStats = diff --git a/config/scripts/package-linux-formats.test.mjs b/config/scripts/package-linux-formats.test.mjs index 7d187af658d..ca47fbb53ba 100644 --- a/config/scripts/package-linux-formats.test.mjs +++ b/config/scripts/package-linux-formats.test.mjs @@ -85,6 +85,7 @@ describe('independent Linux package formats', () => { await packageLinuxFormats({ preparedDirectory, outputDirectory, + prepareAppImageTools: async () => ({}), runBuilder: async (args) => { const app = valueAfter(args, '--prepackaged') const format = valueAfter(args, '--linux') @@ -127,42 +128,93 @@ describe('independent Linux package formats', () => { ).toBe(false) }) - it('settles every worker before cleanup and exposes no partial artifact on failure', async () => { - const finished = [] - let release - const gate = new Promise((resolve) => { - release = resolve - }) - await expect( - packageLinuxFormats({ - preparedDirectory, - outputDirectory, - runBuilder: async (args) => { - const format = valueAfter(args, '--linux') - if (format === 'AppImage') { - throw new Error('compression failed') - } - if (format === 'rpm') { - release() - } - await gate - const { app } = emitPackage(args) - expect(existsSync(app)).toBe(true) - finished.push(format) - } + it.each(['builder', 'tool preparation'])( + 'settles every worker before cleanup and exposes no partial artifact on %s failure', + async (failurePhase) => { + const finished = [] + let overlay + let release + const gate = new Promise((resolve) => { + release = resolve }) - ).rejects.toMatchObject({ - message: 'Linux package formats failed', - errors: [ - expect.objectContaining({ - message: 'AppImage packaging failed', - cause: expect.objectContaining({ message: 'compression failed' }) + await expect( + packageLinuxFormats({ + preparedDirectory, + outputDirectory, + prepareAppImageTools: async ({ directory }) => { + overlay = directory + mkdirSync(directory) + writeFileSync(join(directory, 'mksquashfs'), 'private wrapper') + if (failurePhase === 'tool preparation') { + throw new Error('compression failed') + } + return { APPIMAGE_TOOLS_PATH: directory, ORCA_PR_APPIMAGE_MKSQUASHFS: 'original tool' } + }, + runBuilder: async (args, environment) => { + const format = valueAfter(args, '--linux') + if (format === 'AppImage') { + expect(environment).toEqual({ + APPIMAGE_TOOLS_PATH: overlay, + ORCA_PR_APPIMAGE_MKSQUASHFS: 'original tool' + }) + throw new Error('compression failed') + } + expect(environment).toEqual({}) + if (format === 'rpm') { + release() + } + await gate + const { app } = emitPackage(args) + expect(existsSync(app)).toBe(true) + expect(existsSync(overlay)).toBe(true) + finished.push(format) + } }) - ] + ).rejects.toMatchObject({ + message: 'Linux package formats failed', + errors: [ + expect.objectContaining({ + message: 'AppImage packaging failed', + cause: expect.objectContaining({ message: 'compression failed' }) + }) + ] + }) + expect(finished.sort()).toEqual(['deb', 'rpm']) + expect(existsSync(overlay)).toBe(false) + expect(readdirSync(outputDirectory)).toEqual([]) + expect(readFileSync(join(preparedDirectory, 'resources/package-type'), 'utf8')).toBe( + 'AppImage' + ) + } + ) + + it('passes its private tool environment only to AppImage and removes it after success', async () => { + const originalToolsPath = process.env.APPIMAGE_TOOLS_PATH + let overlay + const calls = [] + await packageLinuxFormats({ + preparedDirectory, + outputDirectory, + prepareAppImageTools: async ({ directory }) => { + overlay = directory + mkdirSync(directory) + return { APPIMAGE_TOOLS_PATH: directory, ORCA_PR_APPIMAGE_MKSQUASHFS: 'original tool' } + }, + runBuilder: async (args, environment) => { + const format = valueAfter(args, '--linux') + calls.push(format) + expect(environment).toEqual( + format === 'AppImage' + ? { APPIMAGE_TOOLS_PATH: overlay, ORCA_PR_APPIMAGE_MKSQUASHFS: 'original tool' } + : {} + ) + expect(process.env.APPIMAGE_TOOLS_PATH).toBe(originalToolsPath) + emitPackage(args) + } }) - expect(finished.sort()).toEqual(['deb', 'rpm']) - expect(readdirSync(outputDirectory)).toEqual([]) - expect(readFileSync(join(preparedDirectory, 'resources/package-type'), 'utf8')).toBe('AppImage') + expect(calls.sort()).toEqual(targets.slice().sort()) + expect(existsSync(overlay)).toBe(false) + expect(process.env.APPIMAGE_TOOLS_PATH).toBe(originalToolsPath) }) it('rejects missing artifacts even when the builder reports success', async () => { @@ -170,6 +222,7 @@ describe('independent Linux package formats', () => { packageLinuxFormats({ preparedDirectory, outputDirectory, + prepareAppImageTools: async () => ({}), runBuilder: async (args) => { const result = emitPackage(args) if (result.format === 'rpm') { @@ -187,6 +240,7 @@ describe('independent Linux package formats', () => { packageLinuxFormats({ preparedDirectory, outputDirectory, + prepareAppImageTools: async () => ({}), runBuilder: async (args) => { emitPackage(args) } @@ -202,6 +256,7 @@ describe('independent Linux package formats', () => { packageLinuxFormats({ preparedDirectory, outputDirectory, + prepareAppImageTools: async () => ({}), runBuilder: async () => { throw new Error('must not run') } diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 1d6ab047544..5c1302ca354 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -212,6 +212,7 @@ describe('per-job path classification', () => { it('runs Linux packaging when an artifact contract changes', () => { for (const file of [ 'config/scripts/package-linux-formats.mjs', + 'config/scripts/package-linux-formats-appimage.mjs', 'config/scripts/script-child-process.mjs', 'config/scripts/space-sharing-copy.mjs', '.github/actions/prepare-linux-package-fixture/action.yml', diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 4bcb1b4f1af..4b8831e357a 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -1,3 +1,4 @@ +import { DEDICATED_E2E_SPECS } from './ci-e2e-job-selection.mjs' import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parse as parseJsonc } from 'jsonc-parser' @@ -153,7 +154,9 @@ describe('PR E2E gate contract', () => { it('uses one runner for changed specs and keeps full runs sharded', () => { expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''") - expect(e2eWorkflow.jobs['changed-e2e'].if).toBe("inputs.test_files != ''") + expect(e2eWorkflow.jobs['changed-e2e'].if).toBe( + "inputs.test_files != '' && (github.event_name != 'pull_request' || inputs.run_changed_e2e)" + ) expect(e2eWorkflow.jobs['changed-e2e'].strategy).toBeUndefined() expect(e2eWorkflow.jobs.e2e.strategy.matrix.include).toEqual( Array.from({ length: 14 }, (_, index) => ({ @@ -165,12 +168,12 @@ describe('PR E2E gate contract', () => { (step) => step.name === 'Run changed E2E specs' ) expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}') - expect(changedRun.run).toContain('. != "tests/e2e/ssh-startup-exec-readiness.spec.ts"') - expect(changedRun.run).toContain('. != "tests/e2e/paired-startup-exec-readiness.spec.ts"') - expect(changedRun.run).toContain( - '. != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts"' + expect(DEDICATED_E2E_SPECS).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts') + expect(DEDICATED_E2E_SPECS).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts') + expect(DEDICATED_E2E_SPECS).toContain( + 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts' ) - expect(changedRun.run).toContain('. != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts"') + expect(DEDICATED_E2E_SPECS).toContain('tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') expect(changedRun.run).toContain('if [ "${#TEST_FILES[@]}" -eq 0 ]') expect(changedRun.run).toContain('grep -l \'@headful\' "${TEST_FILES[@]}"') expect(changedRun.run).toContain('E2E_PROJECT_ARGS+=(--project=electron-headful)') @@ -695,10 +698,7 @@ describe('PR E2E gate contract', () => { }) it('keeps the native IME spec out of the lane that would silently skip it', () => { - const changedRun = e2eWorkflow.jobs['changed-e2e'].steps.find( - (step) => step.name === 'Run changed E2E specs' - ) - expect(changedRun.run).toContain('. != "tests/e2e/terminal-ibus-hangul-native.spec.ts"') + expect(DEDICATED_E2E_SPECS).toContain('tests/e2e/terminal-ibus-hangul-native.spec.ts') // Why it still has to be routed: the dedicated lane is selected by the same route, so the // spec appearing in test_files is how a spec-only edit reaches the real-IME lane at all. expect(selectPrE2eSpecs(['src/shared/terminal-unicode-provider.ts'])).toContain( diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index d93b2901bb7..ac2be049bdf 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -300,6 +300,9 @@ describe('PR workflow parallelism', () => { steps.findIndex((step) => step.name === 'Install dependencies') ) expect(steps[restoreIndex].uses).toBe('actions/cache/restore@v5') + expect(steps[restoreIndex].if).toBe( + "github.event_name == 'pull_request' && (runner.os != 'Windows' || runner.arch != 'X64' || !contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml'))" + ) }) it('uses the repository package-manager version for every direct pnpm setup', () => { @@ -359,7 +362,7 @@ describe('PR workflow parallelism', () => { 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'") + ).toBe("runner.os == 'Linux' && inputs.native-runtime == 'node'") const dependencyInstall = dependencyAction.runs.steps.find( (step) => step.name === 'Install dependencies' ) diff --git a/config/scripts/project-renderer-web-client.mjs b/config/scripts/project-renderer-web-client.mjs index 1a5c4490544..c7999358cbf 100644 --- a/config/scripts/project-renderer-web-client.mjs +++ b/config/scripts/project-renderer-web-client.mjs @@ -20,6 +20,7 @@ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) const selectedFiles = new Set(['web-index.html']) const visitedEntries = new Set() const PDFJS_VIEWER_ASSET_DIRS = ['cmaps', 'standard_fonts', 'wasm'] +const TEXT_REFERENCE_OUTPUT = /\.(?:css|html|m?js|svg)$/ function assertEntryIsolation() { const entryKeys = new Set( @@ -96,26 +97,37 @@ function listOutputFiles(directory, prefix = '') { function includeReferencedOutputs() { const candidates = listOutputFiles(rendererOutput).filter( - (outputPath) => !outputPath.startsWith('.vite/') && !outputPath.endsWith('.html') + (outputPath) => + !outputPath.startsWith('.vite/') && + !outputPath.endsWith('.html') && + !selectedFiles.has(outputPath) && + // Viewer binaries are copied unconditionally and cannot extend the reference closure. + (TEXT_REFERENCE_OUTPUT.test(outputPath) || + !PDFJS_VIEWER_ASSET_DIRS.some((directory) => outputPath.startsWith(`${directory}/`))) ) - let foundReference = true + const referencesByDirectory = new Map() - while (foundReference) { - foundReference = false - for (const selectedFile of selectedFiles) { - if (!/\.(?:css|html|m?js|svg)$/.test(selectedFile)) { + // Set iteration also visits newly discovered files, including transitive references. + for (const selectedFile of selectedFiles) { + if (!TEXT_REFERENCE_OUTPUT.test(selectedFile)) { + continue + } + const directory = posix.dirname(selectedFile) + let references = referencesByDirectory.get(directory) + if (!references) { + references = candidates.map((candidate) => ({ + candidate, + localReference: posix.relative(directory, candidate) + })) + referencesByDirectory.set(directory, references) + } + const contents = readFileSync(join(rendererOutput, selectedFile), 'utf8') + for (const { candidate, localReference } of references) { + if (selectedFiles.has(candidate)) { continue } - const contents = readFileSync(join(rendererOutput, selectedFile), 'utf8') - for (const candidate of candidates) { - if (selectedFiles.has(candidate)) { - continue - } - const localReference = posix.relative(posix.dirname(selectedFile), candidate) - if (contents.includes(candidate) || contents.includes(localReference)) { - selectedFiles.add(candidate) - foundReference = true - } + if (contents.includes(candidate) || contents.includes(localReference)) { + selectedFiles.add(candidate) } } } diff --git a/config/scripts/project-renderer-web-client.test.mjs b/config/scripts/project-renderer-web-client.test.mjs index 49bc0da7c5e..188da677924 100644 --- a/config/scripts/project-renderer-web-client.test.mjs +++ b/config/scripts/project-renderer-web-client.test.mjs @@ -1,12 +1,22 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' -import { spawnSync } from 'node:child_process' +import { runProcess } from '../../src/shared/child-process/run-process' import { afterEach, describe, expect, it } from 'vitest' const scriptPath = resolve('config/scripts/project-renderer-web-client.mjs') const temporaryRoots = [] +function projectFixture(root) { + return runProcess({ + program: process.execPath, + args: [scriptPath], + cwd: root, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 30000 + }) +} + function writeFixtureFile(root, relativePath, contents) { const targetPath = join(root, relativePath) mkdirSync(dirname(targetPath), { recursive: true }) @@ -70,14 +80,11 @@ describe('renderer web client projection', () => { expect(builderConfig).toContain("'!out/renderer/.vite{,/**/*}'") }) - it('copies and minifies only the web dependency closure', () => { + it('copies and minifies only the web dependency closure', async () => { const root = createRendererFixture() - const result = spawnSync(process.execPath, [scriptPath], { - cwd: root, - encoding: 'utf8' - }) + const result = await projectFixture(root) - expect(result.status, result.stderr).toBe(0) + expect(result.code, result.stderr).toBe(0) expect(result.stdout).toContain('Projected web client: 7 files') expect(existsSync(join(root, 'out/web/web-index.html'))).toBe(true) expect(existsSync(join(root, 'out/web/assets/editor.worker-fixture.js'))).toBe(true) @@ -87,31 +94,114 @@ describe('renderer web client projection', () => { expect(readFileSync(join(root, 'out/web/assets/web.css'), 'utf8')).toBe('.root{color:red}\n') }) - it('fails when the renderer manifest omits the web entry', () => { + it('follows transitive relative references and cycles with the existing substring behavior', async () => { + const root = createRendererFixture() + writeFixtureFile( + root, + 'out/renderer/assets/editor.worker-fixture.js', + 'const referenced = "../workers/one.mjs"; const overlapping = "assets/token.png.backup";' + ) + writeFixtureFile(root, 'out/renderer/workers/one.mjs', 'const icon = "../icons/link.svg";') + writeFixtureFile(root, 'out/renderer/icons/link.svg', '') + writeFixtureFile(root, 'out/renderer/assets/token.png', 'substring-token') + writeFixtureFile(root, 'out/renderer/assets/token.png.backup', 'longer-token') + writeFixtureFile(root, 'out/renderer/workers/unreferenced.mjs', 'export const absent = true;') + + const result = await projectFixture(root) + + expect(result.code, result.stderr).toBe(0) + for (const file of [ + 'workers/one.mjs', + 'icons/link.svg', + 'assets/token.png', + 'assets/token.png.backup' + ]) { + expect(existsSync(join(root, 'out/web', file)), file).toBe(true) + } + expect(readFileSync(join(root, 'out/web/assets/token.png'), 'utf8')).toBe('substring-token') + expect(existsSync(join(root, 'out/web/workers/unreferenced.mjs'))).toBe(false) + }) + + it('retains all PDFJS viewer asset directories even without textual references', async () => { + const root = createRendererFixture() + const assets = ['cmaps/fixture.bcmap', 'standard_fonts/fixture.pfb', 'wasm/fixture.wasm'] + for (const file of assets) { + writeFixtureFile(root, `out/renderer/${file}`, Buffer.from([0, 255, 42])) + } + + const result = await projectFixture(root) + + expect(result.code, result.stderr).toBe(0) + for (const file of assets) { + expect(readFileSync(join(root, 'out/web', file))).toEqual(Buffer.from([0, 255, 42])) + } + }) + + it('follows referenced PDFJS text assets without expanding unreferenced viewer modules', async () => { + const root = createRendererFixture() + writeFixtureFile( + root, + 'out/renderer/assets/web-entry.js', + 'const viewer = "../wasm/viewer.mjs";' + ) + writeFixtureFile( + root, + 'out/renderer/wasm/viewer.mjs', + 'const image = "../assets/viewer-image.png";' + ) + writeFixtureFile( + root, + 'out/renderer/wasm/unused.mjs', + 'const unused = "../assets/unreferenced-image.png";' + ) + writeFixtureFile(root, 'out/renderer/assets/viewer-image.png', 'viewer-image') + writeFixtureFile(root, 'out/renderer/assets/unreferenced-image.png', 'unreferenced-image') + + const result = await projectFixture(root) + + expect(result.code, result.stderr).toBe(0) + expect(existsSync(join(root, 'out/web/wasm/viewer.mjs'))).toBe(true) + expect(existsSync(join(root, 'out/web/wasm/unused.mjs'))).toBe(true) + expect(readFileSync(join(root, 'out/web/assets/viewer-image.png'), 'utf8')).toBe('viewer-image') + expect(existsSync(join(root, 'out/web/assets/unreferenced-image.png'))).toBe(false) + }) + + it.each(['../outside.js', '/absolute.js', 'C:/drive.js', 'assets\\backslash.js'])( + 'rejects invalid manifest output paths: %s', + async (outputPath) => { + const root = createRendererFixture() + const manifestPath = join(root, 'out/renderer/.vite/manifest.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + manifest['web-index.html'].file = outputPath + writeFileSync(manifestPath, JSON.stringify(manifest)) + + const result = await projectFixture(root) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('Invalid renderer output path:') + expect(readFileSync(join(root, 'out/web/stale.js'), 'utf8')).toBe('stale') + } + ) + + it('fails when the renderer manifest omits the web entry', async () => { const root = createRendererFixture() writeFixtureFile(root, 'out/renderer/.vite/manifest.json', '{}') - const result = spawnSync(process.execPath, [scriptPath], { - cwd: root, - encoding: 'utf8' - }) + const result = await projectFixture(root) - expect(result.status).toBe(1) + expect(result.code).toBe(1) expect(result.stderr).toContain('Renderer manifest is missing entry: web-index.html') }) - it('rejects renderer entries that execute another entry root', () => { + it('rejects renderer entries that execute another entry root', async () => { const root = createRendererFixture() const manifestPath = join(root, 'out/renderer/.vite/manifest.json') const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) manifest['web-index.html'].dynamicImports.push('index.html') writeFileSync(manifestPath, JSON.stringify(manifest)) - const result = spawnSync(process.execPath, [scriptPath], { - cwd: root, - encoding: 'utf8' - }) + const result = await projectFixture(root) - expect(result.status).toBe(1) + expect(result.code).toBe(1) expect(result.stderr).toContain('Renderer entry web-index.html executes entry index.html') }) }) diff --git a/config/scripts/run-daemon-shutdown-descendants-docker.mjs b/config/scripts/run-daemon-shutdown-descendants-docker.mjs index 30af813f7bd..2f558d9a8c3 100644 --- a/config/scripts/run-daemon-shutdown-descendants-docker.mjs +++ b/config/scripts/run-daemon-shutdown-descendants-docker.mjs @@ -115,7 +115,17 @@ try { bundles.unshift(['baseline', baseline]) } - runDocker(['build', '--platform', platform, '-t', image, dockerDir]) + runDocker([ + 'build', + '--platform', + platform, + ...(process.env.ORCA_DAEMON_SHUTDOWN_FIXTURE_CACHE_IMAGE + ? ['--cache-from', process.env.ORCA_DAEMON_SHUTDOWN_FIXTURE_CACHE_IMAGE] + : []), + '-t', + image, + dockerDir + ]) for (const [mode, bundlePath] of bundles) { const result = runDocker( [ diff --git a/config/scripts/ssh-browser-e2e-routing.test.mjs b/config/scripts/ssh-browser-e2e-routing.test.mjs index a36df63dfd9..d7abce7d39b 100644 --- a/config/scripts/ssh-browser-e2e-routing.test.mjs +++ b/config/scripts/ssh-browser-e2e-routing.test.mjs @@ -1,3 +1,4 @@ +import { DEDICATED_E2E_SPECS } from './ci-e2e-job-selection.mjs' import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parse } from 'yaml' @@ -22,7 +23,8 @@ it('routes SSH browser specs to a lane that enables their opt-ins', () => { expect(runner).toContain(`'${spec}'`) expect(runner).toContain(`${flag}: '1'`) expect(workflow.jobs['ssh-docker-watcher-isolation'].if).toContain(spec) - expect(changedRun.run).toContain(`. != "${spec}"`) + expect(DEDICATED_E2E_SPECS).toContain(spec) + expect(changedRun.run).toContain('node config/scripts/ci-e2e-job-selection.mjs') } }) @@ -42,9 +44,7 @@ it('executes both Docker network routes in a Node job with their opt-in enabled' expect(run.env.ORCA_RUN_DOCKER_SSH_BROWSER_E2E).toBe('1') expect(run.run).toContain(`vitest run --config config/vitest.config.ts ${spec}`) expect(run['continue-on-error']).toBeUndefined() - expect( - workflow.jobs['changed-e2e'].steps.find((step) => step.name === 'Run changed E2E specs').run - ).toContain(`. != "${spec}"`) + expect(DEDICATED_E2E_SPECS).toContain(spec) for (const changed of [ spec, 'src/main/browser/ssh-browser-network-execution-route.ts', diff --git a/config/scripts/ssh-docker-ci-sharding.test.mjs b/config/scripts/ssh-docker-ci-sharding.test.mjs index 403c059e92c..321e1c6ad34 100644 --- a/config/scripts/ssh-docker-ci-sharding.test.mjs +++ b/config/scripts/ssh-docker-ci-sharding.test.mjs @@ -1,3 +1,4 @@ +import { DEDICATED_E2E_SPECS } from './ci-e2e-job-selection.mjs' import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' @@ -22,7 +23,8 @@ it('gives every removed SSH spec a dedicated owner even for test-only edits', () const changedRun = workflow.jobs['changed-e2e'].steps.find( (step) => step.name === 'Run changed E2E specs' ).run - const excluded = [...changedRun.matchAll(/\. != "([^"]+)"/g)].map((match) => match[1]) + expect(changedRun).toContain('node config/scripts/ci-e2e-job-selection.mjs') + const excluded = DEDICATED_E2E_SPECS const owned = runners.flatMap(runnerSpecs) expect(owned.length).toBeGreaterThan(25) expect(new Set(owned).size).toBe(owned.length) diff --git a/config/scripts/ssh-localhost-e2e-routing.test.mjs b/config/scripts/ssh-localhost-e2e-routing.test.mjs index b400e86153c..48335cd969f 100644 --- a/config/scripts/ssh-localhost-e2e-routing.test.mjs +++ b/config/scripts/ssh-localhost-e2e-routing.test.mjs @@ -1,3 +1,4 @@ +import { DEDICATED_E2E_SPECS } from './ci-e2e-job-selection.mjs' import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { parse } from 'yaml' @@ -30,9 +31,7 @@ it('gives the localhost SSH journey its same-filesystem server and agent prerequ expect(run.run).toContain('--project=electron-headless') expect(run.run).not.toContain('--retries') expect(run['continue-on-error']).toBeUndefined() - expect( - workflow.jobs['changed-e2e'].steps.find((step) => step.name === 'Run changed E2E specs').run - ).toContain(`. != "${spec}"`) + expect(DEDICATED_E2E_SPECS).toContain(spec) }) it('selects the localhost journey for its remote hook authorities', () => { diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 30e51b7712c..d6e4df47737 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -5,6 +5,208 @@ unit-selection evidence, headless runtime qualification, review cancellation and ## October 1 PR concurrency follow-up +### Where the next gains are + +The [September 30 demand report](https://github.com/stablyai/orca/actions/runs/36816009362) +samples 282 of 4,194 runs across workflow/conclusion strata. It estimates full job +duration for runs created in the reporting window, rather than occupancy clipped +to that window. PR CI accounts for about 537 runner-hours, including about 321 +hours of unit shards, 73 hours of E2E, 41 hours of Windows packaging, 35 hours of +Linux packaging, 18 hours of static analysis, and 11 hours of typechecking. These +are weighted estimates, not exact billing totals. Unassigned and incomplete jobs +are excluded. The report predates the merged planning/setup change below. + +The [full reference run](https://github.com/stablyai/orca/actions/runs/36841821670) +ran 10,310 files exactly once. Across its five shards Vitest reports about 5,063 +worker-seconds importing modules, 2,838 running tests, 685 transforming source, +267 setting up tests, and 390 preparing environments. Workers overlap, so these +figures cannot be added to predict job elapsed time. They identify repeated +imports and real-time test waits as larger targets than line-count reporting, +which uses about 1.1 runner-hours in the same demand sample. + +Parallel steps share the job's CPU and memory. Their +[background/wait support](https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/) +saves repeated runner setup when independent checks fit together, but does not +increase machine resources or the account's concurrent-job allowance. Cheap +preflight checks still gate expensive unit and package jobs. + +Organization metadata reported the Team plan on October 1. GitHub documents +[60 standard concurrent jobs by default](https://docs.github.com/en/actions/reference/limits#job-concurrency-limits-for-github-hosted-runners) +and allows support requests for increases. The effective configured allowance +was not exposed by the API. A seven-second, repository-only sample at 10:48 UTC +found 32 queued jobs and 70 assigned job records marked in progress, including +one Blacksmith label. Those non-atomic records, runner turnover, other repositories +and provider labels cannot establish the actual allowance or simultaneous usage. +These changes reduce demand; they do not change account settings. If queues +remain, ask GitHub Support to confirm the effective organization limit before +choosing a larger allowance or paid runners. + +The [follow-up PR](https://github.com/stablyai/orca/pull/24355) measures virtual +readiness deadlines in captured-transcript tests, E2E allocations with no general +consumer, renderer projection, native setup, Docker fixtures, and Windows store +restoration. Alternating hosted comparisons check output parity. Completed +benchmark workflows and drivers are removed; their trial commits retain the +exact reproduction code. + +A [three-pair hosted transcript comparison](https://github.com/stablyai/orca/actions/runs/36849458799) +on one four-worker ARM runner measured baseline invocations at 127.001 / 114.307 / +114.265 seconds, versus 28.903 / 28.663 / 28.455 seconds with virtual readiness +deadlines. Median elapsed time for these five files fell about 75%. All 259 +original named tests passed in every baseline and candidate, and candidates +also passed three repaint checks. Summed test-body time fell from a median 325.85 +to 31.80 worker-seconds. This comparison includes Vitest startup/import work but +excludes checkout, dependency setup, and queues; it is not a measured percentage +improvement in the full unit matrix. Real emulator setup and drains remain real, +and the same captured bytes, readiness/refusal deadlines, and assertions run. + +The same hosted trial passed three forced Electron native rebuilds while the +external node-gyp path pointed to a nonexistent file. A separate fresh consumer +then restored the native cache, required a real hit, and passed the existing +Electron binary probe. The Linux Node-runtime workaround remains intact. + +For a network-only E2E selection in that trial, both dedicated network jobs +passed. The previous allocations consumed 87 seconds for the Electron build, +34 for the native primer, and 67 for a general job whose log confirmed that +every selected spec belonged to a dedicated lane. The candidate skipped those +three jobs before runner allocation, avoiding 188 runner-seconds in this case. +This single-case measurement excludes queues and does not predict savings for +mixed selections; the existing dedicated SSH, IME, and ordinary E2E routes remain. + +A [controlled cancellation trial](https://github.com/stablyai/orca/actions/runs/36853246785) +verified both condition outcomes. After intentional cancellation, `always()` +started another 60-second follow-up, while `!cancelled()` skipped it. Cleanup +and artifact uploads succeeded in both treatments. A separate deliberately +failed test still ran its later `!cancelled()` test and cleanup. The 60 seconds +are synthetic condition evidence, not a measurement of a real SSH test's cost. +Completed comparison workflows are removed after recording their evidence; +the exact drivers and workflow remain reproducible at the trial's source commit. + +The [first full PR validation](https://github.com/stablyai/orca/actions/runs/36849458648) +passed all five unit shards and both Linux/Windows package checks. Its reports +contain 10,326 unique files, each once, with zero unhandled errors. Full shard +job durations ranged from 544 to 581 seconds. They ran a different merged source +on different allocations from the earlier reference, so comparing their totals +does not establish an end-to-end speedup. The alternating transcript comparison +above is the controlled timing evidence. + +The [updated full PR validation](https://github.com/stablyai/orca/actions/runs/36855833565) +passed all required gates and both package checks. All 10,335 discovered files +appear once across five passing timing reports, with zero unhandled errors; +122 modules have the existing expected skipped status. Named merge-tree discovery +and saved assignment replay match both successful validation runs. The latest +unit jobs took 520–558 seconds. Refreshing weights with that same measurement set +would reduce the largest projected load from 1,756.706 to 1,708.187 worker-seconds +(2.76%), while retaining the same 8,540.831 total. Applying the earlier successful +run's proposed weights to the latest measurements improves the maximum only +1.54% and the median 0.68%. These small, variable projections do not establish +an elapsed-time gain, so the existing weights remain. + +A [three-pair Windows store comparison](https://github.com/stablyai/orca/actions/runs/36853246494) +used fresh dependency trees, stores and pnpm metadata before each treatment. The +middle pair reversed order. Cached totals include archive restoration and both +unchanged frozen installs; the mobile install ran every existing postinstall +generator. Setup/reset time and runner queues are excluded. + +| Pair | First treatment | Cached total | Registry total | Registry saving | +| ---- | --------------- | ------------ | -------------- | --------------- | +| 1 | Cached | 71.092s | 37.500s | 33.592s | +| 2 | Registry | 73.556s | 39.935s | 33.621s | +| 3 | Cached | 75.660s | 37.001s | 38.659s | + +Treatment medians were 73.556s cached and 37.500s registry; median paired saving +was 33.621s. Cache restore and step overhead alone cost a median 27.955s. Policy +and all six generated-output digests matched across all six treatments. Windows +x64 PR jobs using the mixed root/mobile key now skip its download-store restore; +PRs already skip store saves. Root-only Windows stores, Windows ARM64/x86, other +operating systems, non-PR writers, native/Electron caches and frozen-install policy retain their existing +behavior. The trial covers this mixed install on Windows 2022, not every Windows +dependency key or a whole PR's elapsed time. + +A [three-pair daemon fixture comparison](https://github.com/stablyai/orca/actions/runs/36853246776) +reset Docker build caches and the fixture/base image before every treatment. +Warm totals include the existing action's archive restore/load and the unchanged +daemon descendant oracle; reset time and runner queues are excluded. The middle +pair again reversed order. + +| Pair | First treatment | Cold total | Warm total | Warm saving | +| ---- | --------------- | ---------- | ---------- | ----------- | +| 1 | Cold | 28.984s | 20.673s | 8.311s | +| 2 | Warm | 21.798s | 28.953s | -7.155s | +| 3 | Cold | 28.964s | 17.614s | 11.350s | + +Treatment medians were 28.964s cold and 20.673s warm; median paired saving was +8.311s. Oracle medians fell from 28.886s to 4.907s, while archive restore/load +cost 13.025–24.024s (15.766s median) for a 714,643,456-byte archive. BuildKit +confirmed warm provisioning was cached and cold provisioning was not; every +treatment used the same immutable base image, reaped the descendant and kept +the canary alive. The PR keeps restoration in the background during root/mobile +installation. One serial pair was slower, so the roughly 24s oracle reduction +is not a guaranteed total runner saving. The drivers and exact workflows remain +available at source commit `9231d1be6c76ccc1d2fef741a4e68ae29735a5c8`. + +A [three-pair AppImage compression comparison](https://github.com/stablyai/orca/actions/runs/36855833100) +packaged the same complete Linux x64 app with the pinned 1.0.3 toolset and +mksquashfs 4.6.1. Each timing includes the private app copy, electron-builder +and blockmap generation. Tool download, app compilation, extraction, parity +checks and queues are excluded; the middle pair reversed order. + +| Pair | First treatment | Default zstd 15 | PR zstd 3 | Saving | +| ---- | --------------- | --------------- | --------- | ------- | +| 1 | Default | 22.580s | 11.760s | 10.820s | +| 2 | PR | 22.612s | 12.560s | 10.052s | +| 3 | Default | 22.512s | 11.815s | 10.697s | + +Median packaging time fell from 22.580s to 11.815s (47.7%); median package size +grew from 213,873,601 to 237,570,611 bytes (11.1%). All six extracted manifests +matched every path, byte, mode and symlink target: 4,096 files and 601,194,650 +file bytes. The runtime prefix matched the pinned runtime exactly, and stored +SquashFS options confirmed the actual level-3 override. All static checks passed; +the representative baseline and candidate each passed the unchanged headless +and CLI journeys, including all entrypoints and both shutdown signals. The +directory build passed the existing glibc floor checks on all 19 native binaries. + +Only the PR Linux x64 AppImage child receives the private tool overlay. It resolves +the existing custom-tool override first, checks the pinned tool/version and zstd +configuration, and reuses the original runtime, validator and libraries. Cleanup +waits for every package worker even on failure. Release settings, Linux ARM, +Debian/RPM packaging and all native/package gates retain their existing behavior. +The isolated AppImage result does not establish the full three-format job's gain. + +The same hosted comparison projected one already-built renderer three times per +treatment, again alternating order. Baseline times were 8.079 / 8.219 / 8.129s; +candidate times were 2.568 / 2.477 / 2.403s. Median projection fell from 8.129s to +2.477s (69.5%, 5.653s saved). All six web snapshots matched all 1,137 files and +51,957,014 bytes, and the renderer input remained unchanged after every run. +These timings include the projector process but exclude renderer compilation, +checkout, setup and queues. The drivers and workflow remain available at source +commit `8ba5c9bf9f734d585f5e89945519aef4f607face`. + +Two local cache screens do not justify enabling Node's compile cache. A 96-file +screen with an explicit worker flush produced a small, noisy difference. A larger +256-file screen retained all 2,088 tests: baseline elapsed times were +54.630 / 55.105 / 55.171 seconds, fresh caches 53.719 / 54.577, and a warm cache +53.732. The roughly 1.7% median difference is too small to justify cache transfer +and another test hook without stronger hosted evidence. + +Vitest 4.1.11's experimental filesystem module cache is more promising locally, +but raw reuse is unsafe. A 96-file screen fell from about 8.4 to 5.9 seconds with +a warm cache, while a cold cache cost about 3%. Negative controls then reproduced +false passes after adding a preferred import extension, retargeting a symlink, +changing package exports, or changing transform inputs. Cache-disabled controls +failed correctly. A cache key must cover resolution and transform inputs as well +as file contents before any production trial; source hashes alone do not suffice. + +Affected-test selection remains in shadow mode. Its first merge was September +28, so October 1 cannot satisfy the documented week of evidence. Seven sampled +complete reference reports included one red run, but only two evaluated a smaller +candidate set; each omitted about 177–179 worker-seconds out of 8,073–8,392. Five +full fallbacks are not selection-validation evidence, and two other sampled red +runs had no review artifact. These samples support keeping the conservative +policy, rather than claiming that omitting roughly 12% of files would omit the +same fraction of work. + +### Shared planning and typechecking + PR planning now shares checkout and dependency setup with typechecking. Planning runs in the background, with an explicit failure-propagating join before its artifact is published. Static analysis remains on a separate runner. Its existing @@ -590,3 +792,180 @@ only one potential idle allocation per day, and active development usually requires that build. Defer another release-graph change until skip frequency justifies it. The substantive remaining release occupancy opportunity is the separately documented asynchronous signing policy decision. + +## Persistent Vitest transform cache: rejected for now + +A local 96-file import-heavy sample with Vitest 4.1.11 took 8.31/8.53 seconds +without its filesystem module cache, 8.63/8.69 seconds cold, and 5.91/5.91 +seconds warm: about 30% faster warm. The cache held 3,770 modules and 83 MiB. +These timings exclude hosted cache transfer and do not establish a PR saving. + +Correctness probes found eight changes that incorrectly kept a test passing +against the old transformed import or compiler output: + +| Change after warming | Raw cache | Startup fingerprint | +| ----------------------------------------------------------------- | ---------- | ------------------- | +| Add preferred `value.js` beside previously resolved `value.ts` | False pass | Correctly fails | +| Retarget a source symlink while its old target still exists | False pass | Correctly fails | +| Change an inlined package's `exports` to another existing file | False pass | Correctly fails | +| Add a preferred extension in a generated source directory | False pass | Correctly fails | +| Change TypeScript's JSX factory in `tsconfig.json` | False pass | Correctly fails | +| Create the preferred file from setup after startup fingerprinting | False pass | False pass | +| Add a preferred file inside an external symlinked directory | False pass | False pass | +| Change an external file read by a transform plugin | False pass | False pass | + +The fingerprint included file names/types, symlink targets, package/config/ +TypeScript metadata contents, and effective alias/define options. Following +external symlink inventories and hashing declared transform inputs repaired the +last two rows, but did not repair files created after fingerprinting. All ten +cache-disabled changed-input controls failed correctly; initial and repeated +warm controls passed. Effective alias and simple define changes also invalidated +correctly without the added fingerprint. + +The [Vitest 4.1.11 documentation](https://github.com/vitest-dev/vitest/blob/v4.1.11/docs/config/experimental.md#known-issues) +documents incomplete plugin-input tracking. Its +[cache implementation](https://github.com/vitest-dev/vitest/blob/v4.1.11/packages/vitest/src/node/cache/fsModuleCache.ts) +hashes the module and selected configuration, but retains previously resolved +import URLs. [Upstream fix #11381](https://github.com/vitest-dev/vitest/pull/11381) +merged September 29 and revalidates those URLs. A disposable Vitest 5.0.3 probe, +which contains that fix, reproduced all eight false-pass categories: the old +target still exists, so checking its resolved URL misses a newly preferred file +or changed package export. Upgrading alone does not make reuse safe. + +To reproduce the simplest negative control outside the worktree: + +1. Create `value.ts` containing `export const value = 1`, and a test importing + `./value` and asserting `expect(value).toBe(1)`. Use an isolated config/cache, + one fork worker, `ORCA_BACKGROUND_LAUNCH=1`, and + `NODE_DISABLE_COMPILE_CACHE=1`. +2. Run the installed CLI with `--experimental.fsModuleCache=true` to warm it. + Add `value.js` containing `export const value = 2`; retain `value.ts` and the + unchanged test. The same cached invocation incorrectly passes. +3. Repeat with `--experimental.fsModuleCache=false`. The assertion correctly + fails. Vitest 5 uses `--fsModuleCache` for the equivalent controls. +4. For the startup-inventory control, use unchanged setup code that creates + `value.js` only when a runtime environment switch is enabled. Remove that + file before each invocation/fingerprint; warm with the switch off, then run + with it on. The cached importer still points at `value.ts`, while the fresh + module graph correctly fails. This is an additional persistent-cache error, + not a claim that normal in-process module reuse supports arbitrary mutation. + +Orca currently resolves only pinned Vitest/Vite built-in transform plugins. +Its setup files install runtime guards/shims and temporary user data, rather +than custom transforms. A narrower policy could cache only proven immutable +source/dependency inputs and leave tests, setup, virtual modules, external +fixtures, and unknown plugins cold. That requires a validated transitive input +boundary and mutation policy; hashing every source tree on each lookup would +also spend the gain. Until that policy and hosted transfer cost are measured, +the local warm result does not justify adding a persistent cache to CI. + +## Test fixture imports: reuse the existing narrow builder + +The pointer-drag test imported only `makeWorktree` from `store-test-helpers`, +which also loads the real store slices. Its existing identical export in +`worktrees-slice-test-fixtures` supplies the same defaults without that graph. +Changing this single import preserves the five tests, fork workers, +isolation, and disabled filesystem/Node compile caches. + +Three local interleaved before/after pairs took 2.066/2.047/2.031 seconds versus +0.351/0.388/0.353 seconds: the isolated median fell 82.7%, from 2.047 to 0.353 +seconds. Transformed modules fell from 1,086 to 15. This is an isolated test +result, not a whole-shard estimate: other tests need the store modules anyway. +A broader 20-file screening sample saved only 0.295 seconds at its median, +which does not justify splitting the fixture module across those consumers. + +Two further import-only reuses passed the same six-run controls. The kanban +lane test mocks its card component, so switching its builder import reduced +the isolated median from 2.202 to 0.495 seconds (77.5%) and transformed modules +from 1,082 to 13, with all six tests unchanged. The autosave fixture needs +the real editor slice, but not every store slice: the same import change across +its three consuming suites reduced the median from 3.027 to 1.301 seconds +(57.0%), with 1,107 to 320 modules and all 17 tests unchanged. These +results also measure isolated file groups; they are not additive shard savings. +The remaining inspected builder-only imports already load the full store as +their subject, or use builders whose defaults differ from existing exports. + +## Vitest threads: retain forks after the scoped pilot + +Three local interleaved comparisons kept four workers, `isolate: true`, both +persistent caches disabled, and the same test assertions/module graph. The +94-file happy-dom renderer cohort passed all 570 tests: forks took +17.480/17.382/17.657 seconds and threads 15.048/14.890/15.013 seconds, a 14.1% +median reduction. A 23-file shared JavaScript cohort passed all 248 tests, +with its median falling from 1.435 to 1.274 seconds (11.2%). No main-process +module or native addon loaded; guards reject native loading, `chdir`, and +process signals. These Mac/Node 24 timings motivated the hosted comparison. + +The [pinned Vitest pool documentation](https://github.com/vitest-dev/vitest/blob/v4.1.11/docs/config/pool.md) +defaults to forks and documents thread limitations around process APIs and +native libraries. [Node's worker documentation](https://nodejs.org/docs/latest-v24.x/api/worker_threads.html#new-workerfilename-options) +also excludes V8 flags from worker `execArgv`. Orca's `--expose-gc` worker flag +fails with `ERR_WORKER_INVALID_EXEC_ARGV` under threads. The experiment starts +both parent processes with that flag, retains it on fork workers, and removes +it only from thread worker arguments; GC availability is checked in every +test environment. Process, native, lifecycle, and GC-retention tests stay out +of this comparison. + +The [hosted pilot](https://github.com/stablyai/orca/actions/runs/36855833033) +passed on Linux ARM64, four CPUs, Node 24.21.0, and Ubuntu image +`20260927.135.1`. Three alternating pairs preserved source hashes and complete +module graphs, with no main-process module or native addon loaded: + +| Audited cohort | Forks, seconds | Threads, seconds | Median saving | +| -------------------------------------- | ------------------------ | ------------------------ | --------------------- | +| 94 renderer files / 570 tests | 48.591 / 48.144 / 47.299 | 42.486 / 42.402 / 42.221 | 5.742 seconds (11.9%) | +| 23 shared JavaScript files / 248 tests | 3.386 / 3.330 / 3.424 | 3.203 / 3.237 / 3.278 | 0.149 seconds (4.4%) | + +Each timed group also included two isolation sentinels: totals were 96 files / +572 tests and 25 files / 250 tests, respectively, with no skips. +Their graph hashes matched in every pair (4,015 and 251 modules). Separate +single-worker positive controls passed both sentinels in each pool. With +isolation disabled, the second sentinel correctly failed on leaked state. +Missing GC failed setup, and native loading, `chdir`, and process-signal probes +failed at the guard in both pools. Those expected failures did not pass silently. + +The fixed 117-file sample accounts for about 1.5% of the baseline's aggregate +module time; its isolated gains are not a whole-suite estimate. Maintaining +that exact file list for this benefit is not justified. A broader route needs +a safe eligibility policy, Node 26/Windows evidence, and a mixed full-shard +comparison: separate Vitest projects can repeat shared transforms and erase +the pool-startup saving. A renderer path alone does not prove that future +imports avoid process or native behavior. Production retains forks, and the +temporary workflow, driver, and cohort list were removed after measurement. + +## Oxlint scan consolidation: rejected + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/36855833063) +used one Linux ARM64/four-CPU runner, Node 24.21.0, and three alternating +baseline/candidate pairs. The baseline kept root lint and anti-slop in parallel, +then native and type-aware audits in parallel. The candidate merged the first +three scans and ran the unchanged type-aware audit alongside them. + +| Pair/order | Baseline stage | Candidate stage | Change | +| ------------------ | -------------- | --------------- | ------ | +| 1: baseline first | 49.409 s | 53.197 s | +7.7% | +| 2: candidate first | 50.249 s | 60.297 s | +20.0% | +| 3: baseline first | 50.040 s | 56.672 s | +13.3% | +| Median | 50.040 s | 56.672 s | +13.3% | + +These complete stage timings include anti-slop synchronization in both variants +and candidate configuration generation. Candidate preparation took only +0.141–0.255 seconds. The unchanged type-aware scan took 16.386–17.279 seconds +in the baseline wave, versus 33.561–55.736 seconds beside the merged scan; +these timings are consistent with contention on the four-CPU runner. + +The corrected local Mac/16-CPU comparison had reduced the median from 16.676 +to 14.381 seconds (13.8%). Both comparisons limited each Oxlint invocation to +four threads and used identical source configuration hashes. The hosted result +shows why the local gain did not justify adoption on the actual CI runner. + +Coverage controls passed: the merged scan matched the exact 28,621-file union +with no missing or extra files. Thirty-one fault fixture files produced the +exact 16-diagnostic union, including all seven active root JavaScript rules. +Eighteen focused controls preserved nested-mobile exemptions, type-aware +exclusions, and exit behavior. The native audit's warnings still failed its +original `--deny-warnings` gate and became errors in the merged scan; root +warnings remained non-fatal. Every full-repository scan passed cleanly. + +Keep the existing production waves. The temporary workflow and 601-line +benchmark driver were removed after recording this rejected result. diff --git a/src/main/runtime/agent-transcript-pane-test-harness.ts b/src/main/runtime/agent-transcript-pane-test-harness.ts index 14ad56a8d9d..8fa7701696a 100644 --- a/src/main/runtime/agent-transcript-pane-test-harness.ts +++ b/src/main/runtime/agent-transcript-pane-test-harness.ts @@ -91,3 +91,22 @@ export async function createTranscriptPane( } return { runtime, handle: terminal.handle } } + +/** Advance readiness deadlines after real pane creation and emulator drains. */ +export async function waitForTranscriptIdle( + pane: Awaited>, + timeoutMs: number +) { + vi.useFakeTimers({ + toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] + }) + try { + const waiting = pane.runtime.waitForTerminal(pane.handle, { condition: 'tui-idle', timeoutMs }) + // Expected refusals must have a rejection handler before advancing their deadline. + void waiting.catch(() => {}) + await vi.advanceTimersByTimeAsync(timeoutMs) + return await waiting + } finally { + vi.useRealTimers() + } +} diff --git a/src/main/runtime/codex-header-readiness-transcripts.test.ts b/src/main/runtime/codex-header-readiness-transcripts.test.ts index ea1cfe3b7f0..7ebc7a5d2b8 100644 --- a/src/main/runtime/codex-header-readiness-transcripts.test.ts +++ b/src/main/runtime/codex-header-readiness-transcripts.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID, + waitForTranscriptIdle +} from './agent-transcript-pane-test-harness' import { readRuntimeFixture, replayTranscript, @@ -200,13 +204,15 @@ describe('Codex 0.157 header readiness from captured bytes', () => { describe('through the runtime', () => { async function codexPane(name: string, size?: { cols: number; rows: number }) { - return createTranscriptPane({ + const created = await createTranscriptPane({ paneTitle: 'Terminal', foregroundProcess: 'codex', launchAgent: 'codex', data: readRuntimeFixture(name), size }) + await created.runtime.readTerminal(created.handle, { screen: true }) + return created } it.each(ALL_FIXTURES)( @@ -214,9 +220,10 @@ describe('Codex 0.157 header readiness from captured bytes', () => { async (name) => { const { runtime, handle } = await codexPane(name, { cols: 120, rows: 40 }) // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 8_000 }) - ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) }, 15_000 ) @@ -235,10 +242,9 @@ describe('Codex 0.157 header readiness from captured bytes', () => { data: provisional, size: { cols: 120, rows: 40 } }) + await runtime.readTerminal(handle, { screen: true }) // Why 6s: past the 3s quiescence, so the quiet lane's provisional veto is what holds. - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 6_000 }) - ).rejects.toThrow(/timeout/) + await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) }, 15_000) // Why no bytes: worker-start waits on a pane that has printed nothing yet, which is when the @@ -251,6 +257,7 @@ describe('Codex 0.157 header readiness from captured bytes', () => { data: '', size: { cols: 120, rows: 40 } }) + await runtime.readTerminal(handle, { screen: true }) const readVisibleScreen = vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ handle, status: 'running', @@ -267,9 +274,7 @@ describe('Codex 0.157 header readiness from captured bytes', () => { nextCursor: null, source: 'screen' }) - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) - ).rejects.toThrow(/timeout/) + await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).rejects.toThrow(/timeout/) // Presence precondition: the visible-screen probe actually ran. expect(readVisibleScreen).toHaveBeenCalled() }, 15_000) @@ -282,6 +287,7 @@ describe('Codex 0.157 header readiness from captured bytes', () => { launchAgent: 'codex', data: '' }) + await runtime.readTerminal(handle, { screen: true }) runtime.seedTerminalRestoreTail(TRANSCRIPT_PANE_PTY_ID, { text: [ '╭──────────────────────────────────────────╮', @@ -302,16 +308,15 @@ describe('Codex 0.157 header readiness from captured bytes', () => { nextCursor: null, source: 'screen-unavailable' }) - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) - ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) }, 15_000) it('keeps timing out on the garbled 80x24 default grid, as before', async () => { const { runtime, handle } = await codexPane(EFFORT_OVERRIDE) - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 6_000 }) - ).rejects.toThrow(/timeout/) + await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) }, 15_000) }) }) diff --git a/src/main/runtime/codex-quiet-ready-screen.test.ts b/src/main/runtime/codex-quiet-ready-screen.test.ts index 97f63f0db27..051a086eb38 100644 --- a/src/main/runtime/codex-quiet-ready-screen.test.ts +++ b/src/main/runtime/codex-quiet-ready-screen.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TuiAgent } from '../../shared/tui-agent' -import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' import { readRuntimeFixture, replayTranscript, @@ -435,21 +435,24 @@ describe('through the runtime', () => { // Why 0.158 alone: its header carries no `model:`, so only this lane settles it. const CODEX_0158 = 'codex-0-158-0-timed-turn' async function pane(name: string, launchAgent: TuiAgent, size?: { cols: number; rows: number }) { - return createTranscriptPane({ + const created = await createTranscriptPane({ paneTitle: 'Terminal', foregroundProcess: launchAgent, launchAgent, data: readRuntimeFixture(name), size }) + await created.runtime.readTerminal(created.handle, { screen: true }) + return created } // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. it('codex 0.158: a tui-idle wait settles once the composer is quiet', async () => { const { runtime, handle } = await pane(CODEX_0158, 'codex', { cols: 120, rows: 40 }) - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 8_000 }) - ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) }, 15_000) it('keeps a Claude pane showing the same screen pending', async () => { @@ -457,8 +460,6 @@ describe('through the runtime', () => { cols: 120, rows: 40 }) - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 6_000 }) - ).rejects.toThrow(/timeout/) + await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) }, 15_000) }) diff --git a/src/main/runtime/screen-ruled-agent-transcript-suite.ts b/src/main/runtime/screen-ruled-agent-transcript-suite.ts index b8ffca8b0cb..57a63107861 100644 --- a/src/main/runtime/screen-ruled-agent-transcript-suite.ts +++ b/src/main/runtime/screen-ruled-agent-transcript-suite.ts @@ -1,6 +1,10 @@ // One replay suite for every agent whose readiness its live screen decides (terminal-wait-detection.ts). import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID, + waitForTranscriptIdle +} from './agent-transcript-pane-test-harness' import { finalReadProjection, finalReplayFrame, @@ -96,8 +100,11 @@ export function describeScreenRuledAgentTranscripts(suite: ScreenRuledAgentSuite async (fixture) => { const { runtime, handle } = await pane(fixture) await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: READY_TIMEOUT_MS }) - ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + waitForTranscriptIdle({ runtime, handle }, READY_TIMEOUT_MS) + ).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) }, READY_TIMEOUT_MS + PANE_SETUP_SLACK_MS ) @@ -106,9 +113,9 @@ export function describeScreenRuledAgentTranscripts(suite: ScreenRuledAgentSuite '$name: a tui-idle wait does not settle ready', async (fixture) => { const { runtime, handle } = await pane(fixture) - const result = await runtime - .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: REFUSAL_TIMEOUT_MS }) - .catch((error: unknown) => ({ satisfied: false, error: String(error) })) + const result = await waitForTranscriptIdle({ runtime, handle }, REFUSAL_TIMEOUT_MS).catch( + (error: unknown) => ({ satisfied: false, error: String(error) }) + ) expect(result.satisfied).toBe(false) }, REFUSAL_TIMEOUT_MS + PANE_SETUP_SLACK_MS @@ -128,9 +135,9 @@ export function describeScreenRuledAgentTranscripts(suite: ScreenRuledAgentSuite const { runtime, handle } = await createTranscriptPane(options) await runtime.readTerminal(handle, { screen: true }) options.size = { cols: fixture.cols, rows: fixture.rows } - const result = await runtime - .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: READY_TIMEOUT_MS }) - .catch(() => ({ satisfied: false })) + const result = await waitForTranscriptIdle({ runtime, handle }, READY_TIMEOUT_MS).catch( + () => ({ satisfied: false }) + ) expect(result.satisfied).toBe(suite.readyWithoutScreen.includes(fixture.name)) }, READY_TIMEOUT_MS + PANE_SETUP_SLACK_MS @@ -157,9 +164,9 @@ export function describeScreenRuledAgentTranscripts(suite: ScreenRuledAgentSuite // Why: an echo of the reflowed size sends no SIGWINCH, so nothing repaints. runtime.onExternalPtyResize(TRANSCRIPT_PANE_PTY_ID, firstReady.cols, firstReady.rows) await runtime.readTerminal(handle, { screen: true }) - const result = await runtime - .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: READY_TIMEOUT_MS }) - .catch(() => ({ satisfied: false })) + const result = await waitForTranscriptIdle({ runtime, handle }, READY_TIMEOUT_MS).catch( + () => ({ satisfied: false }) + ) expect(result.satisfied).toBe(suite.readyWithoutScreen.includes(firstReady.name)) }, READY_TIMEOUT_MS + PANE_SETUP_SLACK_MS @@ -184,9 +191,9 @@ export function describeScreenRuledAgentTranscripts(suite: ScreenRuledAgentSuite nextCursor: null, source: 'screen' }) - const result = await runtime - .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) - .catch(() => ({ satisfied: false })) + const result = await waitForTranscriptIdle({ runtime, handle }, 2_500).catch(() => ({ + satisfied: false + })) // Presence precondition: the visible-screen probe actually ran. expect(read).toHaveBeenCalled() return result.satisfied === true @@ -207,6 +214,33 @@ export function describeScreenRuledAgentTranscripts(suite: ScreenRuledAgentSuite }, 15_000 ) + + it('waits for quiet again after a ready pane repaints', async () => { + const { runtime, handle } = await pane(firstReady) + vi.useFakeTimers({ + toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] + }) + try { + const settled = vi.fn() + const waiting = runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: READY_TIMEOUT_MS + }) + void waiting.then(settled, () => {}) + await vi.advanceTimersByTimeAsync(2_000) + expect(settled).not.toHaveBeenCalled() + + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, readRuntimeFixture(firstReady.name), Date.now()) + await runtime.readTerminal(handle, { screen: true }) + await vi.advanceTimersByTimeAsync(2_000) + expect(settled).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(2_000) + await expect(waiting).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + } finally { + vi.useRealTimers() + } + }) }) it('never reads the screen for another agent', async () => { diff --git a/src/main/runtime/screen-ruled-readiness-scope.test.ts b/src/main/runtime/screen-ruled-readiness-scope.test.ts index ea48459a4ba..af3fd48ae44 100644 --- a/src/main/runtime/screen-ruled-readiness-scope.test.ts +++ b/src/main/runtime/screen-ruled-readiness-scope.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' import { readRuntimeFixture } from './agent-transcript-replay-test-harness' vi.mock('electron', () => ({ @@ -24,8 +24,9 @@ describe('screen-rule trust stays with screen-ruled agents', () => { const { runtime, handle } = await createTranscriptPane(options) await runtime.readTerminal(handle, { screen: true }) options.size = { cols: 100, rows: 30 } - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 8_000 }) - ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) }, 20_000) }) diff --git a/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts b/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts index 3843b3d6a4c..4bb93ede790 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts @@ -5,7 +5,7 @@ import { vi } from 'vitest' import { createStore, type StoreApi } from 'zustand/vanilla' import { createEditorSlice } from '@/store/slices/editor' import type { AppState } from '@/store' -import { makeWorktree } from '@/store/slices/store-test-helpers' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' export type FakeEditorDisk = { files: Map diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx index 4beecab12cd..a5cab8fdb52 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx @@ -14,7 +14,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' -import { makeWorktree } from '../../store/slices/store-test-helpers' +import { makeWorktree } from '../../store/slices/worktrees-slice-test-fixtures' const WINDOW_START = 20 const WINDOW_END = 25 diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.test.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.test.ts index 04a5f368b63..7961452cef8 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.test.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.test.ts @@ -3,7 +3,7 @@ import { resolveWorkspaceKanbanPointerDragSelection, shouldStartWorkspaceKanbanCardPointerDrag } from './use-workspace-kanban-card-pointer-drag' -import { makeWorktree } from '../../store/slices/store-test-helpers' +import { makeWorktree } from '../../store/slices/worktrees-slice-test-fixtures' function pointerEvent(overrides: Partial = {}): PointerEvent { return { diff --git a/src/renderer/src/lib/agent-followup-delivery.test.ts b/src/renderer/src/lib/agent-followup-delivery.test.ts index c87f9cfd6f6..06260adfb64 100644 --- a/src/renderer/src/lib/agent-followup-delivery.test.ts +++ b/src/renderer/src/lib/agent-followup-delivery.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { sendFollowupPromptWhenAgentReady } from './agent-followup-delivery' import { inspectRuntimeTerminalProcess, @@ -21,11 +21,17 @@ const INTERPRETER_WRAPPED_AGENTS = [ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () => { beforeEach(() => { vi.clearAllMocks() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) vi.stubGlobal('globalThis', globalThis) // Deliver the prompt write eagerly so the test does not depend on retries. vi.mocked(sendRuntimePtyInputVerified).mockResolvedValue(true) }) + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + for (const { agent, expectedProcess } of INTERPRETER_WRAPPED_AGENTS) { it(`types the prompt once ${agent} is up behind a python3 wrapper with a live child`, async () => { // The console-script agent is running: foreground comm is python3 and the @@ -35,13 +41,16 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () = hasChildProcesses: true }) - const delivered = await sendFollowupPromptWhenAgentReady({ + const delivery = sendFollowupPromptWhenAgentReady({ ptyId: 'pty-1', expectedProcess, prompt: 'ship it', settings: null }) + await vi.advanceTimersByTimeAsync(4 * 150) + const delivered = await delivery + expect(delivered).toBe(true) expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r', 'launch') }) @@ -54,13 +63,16 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () = hasChildProcesses: false }) - const delivered = await sendFollowupPromptWhenAgentReady({ + const delivery = sendFollowupPromptWhenAgentReady({ ptyId: 'pty-1', expectedProcess, prompt: 'ship it', settings: null }) + await vi.advanceTimersByTimeAsync(29 * 150) + const delivered = await delivery + expect(delivered).toBe(false) expect(sendRuntimePtyInputVerified).not.toHaveBeenCalled() }) @@ -71,13 +83,16 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () = hasChildProcesses: false }) - const delivered = await sendFollowupPromptWhenAgentReady({ + const delivery = sendFollowupPromptWhenAgentReady({ ptyId: 'pty-1', expectedProcess, prompt: 'ship it', settings: null }) + await vi.advanceTimersByTimeAsync(29 * 150) + const delivered = await delivery + expect(delivered).toBe(false) expect(sendRuntimePtyInputVerified).not.toHaveBeenCalled() })