From 61f233b389b980e318440d6c1842a3fc578d3b7f Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 17:56:13 +0900 Subject: [PATCH 1/7] ci: run the Windows shards on Windows-sensitive pull requests, nightly on dev, with a shared PowerShell module cache Co-authored-by: lane B9 (Codex) --- .github/workflows/ci.yml | 177 +++++++++++++++++- scripts/ci/windows-sensitive-diff.sh | 93 +++++++++ scripts/test.ts | 53 +++++- structure/ops/cross-platform-ci.md | 21 ++- .../ci-concurrency-groups.test.ts | 12 +- tests/ci-workflows/ci-review-lanes.test.ts | 162 +++++++++++++++- tests/ci-workflows/ci-workflows.test.ts | 13 +- tests/ci-workflows/test-runner.test.ts | 108 +++++++++++ 8 files changed, 609 insertions(+), 30 deletions(-) create mode 100644 scripts/ci/windows-sensitive-diff.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87eccc7cf3f..49ddec44e3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,20 @@ on: type: choice default: all options: [all, release-gates, macos-control] + # Nightly safety net. The Windows shards run on a pull request only when the + # changes job judges it Windows-sensitive, so anything that slips past that + # filter must still be caught within a day: a scheduled run requests the full + # lane=all matrix (nine Windows shards and the macOS control included). + # + # GitHub runs schedules from the DEFAULT branch, which is main, so this + # trigger activates only once this file reaches main by promotion, and the + # workflow definition is main's. Every test checkout reads + # needs.changes.outputs.checkout_ref, which the changes job resolves to ONE + # dev commit on a schedule, so the run tests dev and every job tests the same + # SHA. Its check runs attach to main's head commit; release.yml accepts only a + # push-event run, so a scheduled run can never qualify a release. + schedule: + - cron: "17 18 * * *" permissions: contents: read @@ -88,8 +102,12 @@ concurrency: # `github.run_id` is unique per run, so each dispatch is a group of one: it # cancels nothing and nothing cancels it, including a second dispatch of the # same ref. - group: cross-platform-ci-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} - cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + # + # A scheduled run is keyed the same way. Keyed on the ref it would share + # refs/heads/main with a promotion push and, with cancel-in-progress, cancel + # the very run release.yml waits for. + group: cross-platform-ci-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'schedule' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' }} jobs: # Which Windows runner this run is allowed to use. @@ -118,6 +136,8 @@ jobs: # # `push` on main/preview requires the push permission, and # `workflow_dispatch` requires write access, so both carry a trusted author. + # A schedule runs main's workflow against a dev commit that already merged + # through review, the same trust as a push. # A trusted author is not audited code: merging a contributor PR into `dev` # fires `push`, and its dependencies and postinstall hooks then run here. select-windows-runner: @@ -141,7 +161,7 @@ jobs: set -euo pipefail trusted=no case "$EVENT_NAME" in - push|workflow_dispatch) trusted=yes ;; + push|workflow_dispatch|schedule) trusted=yes ;; esac # Repository variable OCX_SELF_HOSTED_WINDOWS is an OPERATIONAL switch, @@ -197,6 +217,14 @@ jobs: # skipping the check it selects. setup_action: ${{ steps.narrow.outputs.setup_action }} remote_helper: ${{ steps.narrow.outputs.remote_helper }} + # Whether this pull request needs the nine Windows shards: the windows path + # filter below OR an added src/tests line naming a Windows marker. Computed + # by scripts/ci/windows-sensitive-diff.sh, which validates its input and + # treats the diff as data. + windows: ${{ steps.windows.outputs.windows }} + # The commit every test job checks out. Empty (the event's own commit) + # except on a schedule, where it is the one dev SHA resolved below. + checkout_ref: ${{ steps.nightly.outputs.checkout_ref }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -205,6 +233,9 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + # The pull request merge commit plus its first parent, the base tip, + # so the Windows marker scan can diff exactly this pull request. + fetch-depth: 2 - name: Detect changed areas id: filter @@ -341,6 +372,32 @@ jobs: # real only on macOS and Windows, so its job lints and tests the crate on all three. remote_helper: - 'native/remote-workspace-helper/**' + # Paths whose change selects the nine Windows suite shards on a pull + # request. These carry the Windows service, update, shim, intercept, + # ACL and process code, and the harness every Windows shard runs. + # scripts/ci/windows-sensitive-diff.sh adds the content markers. + windows: + - 'src/lib/windows-*' + - 'src/lib/*acl*' + - 'src/service/**' + - 'src/update/**' + - 'src/claude/intercept/**' + - 'src/codex/shim*' + - 'src/codex/app-server-processes.ts' + - 'src/codex/desktop-app/**' + - 'src/config/process-state.ts' + - 'src/cli/update-restart*' + - 'src/server/local-management-read-client.ts' + - 'src/server/startup-health-cache.ts' + - 'src/tray/**' + - 'scripts/test.ts' + - 'scripts/test-*.ts' + - 'scripts/ci/**' + - 'tests/preload.ts' + - 'tests/helpers/**' + - '.github/workflows/ci.yml' + - 'package.json' + - 'bun.lock' # Everything that ends up inside `npm pack`, or that decides what # does. `src/**` belongs here because package.json ships `src` and # bin/ocx.mjs executes it: without that entry an ordinary source PR @@ -457,6 +514,38 @@ jobs: esac done + - name: Decide whether the Windows shards run + id: windows + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + PATH_SELECTED: ${{ steps.filter.outputs.windows }} + run: bash scripts/ci/windows-sensitive-diff.sh + + # A scheduled run starts from main's head. Resolve dev ONCE so the nine + # Windows shards, the macOS control and every other job test one commit, + # however long the queue between them. contents: read already covers it. + - name: Resolve the commit a scheduled run tests + id: nightly + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "schedule" ]; then + echo 'checkout_ref=' >> "$GITHUB_OUTPUT" + exit 0 + fi + sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/dev" --jq .sha)" + if ! printf '%s' "$sha" | grep -Eqx '[0-9a-f]{40}'; then + printf '::error::could not resolve dev to a commit SHA\n' + exit 1 + fi + printf 'checkout_ref=%s\n' "$sha" >> "$GITHUB_OUTPUT" + printf 'Scheduled run: workflow from %s, testing dev at %s\n' "$GITHUB_SHA" "$sha" >> "$GITHUB_STEP_SUMMARY" + printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA" + # The suite, split by file across four Linux runners. # # `scripts/ci/run-bun-test-batches.sh` assigns files to shards by the per-file @@ -496,6 +585,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # tests/ci-workflows/release-version-line.test.ts compares package.json against the # newest release tag. actions/checkout fetches no tags by default, so # without this the check reads an empty tag set and passes on anything - @@ -552,6 +642,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -591,6 +682,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -626,6 +718,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -713,6 +806,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # tests/ci-workflows/release-version-line.test.ts compares package.json against the # newest release tag. actions/checkout fetches no tags by default, so # without this the check reads an empty tag set and passes on anything - @@ -764,8 +858,9 @@ jobs: needs: changes # Diagnostic-only lanes stay off the ordinary release gates: list every lane # that should run this job, so a new lane defaults to skipping the diagnostic. + # The nightly schedule is the full lane=all matrix, so it runs this job too. if: >- - github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control') + github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control') runs-on: macos-latest # Unsharded full-membership control, one worker in sequential fresh batches. # Long-lived Bun isolate pools repeatedly wedged while synchronously reaping @@ -780,6 +875,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # tests/ci-workflows/release-version-line.test.ts compares package.json against the # newest release tag. actions/checkout fetches no tags by default, so # without this the check reads an empty tag set and passes on anything - @@ -826,7 +922,9 @@ jobs: - name: CLI help smoke run: bun run src/cli/index.ts help - # Windows runs only when a maintainer asks for it by hand. + # Windows runs on a Windows-sensitive pull request, on the nightly schedule, + # and on a lane=all (or default) dispatch. It is never part of the push run + # release.yml gates on. # # It left the PR lane first (16m23s against a 6-minute Linux critical path; # the ceiling was raised twice rather than the gap closed — #711 vs #653, @@ -842,11 +940,18 @@ jobs: # release.yml gates on a successful push-event run of this workflow, which # now means Linux + macOS + the gates; Windows re-enters the gate when the # tracked failures are fixed, not before. + # + # Dispatch-only proved too late. #6639, #6556, #6605 and #6606 each landed with + # green pull-request CI while breaking Windows, and the breakage surfaced days + # later on a manual dispatch. So a pull request whose changes job reports + # windows=true (a Windows path, or an added src/tests line naming a Windows + # marker; see scripts/ci/windows-sensitive-diff.sh) now runs the nine shards, + # and the daily schedule catches whatever that filter misses. platform-windows: name: windows ${{ matrix.shard }}/9 - needs: select-windows-runner + needs: [changes, select-windows-runner] if: >- - github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all') + github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all') || github.event_name == 'pull_request' && needs.changes.outputs.windows == 'true' runs-on: ${{ fromJSON(needs.select-windows-runner.outputs.runner) }} # Sharded like the Linux legs. The single-leg run reached 30 minutes on a # green suite and was killed in cleanup; four shards put each leg inside the @@ -919,6 +1024,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # Same reason as the Linux shards and the macOS control: this leg runs the # whole suite, and tests/ci-workflows/release-version-line.test.ts reads release tags. # Without tags the check sees an empty set and cannot fail. @@ -939,6 +1045,43 @@ jobs: cd gui bun run build + # Windows PowerShell 5.1 resolves an autoloaded cmdlet through its module + # analysis cache, and a fresh or redirected LOCALAPPDATA rebuilds that cache + # by scanning every PSModulePath module: 20-27 s per child against ~3.7 s + # with a warm cache (devlog/_fin/260906_release_244_publish/020_integrate.md). + # Test sandboxes redirect the profile, so each one used to pay that rebuild. + # Build one seed here, from this bash step so the module environment matches + # the test step, with the System32 engine the product spawns. scripts/test.ts + # copies the seed into every sandbox; nothing ever writes the seed itself. + # PowerShell serializes the cache after a delay, so wait for a nonempty file + # and export the seed only once it exists. + - name: Prewarm the PowerShell module analysis cache + shell: bash + run: | + set -euo pipefail + seed="${RUNNER_TEMP}\\ocx-ps-module-analysis-seed\\ModuleAnalysisCache" + started=$SECONDS + # The single-quoted argument is PowerShell source; bash must not expand it. + # shellcheck disable=SC2016 + PSModuleAnalysisCachePath="$seed" "${SYSTEMROOT}/System32/WindowsPowerShell/v1.0/powershell.exe" \ + -NoLogo -NoProfile -NonInteractive -Command ' + $p = $env:PSModuleAnalysisCachePath + [void][System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($p)) + $null = Get-Module -ListAvailable + $null = Get-Command -Name Get-Acl, Set-Acl, Get-CimInstance + $deadline = [DateTime]::UtcNow.AddSeconds(60) + while ([DateTime]::UtcNow -lt $deadline) { + if ([System.IO.File]::Exists($p) -and ([System.IO.FileInfo]::new($p)).Length -gt 0) { break } + [System.Threading.Thread]::Sleep(500) + } + ' || echo "::warning::PowerShell module cache prewarm exited non-zero" + if [ -s "$(cygpath -u "$seed")" ]; then + echo "OCX_TEST_PS_MODULE_ANALYSIS_SEED=$seed" >> "$GITHUB_ENV" + echo "module analysis cache seeded in $(( SECONDS - started ))s" + else + echo "::warning::PowerShell module analysis cache was not written; sandboxes seed from the runner profile" + fi + - name: Test in fresh-process batches # --timeout: the Linux batches and the macOS control both pass 60000; this leg was # the only one left on Bun's 5s default, and it is the slowest hardware on the board. @@ -998,6 +1141,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1051,6 +1195,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1080,6 +1225,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1116,6 +1262,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1144,6 +1291,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun id: bun @@ -1181,6 +1329,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup Rust uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master @@ -1214,6 +1363,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1257,6 +1407,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # Deliberately NO setup-bun: prove `npm install -g` works without a # separately-installed Bun. The launcher uses the bundled `bun` dependency. @@ -1313,6 +1464,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -1390,6 +1542,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Install Tauri Linux dependencies run: | @@ -1534,6 +1687,7 @@ jobs: CHANGES_REMOTE_HELPER: ${{ needs.changes.outputs.remote_helper }} CHANGES_NATIVE: ${{ needs.changes.outputs.native }} CHANGES_DESKTOP: ${{ needs.changes.outputs.desktop }} + CHANGES_WINDOWS: ${{ needs.changes.outputs.windows }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -1597,7 +1751,11 @@ jobs: fi dispatch=not-requested windows=not-requested - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ "$EVENT_NAME" = "schedule" ]; then + # The nightly safety net is the full lane=all matrix. + dispatch=requested + windows=requested + elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then # Mirror the diagnostic job allowlist; release-gates requests neither suite. if [ -z "$LANE" ] || [ "$LANE" = "all" ] || [ "$LANE" = "macos-control" ]; then dispatch=requested @@ -1606,6 +1764,9 @@ jobs: if [ -z "$LANE" ] || [ "$LANE" = "all" ]; then windows=requested fi + elif [ "$EVENT_NAME" = "pull_request" ] && [ "${CHANGES_WINDOWS:-}" = "true" ]; then + # A Windows-sensitive pull request (path filter or added-line marker). + windows=requested fi # One line per job, mirroring that job's own `if:`. Adding a job to this workflow diff --git a/scripts/ci/windows-sensitive-diff.sh b/scripts/ci/windows-sensitive-diff.sh new file mode 100644 index 00000000000..05bcaaba27e --- /dev/null +++ b/scripts/ci/windows-sensitive-diff.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Decide whether a pull request needs the nine Windows suite shards. +# +# The Windows leg costs nine runners for up to thirty minutes, so ordinary pull +# requests skip it. Before this selector it ran only on a manual lane=all +# dispatch, and #6639, #6556, #6605 and #6606 each landed green while breaking +# Windows; the breakage surfaced days later on a dispatch. A pull request is now +# Windows-sensitive when either: +# - the changes job's 'windows' path filter matched (PATH_SELECTED=true), or +# - an ADDED line under src/ or tests/ names a Windows marker (see markers below). +# +# The diff is pull-request content and is treated strictly as data: it goes to a +# file, awk keeps added lines, and grep -F matches fixed strings. Nothing from the +# diff is interpolated into a command, evaluated, or echoed. +# +# Inputs (environment): EVENT_NAME, PATH_SELECTED (true|false), GITHUB_OUTPUT. +# Output: windows=true|false appended to GITHUB_OUTPUT. +# Failure contract: an invalid PATH_SELECTED, a failing git diff, or a grep error +# fails the job. A missing base parent cannot be scanned, so it selects Windows. +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +event="${EVENT_NAME:-}" +selected="${PATH_SELECTED:-}" + +emit() { + printf 'windows=%s\n' "$1" >> "$GITHUB_OUTPUT" + printf 'windows-sensitive: %s (%s)\n' "$1" "$2" +} + +case "$selected" in + true|false) ;; + *) + printf '::error::windows path filter output was %q, expected true or false\n' "$selected" + exit 1 + ;; +esac + +if [ "$event" != "pull_request" ]; then + # Schedule and lane=all dispatch request Windows through the job condition; + # push never does. The value is informational outside pull requests. + emit "$selected" "path filter, ${event:-unknown} event" + exit 0 +fi + +if [ "$selected" = "true" ]; then + emit true "path filter" + exit 0 +fi + +# actions/checkout puts the pull request's merge commit at HEAD; with +# fetch-depth: 2 its first parent is the base tip, so HEAD^1..HEAD is exactly +# what this pull request changes on top of its base. +if ! git rev-parse --verify --quiet 'HEAD^1^{commit}' >/dev/null; then + printf '::warning::cannot read the base parent of the merge commit; selecting the Windows leg\n' + emit true "base parent unavailable" + exit 0 +fi + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +if ! git -c core.quotepath=off diff --no-ext-diff --no-textconv --no-color --unified=0 \ + 'HEAD^1' HEAD -- src tests > "$work/diff"; then + printf '::error::git diff of the pull request failed\n' + exit 1 +fi +awk 'substr($0, 1, 1) == "+" && substr($0, 1, 3) != "+++" { print substr($0, 2) }' \ + "$work/diff" > "$work/added" + +# Fixed strings, matched case-insensitively against added lines only. +cat > "$work/markers" <<'MARKERS' +win32 +powershell +pwsh +icacls +schtasks +get-acl +get-ciminstance +process.platform +localappdata +userprofile +MARKERS + +status=0 +grep -F -i -q -f "$work/markers" "$work/added" || status=$? +case "$status" in + 0) emit true "added line names a Windows marker" ;; + 1) emit false "no Windows path or marker" ;; + *) + printf '::error::marker scan failed with status %s\n' "$status" + exit 1 + ;; +esac diff --git a/scripts/test.ts b/scripts/test.ts index 53710dcb78c..383432678ec 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; +import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { basename, join } from "node:path"; +import { basename, isAbsolute, join, win32 } from "node:path"; import { acquireTestRunLock, resolveWrappedTestRunLockPath, @@ -34,6 +34,52 @@ export const LIVE_INSTALL_CREDENTIAL_ENV = [ "OCX_API_TOKEN_FILE", ] as const; +export const TEST_PS_MODULE_ANALYSIS_SEED_ENV = "OCX_TEST_PS_MODULE_ANALYSIS_SEED"; + +export function pinPowerShellModuleAnalysisCache( + root: string, + baseEnv: Record, + platform: NodeJS.Platform = process.platform, +): { set: Record; drop: string[] } { + if (platform !== "win32") return { set: {}, drop: [] }; + const drop = Object.keys(baseEnv).filter(key => key.toLowerCase() === "psmoduleanalysiscachepath"); + const explicitSeed = baseEnv[TEST_PS_MODULE_ANALYSIS_SEED_ENV]; + const localAppData = Object.entries(baseEnv).find(([key]) => key.toLowerCase() === "localappdata")?.[1]; + const seed = explicitSeed && (win32.isAbsolute(explicitSeed) || isAbsolute(explicitSeed)) + ? explicitSeed + : drop.map(key => baseEnv[key]).find(value => !!value) + ?? (localAppData ? join(localAppData, "Microsoft", "Windows", "PowerShell", "ModuleAnalysisCache") : undefined); + const directory = join(root, "ps-module-analysis-cache"); + const owned = join(directory, "ModuleAnalysisCache"); + mkdirSync(directory, { recursive: true }); + // Fresh/redirected LOCALAPPDATA costs 5.1 autoload 20–27s per child (devlog/_fin/ + // 260906_release_244_publish/020_integrate.md). Product spawns only System32 WindowsPowerShell + // 5.1; pwsh shares this path and at worst rebuilds. Each sandbox owns its writable copy: + // concurrent sandboxes never share it, and the developer/runner seed is never written. + if (seed) { + try { + const source = lstatSync(seed); + if (source.isFile() && !source.isSymbolicLink()) { + copyFileSync(seed, owned); + if (lstatSync(owned).size !== source.size || lstatSync(seed).size !== source.size) { + rmSync(owned, { force: true }); + } + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!code || !["ENOENT", "ESTALE", "EACCES", "EPERM"].includes(code)) throw error; + rmSync(owned, { force: true }); + } + } + return { + set: { + PSModuleAnalysisCachePath: owned, + ...(explicitSeed !== undefined ? { [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: explicitSeed } : {}), + }, + drop, + }; +} + export function createIsolatedTestEnvironment( baseEnv: Record = process.env, ): IsolatedTestEnvironment { @@ -67,11 +113,14 @@ export function createIsolatedTestEnvironment( writeTestTempOwner(root, baseEnv[TEST_RUN_ID_ENV]); const inherited = { ...baseEnv }; for (const name of LIVE_INSTALL_CREDENTIAL_ENV) delete inherited[name]; + const moduleAnalysisCache = pinPowerShellModuleAnalysisCache(root, baseEnv); + for (const name of moduleAnalysisCache.drop) delete inherited[name]; return { root, env: { ...inherited, + ...moduleAnalysisCache.set, // Captured BEFORE HOME is overwritten: once the child starts with a rewritten // HOME, `homedir()` returns the sandbox, so this hand-off is the only way the // real-home write guard can still know which path to protect. diff --git a/structure/ops/cross-platform-ci.md b/structure/ops/cross-platform-ci.md index 097529a9154..47389b4850b 100644 --- a/structure/ops/cross-platform-ci.md +++ b/structure/ops/cross-platform-ci.md @@ -11,9 +11,16 @@ request verifies Linux and TypeScript: Linux runs the suite in four shards with `changes` job's native path filter selects the change. `dev` pushes start nothing — dev integration is covered by the pull-request run — while `main` and `preview` must remain push triggers because `release.yml` requires a successful push-event run for the exact release SHA -and does not accept a pull-request run. Windows runs the full suite in nine shards only on manual -`workflow_dispatch` with `lane=all` (or an empty lane), and an aggregate green `ci` check -legitimately includes deliberate skips for every job the event did not request. +and does not accept a pull-request run. Windows runs the full suite in nine shards on a +Windows-sensitive pull request, on the nightly schedule, and on `workflow_dispatch` with +`lane=all` (or an empty lane); it never joins the push run `release.yml` gates on. A pull request +is Windows-sensitive when the `changes` job's `windows` path filter matches or an added +`src/`/`tests/` line names a Windows marker; `scripts/ci/windows-sensitive-diff.sh` makes that call, +treating the diff as data and selecting Windows when it cannot read the base parent. The daily +`schedule` runs main's workflow (GitHub schedules only the default branch) against one dev SHA the +`changes` job resolves and every checkout reads, and requests the macOS control as well. An +aggregate green `ci` check legitimately includes deliberate skips for every job the event did not +request. This scoping accepts a real coverage loss: a green pull request no longer proves the macOS suite, the Rust toolchain, or the app bundle. Those regressions are caught at the promotion push to @@ -58,10 +65,16 @@ batch runner still sweeps a crashed or timed-out batch one file per process, but failure the shard has already taken. The aggregate `ci` gate derives, from the event and the `changes` outputs, which jobs this run actually requested, then requires `success` from every one of them and `skipped` from every job the event did not request — so a job that was requested and never started can no -longer report as a deliberate skip. On a `lane=all` dispatch the gate additionally reads the +longer report as a deliberate skip. Whenever Windows is requested the gate additionally reads the run's own job list through the Actions API and requires nine concrete successful `windows N/9` results, because a matrix rollup can report `success` when one matrix leg is skipped. A release that requires Windows proof still dispatches it for the exact publish SHA. +Before its tests each Windows shard prewarms a Windows PowerShell 5.1 module-analysis cache +seed and exports `OCX_TEST_PS_MODULE_ANALYSIS_SEED`; `createIsolatedTestEnvironment` in +`scripts/test.ts` copies that seed (or the inherited cache) into each sandbox and pins +`PSModuleAnalysisCachePath`, so a redirected profile no longer rebuilds the cache in every child. +Startup ACL reads use .NET, never module-autoloaded `Get-Acl`/`Set-Acl` +(`tests/ci-workflows/ci-review-lanes.test.ts` scans `src/`). Across the jobs, the workflow runs: ```bash diff --git a/tests/ci-workflows/ci-concurrency-groups.test.ts b/tests/ci-workflows/ci-concurrency-groups.test.ts index 420e2b2e8bd..a8d233e6b33 100644 --- a/tests/ci-workflows/ci-concurrency-groups.test.ts +++ b/tests/ci-workflows/ci-concurrency-groups.test.ts @@ -99,11 +99,19 @@ test("push and pull_request still supersede an older head on their own ref", () }); test("every trigger this workflow declares is one the cases above cover", () => { - // A fourth trigger would arrive with no decision recorded about whether it + // A new trigger would arrive with no decision recorded about whether it // supersedes anything, and would inherit the push answer by accident. - expect(Object.keys(workflow.on ?? {}).sort()).toEqual(["pull_request", "push", "workflow_dispatch"]); + expect(Object.keys(workflow.on ?? {}).sort()).toEqual(["pull_request", "push", "schedule", "workflow_dispatch"]); }); test("the evaluator refuses an expression it does not model", () => { expect(() => render("${{ startsWith(github.ref, 'refs/tags/') }}", push("1"))).toThrow(/unsupported expression term/); }); + +test("scheduled runs on main never share the push group or cancel earlier runs", () => { + const scheduled = (run_id: string): GithubContext => ({ event_name: "schedule", ref: "refs/heads/main", run_id }); + const mainPush: GithubContext = { event_name: "push", ref: "refs/heads/main", run_id: "3" }; + expect(concurrency(scheduled("1")).group).not.toBe(concurrency(mainPush).group); + expect(concurrency(scheduled("1")).group).not.toBe(concurrency(scheduled("2")).group); + expect(concurrency(scheduled("1")).cancels).toBe("false"); +}); diff --git a/tests/ci-workflows/ci-review-lanes.test.ts b/tests/ci-workflows/ci-review-lanes.test.ts index b06de5b7300..36d1f9d1936 100644 --- a/tests/ci-workflows/ci-review-lanes.test.ts +++ b/tests/ci-workflows/ci-review-lanes.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { repoPath } from "../helpers/repo-root"; // Review-lane contracts for ci.yml and enforce-pr-target.yml. They live beside @@ -169,7 +171,7 @@ describe("CI review lanes", () => { } const macosControlIf = ci.jobs?.["macos-control"] as { needs?: string; if?: string } | undefined; expect(macosControlIf?.needs).toBe("changes"); - expect(macosControlIf?.if).toBe("github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control')"); + expect(macosControlIf?.if).toBe("github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control')"); }); test("manual release-gates keeps ordinary jobs and skips only diagnostic suites", async () => { @@ -178,12 +180,12 @@ describe("CI review lanes", () => { }; // These job conditions use boolean operators and lowercase string comparisons. // Evaluate the checked-in expressions, rather than a second implementation of them. - const enabled = (job: string, event: string, lane: string, scope = "true", packaging = "true", native = "true") => { + const enabled = (job: string, event: string, lane: string, scope = "true", packaging = "true", native = "true", windows = "false") => { const condition = ci.jobs[job]!.if ?? "true"; const evaluate = new Function("github", "needs", `return (${condition});`); return evaluate( { event_name: event, event: { inputs: { lane } } }, - { changes: { outputs: { ci: scope, packaging, native } } }, + { changes: { outputs: { ci: scope, packaging, native, windows } } }, ); }; for (const [event, lane, windows, control] of [ @@ -217,8 +219,13 @@ describe("CI review lanes", () => { expect(enabled("npm-global-smoke", event, lane, "true", "true")).toBe(true); expect(enabled("npm-global-smoke", event, lane, "true", "false")).toBe(false); } + for (const windows of ["true", "false", ""]) { + expect(enabled("platform-windows", "pull_request", "", "true", "true", "true", windows)).toBe(windows === "true"); + } + expect(enabled("platform-windows", "schedule", "", "false", "false", "false")).toBe(true); + expect(enabled("macos-control", "schedule", "", "false", "false", "false")).toBe(true); expect(ci.jobs["select-windows-runner"]!.if).toBeUndefined(); - expect(ci.jobs["platform-windows"]!.needs).toBe("select-windows-runner"); + expect(ci.jobs["platform-windows"]!.needs).toEqual(["changes", "select-windows-runner"]); expect(ci.jobs.ci!.if).toBe("always()"); expect(ci.jobs.ci!.needs).toEqual(expect.arrayContaining([ "changes", "select-windows-runner", @@ -294,3 +301,148 @@ describe("CI review lanes", () => { } }, 30_000); }); + +describe("Windows and nightly CI contracts", () => { + test("publishes validated Windows selection and one nightly checkout ref", async () => { + const ci = Bun.YAML.parse(await readText(".github/workflows/ci.yml")) as { + on: { schedule: { cron: string }[] }; + jobs: Record; steps?: Array<{ + id?: string; uses?: string; shell?: string; run?: string; + env?: Record; with?: Record; + }> }>; + }; + expect(ci.on.schedule).toEqual([{ cron: "17 18 * * *" }]); + const changes = ci.jobs.changes!; + expect(changes.outputs?.windows).toBe("${{ steps.windows.outputs.windows }}"); + expect(changes.outputs?.checkout_ref).toBe("${{ steps.nightly.outputs.checkout_ref }}"); + const checkout = changes.steps?.find(step => step.uses?.startsWith("actions/checkout@")); + expect(checkout?.with?.["fetch-depth"]).toBe(2); + const filter = changes.steps?.find(step => step.id === "filter"); + const filters = Bun.YAML.parse(String(filter?.with?.filters ?? "")) as Record; + expect(filters.windows).toEqual([ + "src/lib/windows-*", "src/lib/*acl*", "src/service/**", "src/update/**", + "src/claude/intercept/**", "src/codex/shim*", "src/codex/app-server-processes.ts", + "src/codex/desktop-app/**", "src/config/process-state.ts", "src/cli/update-restart*", + "src/server/local-management-read-client.ts", "src/server/startup-health-cache.ts", + "src/tray/**", "scripts/test.ts", "scripts/test-*.ts", "scripts/ci/**", + "tests/preload.ts", "tests/helpers/**", ".github/workflows/ci.yml", "package.json", "bun.lock", + ]); + const windows = changes.steps?.find(step => step.id === "windows"); + expect(windows?.shell).toBe("bash"); + expect(windows?.env).toEqual({ EVENT_NAME: "${{ github.event_name }}", PATH_SELECTED: "${{ steps.filter.outputs.windows }}" }); + expect(windows?.run).toBe("bash scripts/ci/windows-sensitive-diff.sh"); + expect(windows?.run).not.toContain("${{"); + let checkouts = 0; + for (const [name, job] of Object.entries(ci.jobs)) { + if (name === "changes") continue; + for (const step of job.steps ?? []) { + if (!step.uses?.startsWith("actions/checkout@")) continue; + expect(step.with?.ref, `${name} checkout`).toBe("${{ needs.changes.outputs.checkout_ref }}"); + checkouts += 1; + } + } + expect(checkouts).toBe(17); + }); + + test.skipIf(process.platform === "win32" || !Bun.which("jq"))("aggregate requests Windows only for selected PRs and requests both diagnostic jobs nightly", async () => { + const ci = Bun.YAML.parse(await readText(".github/workflows/ci.yml")) as { + jobs: Record; run?: string }> }>; + }; + const aggregate = ci.jobs.ci!; + const step = aggregate.steps?.find(candidate => candidate.name === "Assert every job this event requested succeeded"); + expect(step?.shell).toBe("bash"); + expect(step?.env?.CHANGES_WINDOWS).toBe("${{ needs.changes.outputs.windows }}"); + // Execute only the expectation block (as ci-scope-reduction.test.ts does): the + // leg count after it calls the Actions API, which a test must not reach. + const script = step!.run!; + const start = script.indexOf("scoped=requested"); + const first = script.indexOf("RESULTS_EOF"); + const last = script.indexOf("RESULTS_EOF", first + "RESULTS_EOF".length); + expect(start).toBeGreaterThan(-1); + expect(last).toBeGreaterThan(first); + const slice = script.slice(start, last + "RESULTS_EOF".length); + for (const [event, lane, windows, expectWindows, expectControl] of [ + ["schedule", "", undefined, "requested", "requested"], + ["pull_request", "", "true", "requested", "not-requested"], + ["pull_request", "", "false", "not-requested", "not-requested"], + ["pull_request", "", undefined, "not-requested", "not-requested"], + ["push", "", "true", "not-requested", "not-requested"], + ["workflow_dispatch", "all", "false", "requested", "requested"], + ["workflow_dispatch", "release-gates", "true", "not-requested", "not-requested"], + ] as const) { + const env: Record = { + PATH: process.env.PATH ?? "/usr/bin:/bin", EVENT_NAME: event, LANE: lane, RESULTS: "{}", + CHANGES_CI: "true", CHANGES_NATIVE: "true", CHANGES_PACKAGING: "true", + CHANGES_DESKTOP: "false", CHANGES_DOCS: "false", CHANGES_STRUCTURE: "false", + CHANGES_SETUP_ACTION: "false", CHANGES_REMOTE_HELPER: "false", + ...(windows === undefined ? {} : { CHANGES_WINDOWS: windows }), + }; + const result = spawnSync("bash", ["-c", `set -u\n${slice}\necho "WIN=$(expected_for platform-windows)"\necho "CTL=$(expected_for macos-control)"`], { + encoding: "utf8", timeout: 5_000, env, + }); + expect(result.error).toBeUndefined(); + expect(`${event}/${lane}/${windows}:${result.stdout.match(/^WIN=(.*)$/m)?.[1]}`).toBe(`${event}/${lane}/${windows}:${expectWindows}`); + expect(`${event}/${lane}/${windows}:${result.stdout.match(/^CTL=(.*)$/m)?.[1]}`).toBe(`${event}/${lane}/${windows}:${expectControl}`); + } + }); + + test.skipIf(process.platform === "win32" || !Bun.which("git"))("Windows diff scan considers only added src/tests markers and fails closed without a parent", () => { + const scenarios = [ + { path: "src/example.ts", before: "plain\n", after: "WIN32\n", expected: "true" }, + { path: "tests/example.ts", before: "plain\n", after: "ordinary\n", expected: "false" }, + { path: "src/example.ts", before: "powershell\nplain\n", after: "plain\n", expected: "false" }, + { path: "docs/example.md", before: "plain\n", after: "pwsh\n", expected: "false" }, + { path: "src/example.ts", before: "plain\n", after: "plain\n", selected: "true", expected: "true" }, + { path: "src/example.ts", before: "plain\n", after: "plain\n", selected: "invalid", status: 1 }, + { path: "src/example.ts", before: "plain\n", after: "win32\n", event: "push", expected: "false" }, + { path: "src/example.ts", before: "plain\n", after: "plain\n", event: "schedule", selected: "true", expected: "true" }, + { path: "src/example.ts", before: "plain\n", orphan: true, expected: "true" }, + ]; + for (const scenario of scenarios) { + const directory = mkdtempSync(join(tmpdir(), "ocx-windows-diff-")); + try { + const git = (...args: string[]) => { + const result = spawnSync("git", args, { cwd: directory, encoding: "utf8", timeout: 5_000, + env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null" } }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + }; + git("init", "--quiet"); + const path = join(directory, scenario.path); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, scenario.before); + const commit = () => { + git("add", "."); + git("-c", "user.name=CI Fixture", "-c", "user.email=test@example.test", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "commit", "--quiet", "--allow-empty", "-m", "fixture"); + }; + commit(); + if (!scenario.orphan) { writeFileSync(path, scenario.after ?? scenario.before); commit(); } + const output = join(directory, "output"); + const result = spawnSync("bash", [repoPath("scripts", "ci", "windows-sensitive-diff.sh")], { + cwd: directory, encoding: "utf8", timeout: 5_000, + env: { ...process.env, EVENT_NAME: scenario.event ?? "pull_request", PATH_SELECTED: scenario.selected ?? "false", GITHUB_OUTPUT: output }, + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(scenario.status ?? 0); + if (scenario.expected) expect(readFileSync(output, "utf8")).toBe(`windows=${scenario.expected}\n`); + } finally { rmSync(directory, { recursive: true, force: true }); } + } + }, 30_000); + + test("startup ACL reads avoid module-autoloaded Get-Acl and Set-Acl across src", () => { + const violations: string[] = []; + const walk = (directory: string) => { + for (const entry of readdirSync(repoPath(...directory.split("/")), { withFileTypes: true })) { + const path = `${directory}/${entry.name}`; + if (entry.isDirectory()) { walk(path); continue; } + if (!entry.isFile() || !/\.(ts|tsx|mjs|cjs|js)$/.test(entry.name)) continue; + readFileSync(repoPath(...path.split("/")), "utf8").split("\n").forEach((line, index) => { + if (/^(\/\/|\*|\/\*)/.test(line.trimStart())) return; + if (/\b(Get|Set)-Acl\b/i.test(line)) violations.push(`${path}:${index + 1}`); + }); + } + }; + walk("src"); + expect(violations, `${violations.join("\n")}\nRead ACLs through .NET as in src/lib/windows-owner-acl.ts; module autoload rebuilds the analysis cache for 20-27 s on a fresh profile.`).toEqual([]); + }); +}); diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index e3562a4db50..c8b1d8b54e4 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -295,7 +295,7 @@ describe("GitHub Actions hardening", () => { } | undefined; expect(macosControlJob?.name).toBe("macos control"); expect(macosControlJob?.needs).toBe("changes"); - expect(macosControlJob?.if).toBe("github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control')"); + expect(macosControlJob?.if).toBe("github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control')"); expect(macosControlJob?.["runs-on"]).toBe("macos-latest"); expect(macosControlJob?.strategy).toBeUndefined(); const macosControlSteps = macosControlJob?.steps ?? []; @@ -309,20 +309,15 @@ describe("GitHub Actions hardening", () => { expect(macosControlSteps.some(step => step.run?.includes("--shard"))).toBe(false); expect(macosControlSteps.some(step => step.run?.includes("coreutils") && step.run.includes("GITHUB_PATH"))).toBe(true); - // Windows is dispatch-only: it gates nothing, not even the shipping - // boundary. The sharded promotion run surfaced ~207 Windows-only failures - // that pre-date every released version, so the leg became a measurement - // tool a maintainer runs by hand, not a gate. Assert the positive - // condition and the absence of every automatic trigger — a stray - // `|| github.ref == ...` would restore a red leg to the release path. + // Windows-sensitive PRs and the daily schedule request Windows; release + // pushes still do not. Keep branch-ref shortcuts off this condition. const windowsIf = String((ci.jobs?.["platform-windows"] as { if?: string })?.if ?? ""); expect(windowsIf).toBe( - "github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all')", + "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all') || github.event_name == 'pull_request' && needs.changes.outputs.windows == 'true'", ); expect(windowsIf).not.toContain("refs/heads/main"); expect(windowsIf).not.toContain("refs/heads/preview"); expect(windowsIf).not.toContain("refs/heads/dev"); - expect(windowsIf).not.toContain("pull_request"); // A lane=macos-control dispatch must skip Windows so a red Windows burn-down // cannot fail the unsharded macOS control run. A plain dispatch still runs diff --git a/tests/ci-workflows/test-runner.test.ts b/tests/ci-workflows/test-runner.test.ts index db18e231f3b..0d3496d5569 100644 --- a/tests/ci-workflows/test-runner.test.ts +++ b/tests/ci-workflows/test-runner.test.ts @@ -21,11 +21,13 @@ import { LIVE_INSTALL_CREDENTIAL_ENV, ensureGuiDependencies, inspectChangedRun, + pinPowerShellModuleAnalysisCache, resolveBunTestArgs, resolveBunTestPlan, runTestLane, selectChangedComparisonRef, SERIAL_FULL_SUITE_FILES, + TEST_PS_MODULE_ANALYSIS_SEED_ENV, } from "../../scripts/test"; import { NESTED_LIVE_LOCK_RECEIPT_KEY, @@ -122,6 +124,112 @@ function initChangedRunFixture(): { cwd: string; base: string } { return { cwd, base }; } +describe("PowerShell module analysis cache pin", () => { + test("does nothing outside Windows", () => { + const root = mkdtempSync(join(tmpdir(), "opencodex-ps-cache-")); + try { + for (const platform of ["linux", "darwin"] as const) { + expect(pinPowerShellModuleAnalysisCache(root, { PSModuleAnalysisCachePath: "seed" }, platform)) + .toEqual({ set: {}, drop: [] }); + } + expect(existsSync(join(root, "ps-module-analysis-cache"))).toBe(false); + } finally { + removeTreeWithRetry(root); + } + }); + + test("copies the explicit seed into an owned writable file without changing the seed", () => { + const root = mkdtempSync(join(tmpdir(), "opencodex-ps-cache-")); + const seed = join(root, "seed"); + writeFileSync(seed, "warmed analysis cache"); + try { + const result = pinPowerShellModuleAnalysisCache(root, { + [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: seed, + PSModuleAnalysisCachePath: join(root, "other-seed"), + }, "win32"); + const owned = join(root, "ps-module-analysis-cache", "ModuleAnalysisCache"); + expect(result.set).toEqual({ + PSModuleAnalysisCachePath: owned, [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: seed, + }); + expect(readFileSync(owned, "utf8")).toBe("warmed analysis cache"); + writeFileSync(owned, "sandbox rewrite"); + expect(readFileSync(seed, "utf8")).toBe("warmed analysis cache"); + } finally { + removeTreeWithRetry(root); + } + }); + + test("uses an inherited case variant as seed and drops every variant", () => { + const root = mkdtempSync(join(tmpdir(), "opencodex-ps-cache-")); + const seed = join(root, "seed"); + writeFileSync(seed, "inherited cache"); + try { + const result = pinPowerShellModuleAnalysisCache(root, { + PSMODULEANALYSISCACHEPATH: seed, + PSModuleAnalysisCachePath: seed, + [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: "relative-seed", + }, "win32"); + expect(result.drop).toEqual(["PSMODULEANALYSISCACHEPATH", "PSModuleAnalysisCachePath"]); + expect(readFileSync(result.set.PSModuleAnalysisCachePath!, "utf8")).toBe("inherited cache"); + expect(result.set[TEST_PS_MODULE_ANALYSIS_SEED_ENV]).toBe("relative-seed"); + } finally { + removeTreeWithRetry(root); + } + }); + + test("pins a missing seed path so the first child can warm its own cache", () => { + const root = mkdtempSync(join(tmpdir(), "opencodex-ps-cache-")); + try { + const result = pinPowerShellModuleAnalysisCache(root, { + [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: join(root, "missing"), + }, "win32"); + expect(result.set.PSModuleAnalysisCachePath).toBe(join(root, "ps-module-analysis-cache", "ModuleAnalysisCache")); + expect(existsSync(dirname(result.set.PSModuleAnalysisCachePath!))).toBe(true); + expect(existsSync(result.set.PSModuleAnalysisCachePath!)).toBe(false); + } finally { + removeTreeWithRetry(root); + } + }); + + test("two sandbox roots own independent copies of the same seed", () => { + const root = mkdtempSync(join(tmpdir(), "opencodex-ps-cache-")); + const seed = join(root, "seed"); + writeFileSync(seed, "shared read-only seed"); + try { + const baseEnv = { [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: seed }; + const first = pinPowerShellModuleAnalysisCache(join(root, "first"), baseEnv, "win32").set.PSModuleAnalysisCachePath!; + const second = pinPowerShellModuleAnalysisCache(join(root, "second"), baseEnv, "win32").set.PSModuleAnalysisCachePath!; + expect(first).not.toBe(second); + writeFileSync(first, "first sandbox"); + expect(readFileSync(second, "utf8")).toBe("shared read-only seed"); + expect(readFileSync(seed, "utf8")).toBe("shared read-only seed"); + } finally { + removeTreeWithRetry(root); + } + }); + + test("does not copy a symlink seed", () => { + const root = mkdtempSync(join(tmpdir(), "opencodex-ps-cache-")); + const target = join(root, "target"); + const seed = join(root, "seed-link"); + writeFileSync(target, "target cache"); + try { + try { + symlinkSync(target, seed, "file"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (process.platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "ENOTSUP")) return; + throw error; + } + const result = pinPowerShellModuleAnalysisCache(root, { [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: seed }, "win32"); + expect(existsSync(result.set.PSModuleAnalysisCachePath!)).toBe(false); + expect(readFileSync(target, "utf8")).toBe("target cache"); + } finally { + removeTreeWithRetry(root); + } + }); +}); + describe("test runner captured output", () => { // This describe's first real lane pays Bun's test-runner bootstrap inside its child timeout. // Replay one trivial lane in setup because an import scan cannot warm Bun's runner startup. From 90ef85ce6da043e33018fe1726860ee0c9f1a5f5 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 18:02:12 +0900 Subject: [PATCH 2/7] ci: select every scope on the nightly schedule so it is the full matrix --- .github/workflows/ci.yml | 21 +++++++++++--------- tests/ci-workflows/ci-review-lanes.test.ts | 2 +- tests/ci-workflows/ci-scope-gaps.test.ts | 4 ++-- tests/ci-workflows/ci-structure-gate.test.ts | 2 +- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49ddec44e3b..f4a305747a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,10 +208,13 @@ jobs: # shape-checked by the same validation step as `native`. keyring_matrix: ${{ steps.matrices.outputs.keyring_matrix }} npm_global_matrix: ${{ steps.matrices.outputs.npm_global_matrix }} - gui: ${{ steps.filter.outputs.gui }} - packaging: ${{ steps.filter.outputs.packaging }} - docs: ${{ steps.filter.outputs.docs }} - structure: ${{ steps.filter.outputs.structure }} + # A scheduled run starts from main, so its path filter diffs main and would + # scope jobs by the wrong commit. The nightly safety net selects every scope + # instead, here and in the validation steps below, so it is the full matrix. + gui: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.gui }} + packaging: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.packaging }} + docs: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.docs }} + structure: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.structure }} # Narrow scopes for two paths the ci filter leaves out on purpose. Both are re-emitted by the # validation step below, so a malformed filter output fails this job instead of silently # skipping the check it selects. @@ -423,8 +426,8 @@ jobs: id: scope shell: bash env: - CI_SCOPE: ${{ steps.filter.outputs.ci }} - DESKTOP_SCOPE: ${{ steps.filter.outputs.desktop }} + CI_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.ci }} + DESKTOP_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.desktop }} run: | set -euo pipefail case "$CI_SCOPE" in @@ -450,7 +453,7 @@ jobs: id: matrices shell: bash env: - NATIVE_SELECTED: ${{ steps.filter.outputs.native }} + NATIVE_SELECTED: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.native }} run: | set -euo pipefail case "$NATIVE_SELECTED" in @@ -498,8 +501,8 @@ jobs: id: narrow shell: bash env: - SETUP_ACTION: ${{ steps.filter.outputs.setup_action }} - REMOTE_HELPER: ${{ steps.filter.outputs.remote_helper }} + SETUP_ACTION: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.setup_action }} + REMOTE_HELPER: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.remote_helper }} run: | set -euo pipefail for pair in "setup_action=$SETUP_ACTION" "remote_helper=$REMOTE_HELPER"; do diff --git a/tests/ci-workflows/ci-review-lanes.test.ts b/tests/ci-workflows/ci-review-lanes.test.ts index 36d1f9d1936..b19a8d80a56 100644 --- a/tests/ci-workflows/ci-review-lanes.test.ts +++ b/tests/ci-workflows/ci-review-lanes.test.ts @@ -150,7 +150,7 @@ describe("CI review lanes", () => { expect(changesJob?.outputs?.ci).toBe("${{ steps.scope.outputs.ci }}"); expect(scopeStep?.id).toBe("scope"); expect(scopeStep?.shell).toBe("bash"); - expect(scopeStep?.env?.CI_SCOPE).toBe("${{ steps.filter.outputs.ci }}"); + expect(scopeStep?.env?.CI_SCOPE).toBe("${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.ci }}"); expect(scopeStep?.run).not.toContain("${{"); expect(scopeStep?.run).toContain('case "$CI_SCOPE" in'); expect(scopeStep?.run).toContain("true|false)"); diff --git a/tests/ci-workflows/ci-scope-gaps.test.ts b/tests/ci-workflows/ci-scope-gaps.test.ts index 345b715c73a..a64fb261b0c 100644 --- a/tests/ci-workflows/ci-scope-gaps.test.ts +++ b/tests/ci-workflows/ci-scope-gaps.test.ts @@ -75,8 +75,8 @@ describe("the narrow checks stay narrow", () => { const narrow = (changes?.steps ?? []).find(step => step.id === "narrow"); expect(changes?.outputs?.setup_action).toBe("${{ steps.narrow.outputs.setup_action }}"); expect(changes?.outputs?.remote_helper).toBe("${{ steps.narrow.outputs.remote_helper }}"); - expect(narrow?.env?.SETUP_ACTION).toBe("${{ steps.filter.outputs.setup_action }}"); - expect(narrow?.env?.REMOTE_HELPER).toBe("${{ steps.filter.outputs.remote_helper }}"); + expect(narrow?.env?.SETUP_ACTION).toBe("${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.setup_action }}"); + expect(narrow?.env?.REMOTE_HELPER).toBe("${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.remote_helper }}"); expect(narrow?.run).toContain("exit 1"); }); }); diff --git a/tests/ci-workflows/ci-structure-gate.test.ts b/tests/ci-workflows/ci-structure-gate.test.ts index 5457497770b..21d5f36fab0 100644 --- a/tests/ci-workflows/ci-structure-gate.test.ts +++ b/tests/ci-workflows/ci-structure-gate.test.ts @@ -35,7 +35,7 @@ test("a change under structure/ selects a job that runs the structure gate", () // The whole defect was that nothing satisfied this. Read the condition off the // job rather than naming the job, so renaming it does not quietly pass. expect(filters.structure).toContain("structure/**"); - expect(changes?.outputs?.structure).toBe("${{ steps.filter.outputs.structure }}"); + expect(changes?.outputs?.structure).toBe("${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.structure }}"); const selected = Object.entries(workflow.jobs ?? {}) .filter(([, job]) => job.if?.includes("needs.changes.outputs.structure == 'true'")) From 04cb18d9c9d004e33fa6650aa587d25643e2ce81 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 18:33:42 +0900 Subject: [PATCH 3/7] ci: keep pull-request Windows shards advisory and render the schedule override in scope tests --- .github/workflows/ci.yml | 10 ++++++++-- structure/ops/cross-platform-ci.md | 2 +- tests/ci-workflows/ci-review-lanes.test.ts | 2 +- tests/ci-workflows/ci-scope-reduction.test.ts | 3 ++- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4a305747a9..1b40b3bf781 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1768,8 +1768,11 @@ jobs: windows=requested fi elif [ "$EVENT_NAME" = "pull_request" ] && [ "${CHANGES_WINDOWS:-}" = "true" ]; then - # A Windows-sensitive pull request (path filter or added-line marker). - windows=requested + # A Windows-sensitive pull request (path filter or added-line marker) runs the + # nine shards as ADVISORY: their results show on the pull request, but the + # aggregate does not fail on them while the hosted Windows suite still has + # timing flakes. Dispatch and the nightly schedule keep them required. + windows=advisory fi # One line per job, mirroring that job's own `if:`. Adding a job to this workflow @@ -1816,6 +1819,9 @@ jobs: requested) [ "$result" = "success" ] \ || fail "$job was requested by $EVENT_NAME but reported '$result'" ;; + advisory) + [ "$result" = "success" ] \ + || echo "::warning::$job is advisory on $EVENT_NAME and reported '$result'" ;; *) [ "$result" = "skipped" ] \ || fail "$job was not requested by $EVENT_NAME but reported '$result'" ;; diff --git a/structure/ops/cross-platform-ci.md b/structure/ops/cross-platform-ci.md index 47389b4850b..15ecdd6bb3d 100644 --- a/structure/ops/cross-platform-ci.md +++ b/structure/ops/cross-platform-ci.md @@ -16,7 +16,7 @@ Windows-sensitive pull request, on the nightly schedule, and on `workflow_dispat `lane=all` (or an empty lane); it never joins the push run `release.yml` gates on. A pull request is Windows-sensitive when the `changes` job's `windows` path filter matches or an added `src/`/`tests/` line names a Windows marker; `scripts/ci/windows-sensitive-diff.sh` makes that call, -treating the diff as data and selecting Windows when it cannot read the base parent. The daily +treating the diff as data and selecting Windows when it cannot read the base parent. On a pull request the shards are advisory (shown, not required by `ci`) until the hosted Windows suite is free of timing flakes; dispatch and the schedule require them. The daily `schedule` runs main's workflow (GitHub schedules only the default branch) against one dev SHA the `changes` job resolves and every checkout reads, and requests the macOS control as well. An aggregate green `ci` check legitimately includes deliberate skips for every job the event did not diff --git a/tests/ci-workflows/ci-review-lanes.test.ts b/tests/ci-workflows/ci-review-lanes.test.ts index b19a8d80a56..1a12eab958d 100644 --- a/tests/ci-workflows/ci-review-lanes.test.ts +++ b/tests/ci-workflows/ci-review-lanes.test.ts @@ -363,7 +363,7 @@ describe("Windows and nightly CI contracts", () => { const slice = script.slice(start, last + "RESULTS_EOF".length); for (const [event, lane, windows, expectWindows, expectControl] of [ ["schedule", "", undefined, "requested", "requested"], - ["pull_request", "", "true", "requested", "not-requested"], + ["pull_request", "", "true", "advisory", "not-requested"], ["pull_request", "", "false", "not-requested", "not-requested"], ["pull_request", "", undefined, "not-requested", "not-requested"], ["push", "", "true", "not-requested", "not-requested"], diff --git a/tests/ci-workflows/ci-scope-reduction.test.ts b/tests/ci-workflows/ci-scope-reduction.test.ts index ff8c09c1814..bf069cba33f 100644 --- a/tests/ci-workflows/ci-scope-reduction.test.ts +++ b/tests/ci-workflows/ci-scope-reduction.test.ts @@ -140,7 +140,8 @@ function emitterStep(outputName: string) { } function renderFilterOutputs(text: string, inputs: { ci: string; native: string }): string { - const rendered = text.replace(/\$\{\{\s*steps\.filter\.outputs\.([a-z-]+)\s*\}\}/g, (_match, name: string) => { + // A pull_request emitter sees the plain filter value; the nightly override only matters on schedule. + const rendered = text.replace(/\$\{\{\s*(?:github\.event_name == 'schedule' && 'true' \|\| )?steps\.filter\.outputs\.([a-z_-]+)\s*\}\}/g, (_match, name: string) => { if (name === "ci") return inputs.ci; if (name === "native") return inputs.native; throw new Error(`unsupported filter output in emitter: ${name}`); From 2f45a8344fed1ea74be3cf9f72cc6635d1da16d7 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 19:07:31 +0900 Subject: [PATCH 4/7] ci(windows): drop the PowerShell cache seed and require the shards on sensitive PRs The first run with the seed timed out seven of nine Windows shards, while a dev dispatch without it had one failure. Test sandboxes keep the runner's LOCALAPPDATA, so PowerShell 5.1 children already read the image's warm module-analysis cache; pinning PSModuleAnalysisCachePath to a seed that the prewarm step stops waiting on as soon as it is non-empty swapped that warm cache for a partial one, and every child re-analyzed modules. With the cause removed the shards no longer need to be advisory: a Windows-sensitive pull request now requires them the same way dispatch and the nightly schedule do. --- .github/workflows/ci.yml | 47 +-------- scripts/test.ts | 53 +--------- structure/ops/cross-platform-ci.md | 9 +- tests/ci-workflows/ci-review-lanes.test.ts | 2 +- tests/ci-workflows/test-runner.test.ts | 108 --------------------- 5 files changed, 10 insertions(+), 209 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b40b3bf781..ab43fa93915 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1048,43 +1048,6 @@ jobs: cd gui bun run build - # Windows PowerShell 5.1 resolves an autoloaded cmdlet through its module - # analysis cache, and a fresh or redirected LOCALAPPDATA rebuilds that cache - # by scanning every PSModulePath module: 20-27 s per child against ~3.7 s - # with a warm cache (devlog/_fin/260906_release_244_publish/020_integrate.md). - # Test sandboxes redirect the profile, so each one used to pay that rebuild. - # Build one seed here, from this bash step so the module environment matches - # the test step, with the System32 engine the product spawns. scripts/test.ts - # copies the seed into every sandbox; nothing ever writes the seed itself. - # PowerShell serializes the cache after a delay, so wait for a nonempty file - # and export the seed only once it exists. - - name: Prewarm the PowerShell module analysis cache - shell: bash - run: | - set -euo pipefail - seed="${RUNNER_TEMP}\\ocx-ps-module-analysis-seed\\ModuleAnalysisCache" - started=$SECONDS - # The single-quoted argument is PowerShell source; bash must not expand it. - # shellcheck disable=SC2016 - PSModuleAnalysisCachePath="$seed" "${SYSTEMROOT}/System32/WindowsPowerShell/v1.0/powershell.exe" \ - -NoLogo -NoProfile -NonInteractive -Command ' - $p = $env:PSModuleAnalysisCachePath - [void][System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($p)) - $null = Get-Module -ListAvailable - $null = Get-Command -Name Get-Acl, Set-Acl, Get-CimInstance - $deadline = [DateTime]::UtcNow.AddSeconds(60) - while ([DateTime]::UtcNow -lt $deadline) { - if ([System.IO.File]::Exists($p) -and ([System.IO.FileInfo]::new($p)).Length -gt 0) { break } - [System.Threading.Thread]::Sleep(500) - } - ' || echo "::warning::PowerShell module cache prewarm exited non-zero" - if [ -s "$(cygpath -u "$seed")" ]; then - echo "OCX_TEST_PS_MODULE_ANALYSIS_SEED=$seed" >> "$GITHUB_ENV" - echo "module analysis cache seeded in $(( SECONDS - started ))s" - else - echo "::warning::PowerShell module analysis cache was not written; sandboxes seed from the runner profile" - fi - - name: Test in fresh-process batches # --timeout: the Linux batches and the macOS control both pass 60000; this leg was # the only one left on Bun's 5s default, and it is the slowest hardware on the board. @@ -1769,10 +1732,9 @@ jobs: fi elif [ "$EVENT_NAME" = "pull_request" ] && [ "${CHANGES_WINDOWS:-}" = "true" ]; then # A Windows-sensitive pull request (path filter or added-line marker) runs the - # nine shards as ADVISORY: their results show on the pull request, but the - # aggregate does not fail on them while the hosted Windows suite still has - # timing flakes. Dispatch and the nightly schedule keep them required. - windows=advisory + # nine shards and requires them, exactly as dispatch and the schedule do. A + # shard that only fails on a pull request is a regression in that pull request. + windows=requested fi # One line per job, mirroring that job's own `if:`. Adding a job to this workflow @@ -1819,9 +1781,6 @@ jobs: requested) [ "$result" = "success" ] \ || fail "$job was requested by $EVENT_NAME but reported '$result'" ;; - advisory) - [ "$result" = "success" ] \ - || echo "::warning::$job is advisory on $EVENT_NAME and reported '$result'" ;; *) [ "$result" = "skipped" ] \ || fail "$job was not requested by $EVENT_NAME but reported '$result'" ;; diff --git a/scripts/test.ts b/scripts/test.ts index 383432678ec..53710dcb78c 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; -import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { basename, isAbsolute, join, win32 } from "node:path"; +import { basename, join } from "node:path"; import { acquireTestRunLock, resolveWrappedTestRunLockPath, @@ -34,52 +34,6 @@ export const LIVE_INSTALL_CREDENTIAL_ENV = [ "OCX_API_TOKEN_FILE", ] as const; -export const TEST_PS_MODULE_ANALYSIS_SEED_ENV = "OCX_TEST_PS_MODULE_ANALYSIS_SEED"; - -export function pinPowerShellModuleAnalysisCache( - root: string, - baseEnv: Record, - platform: NodeJS.Platform = process.platform, -): { set: Record; drop: string[] } { - if (platform !== "win32") return { set: {}, drop: [] }; - const drop = Object.keys(baseEnv).filter(key => key.toLowerCase() === "psmoduleanalysiscachepath"); - const explicitSeed = baseEnv[TEST_PS_MODULE_ANALYSIS_SEED_ENV]; - const localAppData = Object.entries(baseEnv).find(([key]) => key.toLowerCase() === "localappdata")?.[1]; - const seed = explicitSeed && (win32.isAbsolute(explicitSeed) || isAbsolute(explicitSeed)) - ? explicitSeed - : drop.map(key => baseEnv[key]).find(value => !!value) - ?? (localAppData ? join(localAppData, "Microsoft", "Windows", "PowerShell", "ModuleAnalysisCache") : undefined); - const directory = join(root, "ps-module-analysis-cache"); - const owned = join(directory, "ModuleAnalysisCache"); - mkdirSync(directory, { recursive: true }); - // Fresh/redirected LOCALAPPDATA costs 5.1 autoload 20–27s per child (devlog/_fin/ - // 260906_release_244_publish/020_integrate.md). Product spawns only System32 WindowsPowerShell - // 5.1; pwsh shares this path and at worst rebuilds. Each sandbox owns its writable copy: - // concurrent sandboxes never share it, and the developer/runner seed is never written. - if (seed) { - try { - const source = lstatSync(seed); - if (source.isFile() && !source.isSymbolicLink()) { - copyFileSync(seed, owned); - if (lstatSync(owned).size !== source.size || lstatSync(seed).size !== source.size) { - rmSync(owned, { force: true }); - } - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (!code || !["ENOENT", "ESTALE", "EACCES", "EPERM"].includes(code)) throw error; - rmSync(owned, { force: true }); - } - } - return { - set: { - PSModuleAnalysisCachePath: owned, - ...(explicitSeed !== undefined ? { [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: explicitSeed } : {}), - }, - drop, - }; -} - export function createIsolatedTestEnvironment( baseEnv: Record = process.env, ): IsolatedTestEnvironment { @@ -113,14 +67,11 @@ export function createIsolatedTestEnvironment( writeTestTempOwner(root, baseEnv[TEST_RUN_ID_ENV]); const inherited = { ...baseEnv }; for (const name of LIVE_INSTALL_CREDENTIAL_ENV) delete inherited[name]; - const moduleAnalysisCache = pinPowerShellModuleAnalysisCache(root, baseEnv); - for (const name of moduleAnalysisCache.drop) delete inherited[name]; return { root, env: { ...inherited, - ...moduleAnalysisCache.set, // Captured BEFORE HOME is overwritten: once the child starts with a rewritten // HOME, `homedir()` returns the sandbox, so this hand-off is the only way the // real-home write guard can still know which path to protect. diff --git a/structure/ops/cross-platform-ci.md b/structure/ops/cross-platform-ci.md index 15ecdd6bb3d..203c20aa7a0 100644 --- a/structure/ops/cross-platform-ci.md +++ b/structure/ops/cross-platform-ci.md @@ -16,7 +16,7 @@ Windows-sensitive pull request, on the nightly schedule, and on `workflow_dispat `lane=all` (or an empty lane); it never joins the push run `release.yml` gates on. A pull request is Windows-sensitive when the `changes` job's `windows` path filter matches or an added `src/`/`tests/` line names a Windows marker; `scripts/ci/windows-sensitive-diff.sh` makes that call, -treating the diff as data and selecting Windows when it cannot read the base parent. On a pull request the shards are advisory (shown, not required by `ci`) until the hosted Windows suite is free of timing flakes; dispatch and the schedule require them. The daily +treating the diff as data and selecting Windows when it cannot read the base parent. Wherever they run, `ci` requires them. The daily `schedule` runs main's workflow (GitHub schedules only the default branch) against one dev SHA the `changes` job resolves and every checkout reads, and requests the macOS control as well. An aggregate green `ci` check legitimately includes deliberate skips for every job the event did not @@ -69,10 +69,9 @@ longer report as a deliberate skip. Whenever Windows is requested the gate addit run's own job list through the Actions API and requires nine concrete successful `windows N/9` results, because a matrix rollup can report `success` when one matrix leg is skipped. A release that requires Windows proof still dispatches it for the exact publish SHA. -Before its tests each Windows shard prewarms a Windows PowerShell 5.1 module-analysis cache -seed and exports `OCX_TEST_PS_MODULE_ANALYSIS_SEED`; `createIsolatedTestEnvironment` in -`scripts/test.ts` copies that seed (or the inherited cache) into each sandbox and pins -`PSModuleAnalysisCachePath`, so a redirected profile no longer rebuilds the cache in every child. +Test sandboxes keep the runner's `LOCALAPPDATA`, so Windows PowerShell 5.1 children reuse the +image's warm module-analysis cache. Replacing it with a freshly built seed made every child +re-analyze modules and timed out seven shards on the first run of #6670. Startup ACL reads use .NET, never module-autoloaded `Get-Acl`/`Set-Acl` (`tests/ci-workflows/ci-review-lanes.test.ts` scans `src/`). Across the jobs, the workflow runs: diff --git a/tests/ci-workflows/ci-review-lanes.test.ts b/tests/ci-workflows/ci-review-lanes.test.ts index 1a12eab958d..b19a8d80a56 100644 --- a/tests/ci-workflows/ci-review-lanes.test.ts +++ b/tests/ci-workflows/ci-review-lanes.test.ts @@ -363,7 +363,7 @@ describe("Windows and nightly CI contracts", () => { const slice = script.slice(start, last + "RESULTS_EOF".length); for (const [event, lane, windows, expectWindows, expectControl] of [ ["schedule", "", undefined, "requested", "requested"], - ["pull_request", "", "true", "advisory", "not-requested"], + ["pull_request", "", "true", "requested", "not-requested"], ["pull_request", "", "false", "not-requested", "not-requested"], ["pull_request", "", undefined, "not-requested", "not-requested"], ["push", "", "true", "not-requested", "not-requested"], diff --git a/tests/ci-workflows/test-runner.test.ts b/tests/ci-workflows/test-runner.test.ts index 0d3496d5569..db18e231f3b 100644 --- a/tests/ci-workflows/test-runner.test.ts +++ b/tests/ci-workflows/test-runner.test.ts @@ -21,13 +21,11 @@ import { LIVE_INSTALL_CREDENTIAL_ENV, ensureGuiDependencies, inspectChangedRun, - pinPowerShellModuleAnalysisCache, resolveBunTestArgs, resolveBunTestPlan, runTestLane, selectChangedComparisonRef, SERIAL_FULL_SUITE_FILES, - TEST_PS_MODULE_ANALYSIS_SEED_ENV, } from "../../scripts/test"; import { NESTED_LIVE_LOCK_RECEIPT_KEY, @@ -124,112 +122,6 @@ function initChangedRunFixture(): { cwd: string; base: string } { return { cwd, base }; } -describe("PowerShell module analysis cache pin", () => { - test("does nothing outside Windows", () => { - const root = mkdtempSync(join(tmpdir(), "opencodex-ps-cache-")); - try { - for (const platform of ["linux", "darwin"] as const) { - expect(pinPowerShellModuleAnalysisCache(root, { PSModuleAnalysisCachePath: "seed" }, platform)) - .toEqual({ set: {}, drop: [] }); - } - expect(existsSync(join(root, "ps-module-analysis-cache"))).toBe(false); - } finally { - removeTreeWithRetry(root); - } - }); - - test("copies the explicit seed into an owned writable file without changing the seed", () => { - const root = mkdtempSync(join(tmpdir(), "opencodex-ps-cache-")); - const seed = join(root, "seed"); - writeFileSync(seed, "warmed analysis cache"); - try { - const result = pinPowerShellModuleAnalysisCache(root, { - [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: seed, - PSModuleAnalysisCachePath: join(root, "other-seed"), - }, "win32"); - const owned = join(root, "ps-module-analysis-cache", "ModuleAnalysisCache"); - expect(result.set).toEqual({ - PSModuleAnalysisCachePath: owned, [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: seed, - }); - expect(readFileSync(owned, "utf8")).toBe("warmed analysis cache"); - writeFileSync(owned, "sandbox rewrite"); - expect(readFileSync(seed, "utf8")).toBe("warmed analysis cache"); - } finally { - removeTreeWithRetry(root); - } - }); - - test("uses an inherited case variant as seed and drops every variant", () => { - const root = mkdtempSync(join(tmpdir(), "opencodex-ps-cache-")); - const seed = join(root, "seed"); - writeFileSync(seed, "inherited cache"); - try { - const result = pinPowerShellModuleAnalysisCache(root, { - PSMODULEANALYSISCACHEPATH: seed, - PSModuleAnalysisCachePath: seed, - [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: "relative-seed", - }, "win32"); - expect(result.drop).toEqual(["PSMODULEANALYSISCACHEPATH", "PSModuleAnalysisCachePath"]); - expect(readFileSync(result.set.PSModuleAnalysisCachePath!, "utf8")).toBe("inherited cache"); - expect(result.set[TEST_PS_MODULE_ANALYSIS_SEED_ENV]).toBe("relative-seed"); - } finally { - removeTreeWithRetry(root); - } - }); - - test("pins a missing seed path so the first child can warm its own cache", () => { - const root = mkdtempSync(join(tmpdir(), "opencodex-ps-cache-")); - try { - const result = pinPowerShellModuleAnalysisCache(root, { - [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: join(root, "missing"), - }, "win32"); - expect(result.set.PSModuleAnalysisCachePath).toBe(join(root, "ps-module-analysis-cache", "ModuleAnalysisCache")); - expect(existsSync(dirname(result.set.PSModuleAnalysisCachePath!))).toBe(true); - expect(existsSync(result.set.PSModuleAnalysisCachePath!)).toBe(false); - } finally { - removeTreeWithRetry(root); - } - }); - - test("two sandbox roots own independent copies of the same seed", () => { - const root = mkdtempSync(join(tmpdir(), "opencodex-ps-cache-")); - const seed = join(root, "seed"); - writeFileSync(seed, "shared read-only seed"); - try { - const baseEnv = { [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: seed }; - const first = pinPowerShellModuleAnalysisCache(join(root, "first"), baseEnv, "win32").set.PSModuleAnalysisCachePath!; - const second = pinPowerShellModuleAnalysisCache(join(root, "second"), baseEnv, "win32").set.PSModuleAnalysisCachePath!; - expect(first).not.toBe(second); - writeFileSync(first, "first sandbox"); - expect(readFileSync(second, "utf8")).toBe("shared read-only seed"); - expect(readFileSync(seed, "utf8")).toBe("shared read-only seed"); - } finally { - removeTreeWithRetry(root); - } - }); - - test("does not copy a symlink seed", () => { - const root = mkdtempSync(join(tmpdir(), "opencodex-ps-cache-")); - const target = join(root, "target"); - const seed = join(root, "seed-link"); - writeFileSync(target, "target cache"); - try { - try { - symlinkSync(target, seed, "file"); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (process.platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "ENOTSUP")) return; - throw error; - } - const result = pinPowerShellModuleAnalysisCache(root, { [TEST_PS_MODULE_ANALYSIS_SEED_ENV]: seed }, "win32"); - expect(existsSync(result.set.PSModuleAnalysisCachePath!)).toBe(false); - expect(readFileSync(target, "utf8")).toBe("target cache"); - } finally { - removeTreeWithRetry(root); - } - }); -}); - describe("test runner captured output", () => { // This describe's first real lane pays Bun's test-runner bootstrap inside its child timeout. // Replay one trivial lane in setup because an import scan cannot warm Bun's runner startup. From add55c38b2add07e783a263b1371ba5c26aaa773 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 19:23:26 +0900 Subject: [PATCH 5/7] test(settings): size isolated child budgets with the CI watchdog The isolated settings children cold-import the management API graph. On Windows CI that takes 2-5 s, and the first child of a busy batch took over 10 s in run 37447563941, so the flat 10 s spawnSync bound killed a healthy child. Use watchdogMs like the other subprocess suites, and derive the per-test bound from it: spawnSync blocks the loop, so the test has to outlast the child it waits on. Local runs keep the old 10 s / 15 s values. --- .../settings-desktop-switch-apply.test.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/config/settings-desktop-switch-apply.test.ts b/tests/config/settings-desktop-switch-apply.test.ts index 297189fffe6..b7248eed3a0 100644 --- a/tests/config/settings-desktop-switch-apply.test.ts +++ b/tests/config/settings-desktop-switch-apply.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readBoundedCodexConfig } from "../../src/codex/inject/bounded-config-reader"; +import { watchdogMs } from "../helpers/ci-watchdog"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot } from "../helpers/repo-root"; @@ -11,6 +12,12 @@ import { repoRoot } from "../helpers/repo-root"; // binds CODEX_CONFIG_PATH to CODEX_HOME at module load, so each case runs in a child // whose env is fixed before the module graph loads. The child timeout stays below // the test timeout so a wedged child fails as a timeout, not a hanging test. +// Each child cold-imports the management API graph: 2-5 s on a Windows CI shard, +// and over 10 s for the first child of a busy batch (run 37447563941), so a flat +// 10 s bound reported contention rather than a wedge. +const ISOLATED_CHILD_TIMEOUT_MS = watchdogMs(10_000); +// spawnSync blocks the loop, so the per-test bound has to outlast the child it waits on. +const ISOLATED_TEST_TIMEOUT_MS = ISOLATED_CHILD_TIMEOUT_MS + 5_000; const ISOLATED_PROVIDER_CONFIG = { port: 10100, defaultProvider: "openai", @@ -48,7 +55,7 @@ function runIsolatedSettingsRequest(options: { OCX_TEST_ROUTE_CONFIG: JSON.stringify(options.routeConfig), }, encoding: "utf8", - timeout: 10_000, + timeout: ISOLATED_CHILD_TIMEOUT_MS, }); if (child.status !== 0) { // A timeout or spawn failure leaves no output; surface status/signal/error @@ -79,7 +86,7 @@ function runIsolatedCodexScript(options: { OPENCODEX_HOME: join(options.root, "opencodex"), }, encoding: "utf8", - timeout: 10_000, + timeout: ISOLATED_CHILD_TIMEOUT_MS, }); if (child.status !== 0) { const cause = child.error ? ` (${child.error.name}: ${child.error.message})` : ""; @@ -203,7 +210,7 @@ test("GET /api/settings reports external Codex ownership without an apply attemp } finally { removeTreeWithRetry(root); } -}, 15_000); +}, ISOLATED_TEST_TIMEOUT_MS); test("GET /api/settings survives an unreadable config.toml during ownership detection", () => { // existsSync passes but readFileSync throws: config.toml as a directory is a @@ -242,7 +249,7 @@ test("GET /api/settings survives an unreadable config.toml during ownership dete } finally { removeTreeWithRetry(root); } -}, 15_000); +}, ISOLATED_TEST_TIMEOUT_MS); test.skipIf(process.platform === "win32")( "GET /api/settings refuses a config.toml FIFO without blocking", @@ -326,7 +333,7 @@ test("PUT /api/settings keeps the undetermined-ownership explanation on a locked } finally { removeTreeWithRetry(root); } -}, 15_000); +}, ISOLATED_TEST_TIMEOUT_MS); test("readBoundedCodexConfig returns null only for a config absent at lookup", () => { const root = mkdtempSync(join(tmpdir(), "ocx-bounded-reader-")); @@ -454,7 +461,7 @@ test("native restore tolerates a config.toml over the observation bound", () => } finally { removeTreeWithRetry(root); } -}, 15_000); +}, ISOLATED_TEST_TIMEOUT_MS); test("PUT /api/settings reports external Codex ownership when the integration is disabled", () => { // clientIntegrations.codex = false trips the apply gate before the injector runs, so @@ -498,7 +505,7 @@ test("PUT /api/settings reports external Codex ownership when the integration is } finally { removeTreeWithRetry(root); } -}, 15_000); +}, ISOLATED_TEST_TIMEOUT_MS); test("post-gate injector read failure retains undetermined ownership and null effective state", () => { const root = mkdtempSync(join(tmpdir(), "ocx-settings-post-gate-")); @@ -529,4 +536,4 @@ test("post-gate injector read failure retains undetermined ownership and null ef authSource: { presentsCodexAccount: null }, } }); } finally { removeTreeWithRetry(root); } -}, 15000); +}, ISOLATED_TEST_TIMEOUT_MS); From 914357e68bd34c89cc7ce246db0c5a830a5681f7 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 19:24:16 +0900 Subject: [PATCH 6/7] test(native-main): budget the owner child's cold start separately The contender case failed twice on Windows dev dispatches (37316384711, 37447563941) with "child event timeout after 45000ms; events=[]". The test finished at 55.6 s, which is only possible if that same child answered the cleanup "stop" command within seconds of the timeout; a wedged child would have held stop() for another 45 s. So the child was slow to reach "listening" while cold-importing the server graph on a busy shard, and then healthy. Give the listening waits twice the event budget and add one startup allowance to the per-test budget; every later wait is unchanged. --- .../native-main-owner-lifetime.test.ts | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/tests/codex-integration/native-main-owner-lifetime.test.ts b/tests/codex-integration/native-main-owner-lifetime.test.ts index 96fe8fb4249..018afa750ad 100644 --- a/tests/codex-integration/native-main-owner-lifetime.test.ts +++ b/tests/codex-integration/native-main-owner-lifetime.test.ts @@ -159,12 +159,19 @@ function successorConfig(f: Fixture, configName: string, includePool: boolean): // contention. `watchdogMs` is the repository's existing answer to exactly this. const OWNER_EVENT_WAIT_MS = watchdogMs(10_000); +// Reaching "listening" includes the child's cold import of the server graph. On a busy +// Windows shard that once took over 45 s for the first child of a batch, and the same +// child then answered "stop" within seconds (dev runs 37316384711, 37447563941): slow +// startup, not a wedge. Startup gets twice the event budget; every later wait keeps it. +const OWNER_STARTUP_WAIT_MS = OWNER_EVENT_WAIT_MS * 2; + // The lease cases perform several of those waits back to back. The multi-server // case spawns two children and walks four ownership transitions, and it was // CANCELLED at 30,172ms against a flat 30s budget -- the budget expired mid-test, // so no assertion ever reported. Derive it from the deadline so the two cannot // drift apart again. -const OWNER_LEASE_BUDGET_MS = Math.max(30_000, OWNER_EVENT_WAIT_MS * 4); +// One slow cold start is budgeted on top of the four event waits. +const OWNER_LEASE_BUDGET_MS = Math.max(30_000, OWNER_EVENT_WAIT_MS * 4 + OWNER_STARTUP_WAIT_MS); async function waitUntil(probe: () => T | null, timeoutMs = OWNER_EVENT_WAIT_MS): Promise { const deadline = Date.now() + timeoutMs; @@ -433,16 +440,16 @@ describe("native-main process owner lease", () => { let second: ChildHarness | undefined; let support: ChildHarness | undefined; try { - await first.waitFor(event => event.event === "listening"); + await first.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await first.snapshot(isHeldReady); second = new ChildHarness(secondFixture); - await second.waitFor(event => event.event === "listening"); + await second.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await second.snapshot(isContended); const supportConfig = join(firstFixture.root, "owner-support"); writeConfig(firstFixture.codexHome, supportConfig); support = new ChildHarness({ ...firstFixture, configDir: supportConfig }); - await support.waitFor(event => event.event === "listening"); + await support.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await support.snapshot(isContended); expect((await support.command("set-mode", { mode: "pool" })).ok).toBe(true); const pool = await support.command("request", { kind: "pool" }); @@ -489,7 +496,7 @@ describe("native-main process owner lease", () => { const owner = new ChildHarness(f, { NATIVE_OWNER_HOLD_SWITCH_BOUNDARY: "auth-replaced" }); let successor: ChildHarness | undefined; try { - const listening = await owner.waitFor(event => event.event === "listening"); + const listening = await owner.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await owner.snapshot(isHeldReady); const switchRequest = fetch(`http://127.0.0.1:${Number(listening.port)}/api/native-main-profiles/switch`, { method: "POST", @@ -500,7 +507,7 @@ describe("native-main process owner lease", () => { expect(probeNativeProfileRecoveryState(f.manager.context)).toBe("journal"); successor = new ChildHarness(successorFixture); - await successor.waitFor(event => event.event === "listening"); + await successor.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await successor.snapshot(isContended); const before = await successor.command("snapshot"); const blockedMain = await successor.command("request", { kind: "main" }); @@ -532,7 +539,7 @@ describe("native-main process owner lease", () => { const owner = new ChildHarness(f, { NATIVE_OWNER_HOLD_AUTH_TEMP: "1" }); let successor: ChildHarness | undefined; try { - const listening = await owner.waitFor(event => event.event === "listening"); + const listening = await owner.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await owner.snapshot(isHeldReady); const switchRequest = fetch(`http://127.0.0.1:${Number(listening.port)}/api/native-main-profiles/switch`, { method: "POST", @@ -553,7 +560,7 @@ describe("native-main process owner lease", () => { expect(probeNativeProfileRecoveryState(f.manager.context)).toBe("journal"); successor = new ChildHarness(successorConfig(f, "temp-crash-successor", false), { NATIVE_OWNER_HOLD_RECOVERY: "1" }); - await successor.waitFor(event => event.event === "listening"); + await successor.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await successor.snapshot(isContended); await owner.hardKill(); await switchRequest; @@ -586,7 +593,7 @@ describe("native-main process owner lease", () => { linkSync(target, residue); const child = new ChildHarness(f); try { - await child.waitFor(event => event.event === "listening"); + await child.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); const blocked = await child.snapshot(event => { const owner = event.owner as { status?: string } | undefined; const gate = event.gate as { status?: string; reason?: string } | undefined; @@ -610,13 +617,13 @@ describe("native-main process owner lease", () => { const owner = new ChildHarness(f); let contender: ChildHarness | undefined; try { - await owner.waitFor(event => event.event === "listening"); + await owner.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await owner.snapshot(isHeldReady); const alias = resolve(f.codexHome, ".", "..", "codex", "."); const extra = await owner.command("start-extra-server", { codexHomeAlias: alias }); expect(extra.ok).toBe(true); contender = new ChildHarness(contenderFixture); - await contender.waitFor(event => event.event === "listening"); + await contender.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await contender.snapshot(isContended); expect((await owner.command("stop-server", { index: 0 })).ok).toBe(true); From 74ec1f8b4edc40c197cbd5661f6aac52654915b9 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 19:47:49 +0900 Subject: [PATCH 7/7] test(claude-picker): size restart child budgets with the CI watchdog "fresh production restarts retain fingerprint" failed at exactly 10 s on Windows in three runs (37360604391, 37443819560, 37449564955): its first child cold-imports the intercept runtime and mints the picker CA under a flat 10 s spawnSync bound, while a neighbouring case in the same file takes 8.5 s on that runner. Use watchdogMs for every child here, bound the previously unbounded predecessor child too, and give each multi-child test a per-test bound that covers all of its children. --- .../claude-picker-startup.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/claude-integration/claude-picker-startup.test.ts b/tests/claude-integration/claude-picker-startup.test.ts index 355bcca97f8..9603173247e 100644 --- a/tests/claude-integration/claude-picker-startup.test.ts +++ b/tests/claude-integration/claude-picker-startup.test.ts @@ -10,6 +10,7 @@ import { startConnectProxy } from "../../src/claude/intercept/connect-proxy"; import { createCertificateAuthority } from "../../src/claude/intercept/local-ca"; import { PICKER_CA_COMMON_NAME, PICKER_HOST } from "../../src/claude/intercept/picker-ca"; import { pickerCaCertPath, pickerCaFingerprints } from "../../src/claude/intercept/picker-ca"; +import { watchdogMs } from "../helpers/ci-watchdog"; import { memoryPickerCaStore } from "../helpers/picker-ca-store"; import { saveConfig } from "../../src/config"; import type { OcxConfig } from "../../src/types"; @@ -34,6 +35,12 @@ function setup() { return { root, config }; } +// Each restart child cold-imports the intercept runtime and the first one mints the picker +// CA. A neighbouring case takes 8.5 s on a Windows shard, and the first child here passed a +// flat 10 s bound in three dev/PR runs (37360604391, 37443819560, 37449564955). spawnSync +// blocks the loop, so each multi-child test's own bound covers all of its children. +const PICKER_CHILD_TIMEOUT_MS = watchdogMs(10_000); + test("Windows and Linux skip all picker effects while still discarding legacy key", async () => { for (const platform of ["linux", "win32"] as const) { const { root, config } = setup(); @@ -134,7 +141,7 @@ test("fresh production restarts retain fingerprint and issue no trust mutations, await handle.stop(); process.stdout.write(JSON.stringify({ fingerprint, mutations, effective: status.effective, reason: status.reason })); `], env: { ...process.env, HOME: root, OPENCODEX_HOME: root, CLAUDE_CONFIG_DIR: join(root, "claude"), - OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(root, "desktop") }, stdout: "pipe", stderr: "pipe", timeout: 10_000 }); + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(root, "desktop") }, stdout: "pipe", stderr: "pipe", timeout: PICKER_CHILD_TIMEOUT_MS }); expect(child.exitCode, child.stderr.toString()).toBe(0); return JSON.parse(child.stdout.toString()) as { fingerprint: string; mutations: string[]; effective: boolean; reason: string }; }; @@ -143,7 +150,7 @@ test("fresh production restarts retain fingerprint and issue no trust mutations, expect(second).toEqual(first); expect(lost).toMatchObject({ fingerprint: first.fingerprint, mutations: [], effective: false, reason: "trust_pending" }); expect(first.mutations).toEqual([]); -}); +}, PICKER_CHILD_TIMEOUT_MS * 3 + 5_000); test("late first-party enable migrates a dead ephemeral predecessor before persistent activation", () => { const { root } = setup(); @@ -153,7 +160,7 @@ test("late first-party enable migrates a dead ephemeral predecessor before persi const prior = Bun.spawnSync({ cmd: [process.execPath, "-e", ` import { ensurePickerCa } from ${JSON.stringify(caUrl)}; process.stdout.write(ensurePickerCa(${JSON.stringify(root)}).fingerprint); - `], stdout: "pipe", stderr: "pipe" }); + `], stdout: "pipe", stderr: "pipe", timeout: PICKER_CHILD_TIMEOUT_MS }); expect(prior.exitCode, prior.stderr.toString()).toBe(0); const child = Bun.spawnSync({ cmd: [process.execPath, "-e", ` import { readFileSync } from "node:fs"; @@ -181,14 +188,14 @@ test("late first-party enable migrates a dead ephemeral predecessor before persi await controller.enable({ persist: false, context: "server", allowTrustPrompt: false }); process.stdout.write(JSON.stringify({ removed, oldSha, writes: fake.writes, pending: readPendingPickerCaUntrust(root), fingerprint: pickerCaFingerprints(readFileSync(pickerCaCertPath(root), "utf8")).sha256 })); - `], stdout: "pipe", stderr: "pipe", timeout: 10_000 }); + `], stdout: "pipe", stderr: "pipe", timeout: PICKER_CHILD_TIMEOUT_MS }); expect(child.exitCode, child.stderr.toString()).toBe(0); const result = JSON.parse(child.stdout.toString()); expect(result.removed).toEqual([result.oldSha]); expect(result.writes).toBe(1); expect(result.pending).toBeNull(); expect(result.fingerprint).not.toBe(prior.stdout.toString()); -}); +}, PICKER_CHILD_TIMEOUT_MS * 2 + 5_000); test("default, off, and disabled integration remain credential-free through status, refresh, and off", async () => { for (const claudeCode of [undefined, { desktopMode: "first-party", intercept: { picker: false } }, { desktopMode: "first-party" }]) {