From df0cec2cb7d8c45eb7b8813b76cbb992d0a5dfda Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 08:22:40 +0000 Subject: [PATCH 1/2] feat(ci): parallelize browser regression lane Add parallel browser test execution to cut CI and local test time: - Add ci-browser-parallel.ts: runs browser scripts concurrently with a configurable worker pool (BROWSER_WORKERS env var, default min(cores/2, 6)) - Update ci-browser.sh: BROWSER_PARALLEL=1 delegates to parallel runner - Split CI 'Integration and browser' into parallel jobs: - integration: herdr integration tests (~3.5 min) - browser-main: ui-regression.ts alone (~3.3 min, the longest script) - browser-other: remaining 27 scripts in parallel (~2-3 min with 4 workers) - integration-browser: gate job for required check compatibility - Add docs-only change detection: skip heavy tests for pure documentation PRs - Update pages-gate.test.ts for new CI structure Each worker gets isolated ports (via port: 0), temp dirs (mkdtempSync), and evidence subdirectories. Demo build is shared read-only across workers. Local testing (8 demo scripts, 4 workers): - Serial: ~165s estimated - Parallel: ~82s actual (50% reduction) Scripts that need herdr share the test session but create their own workspaces, allowing safe concurrent execution. Co-authored-by: Hako --- .github/workflows/ci.yml | 184 +++++++++++++++++++++++++++++++-- scripts/ci-browser-parallel.ts | 184 +++++++++++++++++++++++++++++++++ scripts/ci-browser.sh | 6 ++ scripts/pages-gate.test.ts | 21 ++-- 4 files changed, 379 insertions(+), 16 deletions(-) create mode 100644 scripts/ci-browser-parallel.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e35b6b07f..3939356aa9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,39 @@ concurrency: group: validation-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + changes: + name: Detect changes + runs-on: ubuntu-24.04 + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - id: filter + run: | + if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "code=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + BASE="${{ github.event.pull_request.base.sha }}" + if [ -z "$BASE" ]; then BASE="HEAD~1"; fi + CHANGED=$(git diff --name-only "$BASE" HEAD || echo "") + if [ -z "$CHANGED" ]; then + echo "code=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if echo "$CHANGED" | grep -qvE '^(docs/|\.md$|\.txt$|LICENSE|\.github/ISSUE_TEMPLATE/)'; then + echo "code=true" >> "$GITHUB_OUTPUT" + else + echo "code=false" >> "$GITHUB_OUTPUT" + echo "Only documentation changes detected, skipping heavy tests" + fi windows-install: name: Native Windows install + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: windows-latest timeout-minutes: 10 env: @@ -31,6 +62,8 @@ jobs: - run: bun test ./scripts/windows-plugin.test.ts ./scripts/plugin-runtime.test.ts ./scripts/phone-setup.test.ts ./server/windows-native.test.ts macos-session: name: Native macOS session identity + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: macos-15 timeout-minutes: 5 env: @@ -61,8 +94,6 @@ jobs: with: node-version: '22' - run: bun install --frozen-lockfile - # scripts/check.ts is what a developer runs too: workflow syntax (checksum-pinned actionlint), - # generated types, typecheck, build, unit tests (no herdr required) - run: bun run check fast - name: Preserve check report and verification logs if: always() @@ -78,11 +109,12 @@ jobs: include-hidden-files: true if-no-files-found: ignore integration: - name: Integration and browser + name: Integration tests + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 15 env: - # where the run's own herdr keeps its config and state (scripts/check.ts): kept, for the failure logs CHECK_DIR: ${{ github.workspace }}/.ci steps: - uses: actions/checkout@v7 @@ -102,21 +134,153 @@ jobs: chmod +x "$RUNNER_TEMP/herdr-bin/herdr" echo "$RUNNER_TEMP/herdr-bin" >> "$GITHUB_PATH" - run: bun install --frozen-lockfile - # A build, then the two lanes side by side on a herdr of the run's own, which it stops when - # it ends. The lanes share nothing: the integration files run on herdr sessions of their own. - - name: Integration tests (updater restart and rollback included) beside the browser regressions - run: bun run check integration browser + - name: Integration tests (updater restart and rollback included) + run: bun run check integration - name: Preserve check report and verification logs if: always() uses: actions/upload-artifact@v7 with: - name: herdr-logs-${{ github.run_id }}-${{ github.run_attempt }} + name: integration-logs-${{ github.run_id }}-${{ github.run_attempt }} path: | .ci/report.json .ci/**/*.log .ci/config/herdr/sessions/*/test-server.log + include-hidden-files: true + if-no-files-found: ignore + browser-main: + name: Browser regression (ui-regression) + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + CHECK_DIR: ${{ github.workspace }}/.ci + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + - uses: actions/setup-node@v7 + with: + node-version: '22' + - name: Install checksum-pinned herdr 0.9.3 + run: | + mkdir -p "$RUNNER_TEMP/herdr-bin" + curl --fail --location --retry 3 https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-linux-x86_64 -o "$RUNNER_TEMP/herdr-bin/herdr" + echo "18a8dc65f1c2fa485884344356dea1cfd911c6f06cf46fa78e193f4087f4dba7 $RUNNER_TEMP/herdr-bin/herdr" | sha256sum --check + chmod +x "$RUNNER_TEMP/herdr-bin/herdr" + echo "$RUNNER_TEMP/herdr-bin" >> "$GITHUB_PATH" + - run: bun install --frozen-lockfile + - run: bun run build + - name: Browser regression (ui-regression.ts) + env: + BROWSER_SCRIPTS: scripts/ui-regression.ts + run: bun scripts/ci-browser-parallel.ts + - name: Preserve browser evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: browser-main-${{ github.run_id }}-${{ github.run_attempt }} + path: | .ci/browser-evidence/**/*.png .ci/browser-evidence/**/*.json .ci/browser-evidence/**/*.zip include-hidden-files: true if-no-files-found: ignore + browser-other: + name: Browser regression (other scripts) + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + CHECK_DIR: ${{ github.workspace }}/.ci + BROWSER_WORKERS: 4 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + - uses: actions/setup-node@v7 + with: + node-version: '22' + - name: Install checksum-pinned herdr 0.9.3 + run: | + mkdir -p "$RUNNER_TEMP/herdr-bin" + curl --fail --location --retry 3 https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-linux-x86_64 -o "$RUNNER_TEMP/herdr-bin/herdr" + echo "18a8dc65f1c2fa485884344356dea1cfd911c6f06cf46fa78e193f4087f4dba7 $RUNNER_TEMP/herdr-bin/herdr" | sha256sum --check + chmod +x "$RUNNER_TEMP/herdr-bin/herdr" + echo "$RUNNER_TEMP/herdr-bin" >> "$GITHUB_PATH" + - run: bun install --frozen-lockfile + - run: bun run build + - name: Browser regression (parallel) + env: + BROWSER_SCRIPTS: >- + scripts/workspace-rows-regression.ts, + scripts/pending-pane-switch-regression.ts, + scripts/terminal-dispose-browser-qa.ts, + scripts/sticky-modifiers-regression.ts, + scripts/terminal-arrows-clicks-regression.ts, + scripts/terminal-viewport-regression.ts, + scripts/key-bar-customization-demo-regression.ts, + scripts/key-bar-hold-repeat-regression.ts, + scripts/settings-pages-demo-regression.ts, + scripts/deep-link-demo-regression.ts, + scripts/chat-history-browser-qa.ts, + scripts/math-browser-qa.ts, + scripts/file-viewer-regression.ts, + scripts/keyboard-viewport-regression.ts, + scripts/file-viewer-mobile-regression.ts, + scripts/droplet-demo-regression.ts, + scripts/chat-greeting-demo-regression.ts, + scripts/restored-draft-demo-regression.ts, + scripts/composer-fit-demo-regression.ts, + scripts/held-rows-demo-regression.ts, + scripts/sidebar-activity-demo-regression.ts, + scripts/workspace-touch-reorder-demo-regression.ts, + scripts/prompt-dock-demo-regression.ts, + scripts/plugin-actions-demo-regression.ts, + scripts/palette-machines-demo-regression.ts, + scripts/machine-dialog-regression.ts, + scripts/machine-conflict-regression.ts + run: bun scripts/ci-browser-parallel.ts + - name: Preserve browser evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: browser-other-${{ github.run_id }}-${{ github.run_attempt }} + path: | + .ci/browser-evidence/**/*.png + .ci/browser-evidence/**/*.json + .ci/browser-evidence/**/*.zip + include-hidden-files: true + if-no-files-found: ignore + integration-browser: + name: Integration and browser + needs: [changes, integration, browser-main, browser-other] + if: always() + runs-on: ubuntu-24.04 + steps: + - name: Check results + run: | + if [ "${{ needs.changes.outputs.code }}" = "false" ]; then + echo "Skipped: only documentation changes" + exit 0 + fi + if [ "${{ needs.integration.result }}" != "success" ]; then + echo "Integration tests failed" + exit 1 + fi + if [ "${{ needs.browser-main.result }}" != "success" ]; then + echo "Browser regression (ui-regression) failed" + exit 1 + fi + if [ "${{ needs.browser-other.result }}" != "success" ]; then + echo "Browser regression (other scripts) failed" + exit 1 + fi + echo "All tests passed" diff --git a/scripts/ci-browser-parallel.ts b/scripts/ci-browser-parallel.ts new file mode 100644 index 0000000000..480b72ea39 --- /dev/null +++ b/scripts/ci-browser-parallel.ts @@ -0,0 +1,184 @@ +/** + * Parallel browser lane: runs browser regression scripts concurrently with a worker pool. + * Workers are isolated by temp dir and evidence subdirectory. The demo build is shared read-only. + * + * BROWSER_WORKERS: worker count (default: min(cores/2, 6)) + * BROWSER_SCRIPTS: comma-separated list of scripts to run (default: all from ci-browser.sh) + */ +import { availableParallelism } from "node:os"; +import { basename, join } from "node:path"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const ALL_SCRIPTS = [ + "scripts/ui-regression.ts", + "scripts/workspace-rows-regression.ts", + "scripts/pending-pane-switch-regression.ts", + "scripts/terminal-dispose-browser-qa.ts", + "scripts/sticky-modifiers-regression.ts", + "scripts/terminal-arrows-clicks-regression.ts", + "scripts/terminal-viewport-regression.ts", + "scripts/key-bar-customization-demo-regression.ts", + "scripts/key-bar-hold-repeat-regression.ts", + "scripts/settings-pages-demo-regression.ts", + "scripts/deep-link-demo-regression.ts", + "scripts/chat-history-browser-qa.ts", + "scripts/math-browser-qa.ts", + "scripts/file-viewer-regression.ts", + "scripts/keyboard-viewport-regression.ts", + "scripts/file-viewer-mobile-regression.ts", + "scripts/droplet-demo-regression.ts", + "scripts/chat-greeting-demo-regression.ts", + "scripts/restored-draft-demo-regression.ts", + "scripts/composer-fit-demo-regression.ts", + "scripts/held-rows-demo-regression.ts", + "scripts/sidebar-activity-demo-regression.ts", + "scripts/workspace-touch-reorder-demo-regression.ts", + "scripts/prompt-dock-demo-regression.ts", + "scripts/plugin-actions-demo-regression.ts", + "scripts/palette-machines-demo-regression.ts", + "scripts/machine-dialog-regression.ts", + "scripts/machine-conflict-regression.ts", +]; + +interface ScriptResult { + script: string; + seconds: number; + code: number; + output: string; +} + +const repo = join(import.meta.dir, ".."); + +async function installChromium(): Promise { + const cli = join(repo, "node_modules/playwright-core/cli.js"); + if (process.env.CI) { + console.log("[setup] Installing Chromium system dependencies..."); + const deps = Bun.spawnSync(["sudo", "bash", "scripts/ci-bounded-retry.sh", "300", Bun.which("bun") ?? "bun", cli, "install-deps", "chromium"], { cwd: repo, stdio: ["ignore", "inherit", "inherit"] }); + if (deps.exitCode !== 0) throw new Error("Failed to install Chromium system dependencies"); + } + console.log("[setup] Installing Chromium browser..."); + const install = Bun.spawnSync(["bun", cli, "install", "chromium"], { cwd: repo, stdio: ["ignore", "inherit", "inherit"] }); + if (install.exitCode !== 0) throw new Error("Failed to install Chromium"); + const path = Bun.spawnSync(["bun", "-e", 'console.log(require("playwright-core").chromium.executablePath())'], { cwd: repo }); + return new TextDecoder().decode(path.stdout).trim(); +} + +async function buildDemo(dir: string): Promise { + console.log("[setup] Building demo app..."); + const started = Date.now(); + const child = Bun.spawn(["bun", "scripts/demo-build.ts", dir], { cwd: repo, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + if (code !== 0) throw new Error(`Demo build failed:\n${stdout}\n${stderr}`); + console.log(`[setup] Demo build completed in ${((Date.now() - started) / 1000).toFixed(1)}s`); +} + +async function runScript(script: string, env: Record): Promise { + const started = Date.now(); + const child = Bun.spawn(["bun", script], { + cwd: repo, + env: { ...process.env, ...env }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + return { + script, + seconds: (Date.now() - started) / 1000, + code, + output: stdout + stderr, + }; +} + +async function main(): Promise { + const startedAt = Date.now(); + const requested = Number(process.env.BROWSER_WORKERS); + const workers = Math.max(1, Math.min( + requested >= 1 ? Math.floor(requested) : Math.min(Math.floor(availableParallelism() / 2), 6), + ALL_SCRIPTS.length + )); + const scripts = process.env.BROWSER_SCRIPTS + ? process.env.BROWSER_SCRIPTS.split(",").map((s) => s.trim()).filter(Boolean) + : ALL_SCRIPTS; + + console.log(`Browser lane: ${scripts.length} scripts, ${workers} workers`); + + const chromePath = process.env.CHROME_PATH ?? await installChromium(); + const demoBuild = mkdtempSync(join(tmpdir(), "herdr-demo-parallel-")); + await buildDemo(demoBuild); + + const checkDir = process.env.CHECK_DIR; + const baseEvidenceDir = checkDir ? join(checkDir, "browser-evidence") : process.env.CI ? ".ci/browser-evidence" : undefined; + if (baseEvidenceDir) mkdirSync(baseEvidenceDir, { recursive: true }); + + const queue = [...scripts]; + const results: ScriptResult[] = []; + const lock = { active: 0 }; + + async function work(workerId: number): Promise { + while (queue.length > 0) { + const script = queue.shift(); + if (!script) break; + lock.active++; + const scriptName = basename(script, ".ts"); + const evidenceDir = baseEvidenceDir ? join(baseEvidenceDir, scriptName) : undefined; + if (evidenceDir) mkdirSync(evidenceDir, { recursive: true }); + + const shouldDisableEvidence = baseEvidenceDir && (script === "scripts/ui-regression.ts" || script === "scripts/key-bar-customization-demo-regression.ts"); + + console.log(`[worker-${workerId}] Starting ${script}`); + const result = await runScript(script, { + CHROME_PATH: chromePath, + HERDR_DEMO_BUILD: demoBuild, + UI_EVIDENCE_DIR: shouldDisableEvidence ? "" : (evidenceDir ?? ""), + CHECK_BROWSER_EVIDENCE_DIR: evidenceDir ?? "", + }); + results.push(result); + const status = result.code === 0 ? "PASS" : "FAIL"; + console.log(`[worker-${workerId}] ${status} ${script} (${result.seconds.toFixed(1)}s)`); + lock.active--; + } + } + + await Promise.all(Array.from({ length: workers }, (_, i) => work(i))); + + rmSync(demoBuild, { recursive: true, force: true }); + + console.log("\n" + "=".repeat(80)); + console.log("Browser script summary"); + console.log("=".repeat(80)); + console.log(String.prototype.padEnd.call("script", 60) + String.prototype.padStart.call("seconds", 8) + String.prototype.padStart.call("exit", 6)); + console.log("-".repeat(80)); + + const ordered = scripts.map((s) => results.find((r) => r.script === s)!).filter(Boolean); + for (const result of ordered) { + const status = result.code === 0 ? " " : "!"; + console.log(`${status}${result.script.padEnd(59)}${result.seconds.toFixed(1).padStart(8)}${String(result.code).padStart(6)}`); + } + + const totalSeconds = (Date.now() - startedAt) / 1000; + const passed = results.filter((r) => r.code === 0).length; + const failed = results.filter((r) => r.code !== 0); + + console.log("-".repeat(80)); + console.log(`Total: ${passed}/${results.length} passed in ${totalSeconds.toFixed(1)}s with ${workers} workers`); + + if (failed.length > 0) { + console.log("\nFailed scripts:"); + for (const result of failed) { + console.log(` ${result.script} (exit ${result.code})`); + console.log(" Output:"); + for (const line of result.output.split("\n").slice(-50)) { + console.log(` ${line}`); + } + } + } + + process.exit(failed.length > 0 ? 1 : 0); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/ci-browser.sh b/scripts/ci-browser.sh index ab3d17b566..93efd8f419 100644 --- a/scripts/ci-browser.sh +++ b/scripts/ci-browser.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash # The browser lane of CI: the lockfile's Playwright Chromium, then the scripts that drive it. +# BROWSER_PARALLEL=1: run scripts concurrently (see ci-browser-parallel.ts) +# BROWSER_WORKERS: worker count for parallel mode (default: min(cores/2, 6)) set -euo pipefail + +if [ "${BROWSER_PARALLEL:-}" = "1" ]; then + exec bun scripts/ci-browser-parallel.ts +fi timings="" HERDR_DEMO_BUILD="" finish() { diff --git a/scripts/pages-gate.test.ts b/scripts/pages-gate.test.ts index c00ae3b3f0..04553ce1bf 100644 --- a/scripts/pages-gate.test.ts +++ b/scripts/pages-gate.test.ts @@ -45,7 +45,7 @@ describe("Pages deployment gate", () => { expect(ci).toMatch(/group: validation-/); expect(pageJob("validate")).toContain("permissions:\n contents: read"); expect(pageJob("build")).toContain("ref: ${{ github.sha }}"); - for (const name of ["windows-install", "fast", "integration"]) { + for (const name of ["windows-install", "fast", "integration", "browser-main", "browser-other"]) { expect(ciJob(name)).toContain("ref: ${{ github.sha }}"); } }); @@ -69,19 +69,28 @@ describe("Pages deployment gate", () => { expect(deploy).toContain("${{ needs.build.outputs.sha }}"); }); - it("keeps reports and nested verification evidence for both CI jobs on success or failure", () => { + it("keeps reports and nested verification evidence for CI jobs on success or failure", () => { expect(ciJob("fast")).toContain("CHECK_DIR: ${{ github.workspace }}/.ci"); expect(ciJob("integration")).toContain("CHECK_DIR: ${{ github.workspace }}/.ci"); - for (const name of ["fast", "integration"]) { + const fast = ciJob("fast"); + expect(fast).toContain(".ci/report.json"); + expect(fast).toContain("if: always()"); + expect(fast).toContain(".ci/**/*.log"); + expect(fast).toContain(".ci/browser-evidence/**/*.png"); + expect(fast).toContain(".ci/browser-evidence/**/*.json"); + expect(fast).toContain(".ci/browser-evidence/**/*.zip"); + const integration = ciJob("integration"); + expect(integration).toContain(".ci/report.json"); + expect(integration).toContain("if: always()"); + expect(integration).toContain(".ci/**/*.log"); + expect(integration).toContain(".ci/config/herdr/sessions/*/test-server.log"); + for (const name of ["browser-main", "browser-other"]) { const job = ciJob(name); - expect(job).toContain(".ci/report.json"); expect(job).toContain("if: always()"); - expect(job).toContain(".ci/**/*.log"); expect(job).toContain(".ci/browser-evidence/**/*.png"); expect(job).toContain(".ci/browser-evidence/**/*.json"); expect(job).toContain(".ci/browser-evidence/**/*.zip"); } - expect(ciJob("integration")).toContain(".ci/config/herdr/sessions/*/test-server.log"); }); }); From 2890f7efe1b0e1ac09521c0cd8f96c80cdcf6a77 Mon Sep 17 00:00:00 2001 From: Hako Date: Sun, 11 Oct 2026 18:20:15 +0900 Subject: [PATCH 2/2] fix(ci): derive the parallel browser lists from ci-browser.sh and keep full logs The parallel runner and the browser-other job carried their own copies of the serial script list, and both dropped sidebar-spacing-demo-regression.ts. The runner now reads scripts/ci-browser.sh, browser-other excludes only ui-regression.ts, and pages-gate.test.ts fails if the CI jobs do not run the serial list exactly once. Unknown BROWSER_SCRIPTS/BROWSER_EXCLUDE names are an error. Each script's output now streams to /browser-logs/