From a5c566f807ad4d7dff5401aace37bff41c950879 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 18 Oct 2026 13:52:54 +0700 Subject: [PATCH] Make full CI finish sooner: eight browser shards, split Windows server job, cancel superseded runs (#7275) Eight Playwright shards with two workers each, format/lint/relay/sdk combined into one routed quality job, Windows server integration in its own job, cancel-in-progress on every ref, one vitest start for the filtered integration files, no fan-in gate, and a deadline for the heap-bound directory search test that covers its 27,000-directory fixture on Windows. First run on the Team plan: 19m48s from creation to last job with zero queue, all 21 jobs green, 224 runner-minutes. The same workflow under the old 20-job cap finished in 58 minutes with up to 40 minutes of queue per job. --- .github/workflows/ci.yml | 253 +++++++++++------- packages/server/package.json | 2 +- .../src/utils/directory-suggestions.test.ts | 6 +- packages/server/vitest.config.ts | 3 - scripts/ci-workflow.test.mjs | 204 +++++++++++++- 5 files changed, 359 insertions(+), 109 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e3f65f336..a2cbb0c29a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,9 +8,13 @@ on: merge_group: workflow_dispatch: +# A newer push to the same ref cancels the run still in progress, main included. +# A full run costs about 200 runner-minutes against the org's 20 concurrent +# jobs, and a superseded main run only answers a question the newer commit +# answers again. concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true env: # CI does not use the CUDA execution provider, and the onnxruntime-node @@ -54,10 +58,10 @@ jobs: - name: Validate CI contracts run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs - format: - name: format + quality: + name: quality needs: changes - if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false' || needs.changes.outputs.quality != 'false' || needs.changes.outputs.relay != 'false' || needs.changes.outputs.sdk != 'false') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -68,36 +72,63 @@ jobs: cache: "npm" - name: Install dependencies + id: install run: node scripts/npm-retry.mjs ci + # Explicit status checks keep independent domains running after a failure. + # A failed step still fails quality; no continue-on-error or success shim. - name: Check formatting + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }} run: npm run format:check - lint: - name: lint - needs: changes - if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: "npm" - - - name: Install dependencies - run: node scripts/npm-retry.mjs ci - - name: Lint lockfile + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity - name: Verify dependency signatures + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} run: npm audit signatures - name: Lint + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} run: npm run lint + - name: Build relay + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} + run: npm run build:relay + + - name: Run relay tests + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} + run: npm run test --workspace=@getpaseo/relay + + - name: Build client dependencies + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run build:relay && npm run build:client + + - name: Build plugin SDK + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run build:plugin + + - name: Run plugin SDK tests + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run test --workspace=@getpaseo/plugin + + - name: Run protocol tests + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run test --workspace=@getpaseo/protocol + + - name: Run client tests + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run test --workspace=@getpaseo/client + + - name: Run highlight tests + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run test --workspace=@getpaseo/highlight + + - name: Typecheck client examples + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run typecheck:examples --workspace=@getpaseo/client + typecheck: name: typecheck needs: changes @@ -143,43 +174,56 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} runs-on: ubuntu-latest - steps: &server_test_steps - - uses: actions/checkout@v4 + steps: + - &server_capacity + name: Log runner capacity + run: | + node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))" + + - &server_checkout + uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-node@v4 + - &server_node + uses: actions/setup-node@v4 with: node-version: "22" cache: "npm" - - name: Fetch origin/main (worktree tests) + - &server_fetch + name: Fetch origin/main (worktree tests) if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: git fetch --no-tags origin main:refs/remotes/origin/main - - name: Install dependencies + - &server_install + name: Install dependencies run: node scripts/npm-retry.mjs ci - - name: Install agent CLIs for provider tests + - &server_agent_clis + name: Install agent CLIs for provider tests if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai - - name: Build server dependencies + - &server_build + name: Build server dependencies run: npm run build:server-deps - name: Run server tests if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: npm run test --workspace=@getpaseo/server - env: + env: &server_test_env CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - - name: Run built-in plugin tests + - &server_plugin_tests + name: Run built-in plugin tests if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: npm run test --workspace=@getpaseo/builtin-plugins - - name: Run Hub CLI/daemon contract + - &server_hub_contract + name: Run Hub CLI/daemon contract if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }} run: npm run test:hub-cli-contract --workspace=@getpaseo/server @@ -188,7 +232,41 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} runs-on: windows-latest - steps: *server_test_steps + steps: + - *server_capacity + - *server_checkout + - *server_node + - *server_fetch + - *server_install + - *server_agent_clis + - *server_build + + - name: Run server unit tests + if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} + run: npm run test:unit --workspace=@getpaseo/server + env: *server_test_env + + - *server_plugin_tests + - *server_hub_contract + + # Keep the serial integration chain off the Windows unit critical path. + server-integration-windows: + name: server-integration (windows-latest) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} + runs-on: windows-latest + steps: + - *server_capacity + - *server_checkout + - *server_node + - *server_fetch + - *server_install + - *server_agent_clis + - *server_build + + - name: Run server integration tests + run: npm run test:integration --workspace=@getpaseo/server + env: *server_test_env # native-recursive.ts serves darwin and win32, but windows-latest above is the only # runner that exercises it. macOS — the platform the reported watcher bug was on — @@ -332,52 +410,19 @@ jobs: - name: Run app unit tests run: npm run test --workspace=@getpaseo/app - sdk-tests: - name: sdk-tests - needs: changes - if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: "npm" - - - name: Install dependencies - run: node scripts/npm-retry.mjs ci - - - name: Build client dependencies - run: npm run build:relay && npm run build:client - - - name: Build plugin SDK - run: npm run build:plugin - - - name: Run plugin SDK tests - run: npm run test --workspace=@getpaseo/plugin - - - name: Run protocol tests - run: npm run test --workspace=@getpaseo/protocol - - - name: Run client tests - run: npm run test --workspace=@getpaseo/client - - - name: Run highlight tests - run: npm run test --workspace=@getpaseo/highlight - - - name: Typecheck client examples - run: npm run typecheck:examples --workspace=@getpaseo/client - playwright-1: - name: playwright (shard 1/4) + name: playwright (shard 1/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - PLAYWRIGHT_SHARD: "1/4" + PLAYWRIGHT_SHARD: "1/8" PLAYWRIGHT_ARTIFACT: "1" steps: &playwright_test_steps + - name: Log runner capacity + run: | + node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))" + - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -405,9 +450,9 @@ jobs: run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - # Weight file groups by observed duration; raw test counts overload - # early agent/Changes specs. Required check names remain unchanged. - PWTEST_SHARD_WEIGHTS: "111:172:137:160" + E2E_WORKERS: "2" + # File groups are weighted by observed reporter durations. + PWTEST_SHARD_WEIGHTS: "93:100:101:104:91:94:122:110" - name: Upload test artifacts uses: actions/upload-artifact@v4 @@ -420,56 +465,74 @@ jobs: retention-days: 7 playwright-2: - name: playwright (shard 2/4) + name: playwright (shard 2/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - PLAYWRIGHT_SHARD: "2/4" + PLAYWRIGHT_SHARD: "2/8" PLAYWRIGHT_ARTIFACT: "2" steps: *playwright_test_steps playwright-3: - name: playwright (shard 3/4) + name: playwright (shard 3/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - PLAYWRIGHT_SHARD: "3/4" + PLAYWRIGHT_SHARD: "3/8" PLAYWRIGHT_ARTIFACT: "3" steps: *playwright_test_steps playwright-4: - name: playwright (shard 4/4) + name: playwright (shard 4/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - PLAYWRIGHT_SHARD: "4/4" + PLAYWRIGHT_SHARD: "4/8" PLAYWRIGHT_ARTIFACT: "4" steps: *playwright_test_steps - relay-tests: - name: relay-tests + playwright-5: + name: playwright (shard 5/8) needs: changes - if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + env: + PLAYWRIGHT_SHARD: "5/8" + PLAYWRIGHT_ARTIFACT: "5" + steps: *playwright_test_steps - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: "npm" + playwright-6: + name: playwright (shard 6/8) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} + runs-on: ubuntu-latest + env: + PLAYWRIGHT_SHARD: "6/8" + PLAYWRIGHT_ARTIFACT: "6" + steps: *playwright_test_steps - - name: Install dependencies - run: node scripts/npm-retry.mjs ci + playwright-7: + name: playwright (shard 7/8) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} + runs-on: ubuntu-latest + env: + PLAYWRIGHT_SHARD: "7/8" + PLAYWRIGHT_ARTIFACT: "7" + steps: *playwright_test_steps - - name: Build relay - run: npm run build:relay - - - name: Run relay tests - run: npm run test --workspace=@getpaseo/relay + playwright-8: + name: playwright (shard 8/8) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} + runs-on: ubuntu-latest + env: + PLAYWRIGHT_SHARD: "8/8" + PLAYWRIGHT_ARTIFACT: "8" + steps: *playwright_test_steps cli-tests-1: name: cli-tests (shard 1/3) diff --git a/packages/server/package.json b/packages/server/package.json index ec2d4cbe65..49549cdff3 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index a7d5d5e9eb..72c77a4ce2 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -1121,6 +1121,10 @@ describe("home-tree scan cost", () => { return stopPathContainmentMetrics(); } + // Creating the 27,000-directory fixture alone takes about 20 s on Windows + // runners under parallel load, and it has crossed the default 30 s deadline. + // The heap bound is enforced by the child's --max-old-space-size and its own + // 20 s timeout, so the test deadline only has to cover fixture setup. it("finishes overlapping broad-tree searches within a bounded heap", async () => { const broadRoot = path.join(scanRoot, "broad"); mkdirSync(broadRoot); @@ -1191,7 +1195,7 @@ describe("home-tree scan cost", () => { outOfMemory: false, }); expect(JSON.parse(stdout)).toEqual(Array.from({ length: 14 }, () => [])); - }); + }, 120_000); it("derives no containment for a tree that cannot leave the root", async () => { const checks = await countContainmentChecks(scanWholeTree); diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index c80345c79e..9f3717a9bc 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -19,9 +19,6 @@ export default defineConfig({ setupFiles: [path.resolve(__dirname, "./src/test-utils/vitest-setup.ts")], pool: "forks", fileParallelism: false, - // Even two concurrent Git-heavy suites can exhaust Windows test deadlines. - // Run files serially so their subprocesses do not compete across suites. - maxWorkers: process.platform === "win32" ? 1 : undefined, exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**", "**/.dev/**"], }, }); diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index d71fa6e184..7034ec4893 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync, readdirSync } from "node:fs"; import { relative as relativePath } from "node:path"; import test from "node:test"; +import { runInNewContext } from "node:vm"; const repoRoot = new URL("../", import.meta.url); const ciWorkflowPath = new URL(".github/workflows/ci.yml", repoRoot); @@ -12,21 +13,26 @@ const serverTsconfigPath = new URL("packages/server/tsconfig.server.json", repoR const desktopPackagePath = new URL("packages/desktop/package.json", repoRoot); const gatedCiJobs = new Map([ - ["format", { name: "format", contract: "format" }], - ["lint", { name: "lint", contract: "quality" }], + ["quality", { name: "quality", contracts: ["format", "quality", "relay", "sdk"] }], ["typecheck", { name: "typecheck", contract: "quality" }], ["server-tests-ubuntu", { name: "server-tests (ubuntu-latest)", contracts: ["server", "hub"] }], ["server-tests-windows", { name: "server-tests (windows-latest)", contracts: ["server", "hub"] }], + [ + "server-integration-windows", + { name: "server-integration (windows-latest)", contract: "server" }, + ], ["server-tests-macos", { name: "server-tests (macos-14, file observation)", contract: "server" }], ["desktop-tests-ubuntu", { name: "desktop-tests (ubuntu-latest)", contract: "desktop" }], ["desktop-tests-windows", { name: "desktop-tests (windows-latest)", contract: "desktop" }], ["app-tests", { name: "app-tests", contract: "app" }], - ["sdk-tests", { name: "sdk-tests", contract: "sdk" }], - ["playwright-1", { name: "playwright (shard 1/4)", contract: "browser" }], - ["playwright-2", { name: "playwright (shard 2/4)", contract: "browser" }], - ["playwright-3", { name: "playwright (shard 3/4)", contract: "browser" }], - ["playwright-4", { name: "playwright (shard 4/4)", contract: "browser" }], - ["relay-tests", { name: "relay-tests", contract: "relay" }], + ["playwright-1", { name: "playwright (shard 1/8)", contract: "browser" }], + ["playwright-2", { name: "playwright (shard 2/8)", contract: "browser" }], + ["playwright-3", { name: "playwright (shard 3/8)", contract: "browser" }], + ["playwright-4", { name: "playwright (shard 4/8)", contract: "browser" }], + ["playwright-5", { name: "playwright (shard 5/8)", contract: "browser" }], + ["playwright-6", { name: "playwright (shard 6/8)", contract: "browser" }], + ["playwright-7", { name: "playwright (shard 7/8)", contract: "browser" }], + ["playwright-8", { name: "playwright (shard 8/8)", contract: "browser" }], ["cli-tests-1", { name: "cli-tests (shard 1/3)", contract: "cli" }], ["cli-tests-2", { name: "cli-tests (shard 2/3)", contract: "cli" }], ["cli-tests-3", { name: "cli-tests (shard 3/3)", contract: "cli" }], @@ -36,7 +42,7 @@ function jobBlocks(source) { const jobs = new Map(); let currentJob; - for (const line of source.split("\n")) { + for (const line of (source.split("\njobs:\n")[1] ?? source).split("\n")) { const jobMatch = /^ ([a-z0-9_-]+):\s*$/.exec(line); if (jobMatch) { currentJob = jobMatch[1]; @@ -100,6 +106,12 @@ test("gated checks are statically named jobs with real job-level gating", () => }); test("change gating allows superseded workflow runs to cancel", () => { + const ci = readFileSync(ciWorkflowPath, "utf8"); + assert.match( + ci, + /^concurrency:\n group: ci-\$\{\{ github\.workflow \}\}-\$\{\{ github\.ref \}\}\n cancel-in-progress: true$/m, + "a newer push to any ref, main included, cancels the superseded run", + ); for (const workflowPath of [ciWorkflowPath, dockerWorkflowPath, nixWorkflowPath]) { const source = readFileSync(workflowPath, "utf8"); assert.doesNotMatch( @@ -331,3 +343,177 @@ test("stable iOS releases upload once and distribute to external testers before ); assert.match(review, /bundle exec fastlane ios submit_review/); }); + +test("eight Playwright shards share two-worker coverage", () => { + const source = readFileSync(ciWorkflowPath, "utf8"); + const jobs = jobBlocks(source); + assert.equal(jobs.size, 21); + for (let shard = 1; shard <= 8; shard++) { + const job = jobs.get(`playwright-${shard}`).join("\n"); + assert.match(job, new RegExp(`PLAYWRIGHT_SHARD: "${shard}/8"`)); + assert.match(job, new RegExp(`PLAYWRIGHT_ARTIFACT: "${shard}"`)); + assert.match( + job, + shard === 1 ? /steps: &playwright_test_steps/ : /steps: \*playwright_test_steps/, + ); + } + const shared = jobs.get("playwright-1").join("\n"); + assert.match(shared, /E2E_WORKERS: "2"/); + const weights = /PWTEST_SHARD_WEIGHTS: "([\d:]+)"/.exec(shared)[1].split(":").map(Number); + assert.equal(weights.length, 8); + assert.ok(weights.every((weight) => weight > 0)); + assert.match(shared, /npm run test:e2e --workspace=@getpaseo\/app -- --shard=/); + + // No fan-in result job: a gate needs its own runner and under contention it + // queued 8m08s behind the last shard. Required checks name the shards directly. + assert.equal(jobs.has("playwright"), false); +}); + +test("quality preserves commands and routes each domain independently after failures", () => { + const jobs = jobBlocks(readFileSync(ciWorkflowPath, "utf8")); + const quality = jobs.get("quality").join("\n"); + assert.doesNotMatch(quality, /^\s+continue-on-error:/m); + const commands = { + format: ["npm run format:check"], + quality: [ + "npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity", + "npm audit signatures", + "npm run lint", + ], + relay: ["npm run build:relay", "npm run test --workspace=@getpaseo/relay"], + sdk: [ + "npm run build:relay && npm run build:client", + "npm run build:plugin", + "npm run test --workspace=@getpaseo/plugin", + "npm run test --workspace=@getpaseo/protocol", + "npm run test --workspace=@getpaseo/client", + "npm run test --workspace=@getpaseo/highlight", + "npm run typecheck:examples --workspace=@getpaseo/client", + ], + }; + const checks = [...quality.matchAll(/ if: \$\{\{ (.+) \}\}\n run: (.+)/g)]; + assert.deepEqual( + checks.map(([, , command]) => command), + Object.values(commands).flat(), + ); + const domains = Object.keys(commands); + function accepts(expression, outputs, { cancelled = false, installed = true } = {}) { + // Prior checks have failed: success() is false. Explicit !cancelled() must + // override Actions' default success condition so later domains still run. + assert.match(expression, /!cancelled\(\)/); + return runInNewContext(expression, { + needs: { changes: { outputs } }, + steps: { install: { outcome: installed ? "success" : "failure" } }, + cancelled: () => cancelled, + success: () => false, + }); + } + const disabled = { full: "false", ...Object.fromEntries(domains.map((key) => [key, "false"])) }; + const jobCondition = / if: \$\{\{ (.+) \}\}/.exec(quality)[1]; + assert.equal(accepts(jobCondition, disabled), false); + for (const selected of domains) { + const outputs = { ...disabled, [selected]: "true" }; + assert.equal(accepts(jobCondition, outputs), true); + for (const [, expression, command] of checks) { + assert.equal(accepts(expression, outputs), commands[selected].includes(command), command); + } + } + for (const [, expression] of checks) { + assert.equal(accepts(expression, { ...disabled, full: "true" }), true); + assert.equal(accepts(expression, {}), true, "missing routing outputs fail open"); + assert.equal(accepts(expression, {}, { cancelled: true }), false); + assert.equal(accepts(expression, {}, { installed: false }), false); + } + assert.equal(accepts(jobCondition, {}, { cancelled: true }), false); +}); + +test("Windows units and integration run independently with complete routed coverage", () => { + const source = readFileSync(ciWorkflowPath, "utf8"); + const jobs = jobBlocks(source); + const ubuntu = jobs.get("server-tests-ubuntu").join("\n"); + const windows = jobs.get("server-tests-windows").join("\n"); + const integration = jobs.get("server-integration-windows").join("\n"); + assert.match(ubuntu, /run: npm run test --workspace=@getpaseo\/server/); + assert.match(windows, /run: npm run test:unit --workspace=@getpaseo\/server/); + assert.doesNotMatch(windows, /run: npm run test(?: |:integration)/); + assert.match(integration, /run: npm run test:integration --workspace=@getpaseo\/server/); + assert.doesNotMatch(integration, /test:unit|server_plugin_tests|server_hub_contract/); + for (const job of [windows, integration]) { + assert.match(job, /^ needs: changes$/m, "both phases become eligible independently"); + assert.match(job, /^ runs-on: windows-latest$/m); + assert.doesNotMatch(job, /continue-on-error|maxWorkers|--exclude/); + for (const step of [ + "server_capacity", + "server_checkout", + "server_node", + "server_fetch", + "server_install", + "server_agent_clis", + "server_build", + ]) { + assert.match(ubuntu, new RegExp(`&${step}\\b`)); + assert.match(job, new RegExp(`- \\*${step}\\b`)); + } + assert.match(job, /env: \*server_test_env/); + } + assert.match(windows, /- \*server_plugin_tests/); + assert.match(windows, /- \*server_hub_contract/); + assert.match(ubuntu, /run: npm run test --workspace=@getpaseo\/builtin-plugins/); + assert.match(ubuntu, /run: npm run test:hub-cli-contract --workspace=@getpaseo\/server/); + + const scripts = JSON.parse( + readFileSync(new URL("packages/server/package.json", repoRoot), "utf8"), + ).scripts; + assert.equal(scripts.test, "npm run test:unit && npm run test:integration"); + assert.equal(scripts["test:unit"], 'vitest run --fileParallelism --exclude "**/*.e2e.test.ts"'); + const invocations = scripts["test:integration"].split(" && "); + // Each vitest start costs about half a minute on Windows, so the four + // name-filtered files share one invocation and one combined filter. + assert.equal(invocations.length, 2); + assert.ok(invocations.every((command) => command.startsWith("vitest run --maxWorkers=1 "))); + assert.doesNotMatch(invocations[0], / -t /); + assert.equal( + invocations[1], + 'vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t "merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments"', + ); + const serverConfig = readFileSync(new URL("packages/server/vitest.config.ts", repoRoot), "utf8"); + assert.doesNotMatch(serverConfig, /maxWorkers/); + + function condition(job) { + return / if: \$\{\{ (.+) \}\}/.exec(job)[1]; + } + function selected(expression, outputs, cancelled = false) { + return runInNewContext(expression, { + needs: { changes: { outputs } }, + cancelled: () => cancelled, + }); + } + const unitCondition = condition(windows); + const integrationCondition = condition(integration); + const unitStep = / - name: Run server unit tests\n if: \$\{\{ (.+) \}\}/.exec( + windows, + )[1]; + const hubStep = + / - &server_hub_contract\n name: Run Hub CLI\/daemon contract\n if: \$\{\{ (.+) \}\}/.exec( + ubuntu, + )[1]; + const pluginStep = + / - &server_plugin_tests\n name: Run built-in plugin tests\n if: \$\{\{ (.+) \}\}/.exec( + ubuntu, + )[1]; + for (const [outputs, units, integrations, hub] of [ + [{ full: "true", server: "false", hub: "false" }, true, true, false], + [{ full: "false", server: "true", hub: "false" }, true, true, false], + [{ full: "false", server: "false", hub: "true" }, true, false, true], + [{ full: "false", server: "false", hub: "false" }, false, false, false], + [{}, true, true, false], + ]) { + assert.equal(selected(unitCondition, outputs), units); + assert.equal(selected(integrationCondition, outputs), integrations); + assert.equal(selected(unitStep, outputs), integrations); + assert.equal(selected(pluginStep, outputs), integrations); + assert.equal(selected(hubStep, outputs), hub); + assert.equal(selected(unitCondition, outputs, true), false); + assert.equal(selected(integrationCondition, outputs, true), false); + } +});