Speed up server CI tests (#2537)

* perf(ci): reduce server test latency

Server test files use isolated resources, so they do not require suite-wide serialization.

* fix(ci): scope server test parallelism to unit suite

Keep real-provider and local-resource suites serialized because they may share user configuration and account limits.

* fix(terminal): isolate zsh runtimes by process

Prevent concurrent daemon and test processes from deleting or replacing shell integration files used by another process.

* fix(ci): preserve required matrix check names

GitHub evaluates job conditions before expanding a matrix. Expand active matrices first and gate their expensive steps so required check contexts are always reported. Allow superseded runs to cancel while retaining fail-open path detection.
This commit is contained in:
Mohamed Boudra
2026-07-28 15:51:12 +02:00
committed by GitHub
parent fd7061a8b2
commit fdee3236f7
5 changed files with 142 additions and 39 deletions

View File

@@ -115,6 +115,9 @@ jobs:
- 'packages/relay/**' - 'packages/relay/**'
- 'packages/server/**' - 'packages/server/**'
- name: Validate CI workflow
run: node --test scripts/ci-workflow.test.mjs
format: format:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
@@ -138,7 +141,7 @@ jobs:
lint: lint:
needs: changes needs: changes
if: >- if: >-
${{ always() && ${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' || needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }} needs.changes.outputs.quality != 'false') }}
@@ -168,7 +171,7 @@ jobs:
typecheck: typecheck:
needs: changes needs: changes
if: >- if: >-
${{ always() && ${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' || needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }} needs.changes.outputs.quality != 'false') }}
@@ -199,11 +202,7 @@ jobs:
server-tests: server-tests:
needs: changes needs: changes
if: >- if: ${{ !cancelled() }}
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.server != 'false') }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -212,28 +211,43 @@ jobs:
name: server-tests (${{ matrix.os }}) name: server-tests (${{ matrix.os }})
env: env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1" ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.server != 'false' }}
steps: steps:
- name: Skip unaffected server tests
if: env.RUN_TESTS != 'true'
run: echo "No server changes detected."
- uses: actions/checkout@v4 - uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with: with:
node-version: "22" node-version: "22"
cache: "npm" cache: "npm"
- name: Fetch origin/main (worktree tests) - name: Fetch origin/main (worktree tests)
if: env.RUN_TESTS == 'true'
run: git fetch --no-tags origin main:refs/remotes/origin/main run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies - name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests - name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies - name: Build server dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:server-deps run: npm run build:server-deps
- name: Run server tests - name: Run server tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/server run: npm run test --workspace=@getpaseo/server
env: env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -242,11 +256,7 @@ jobs:
desktop-tests: desktop-tests:
needs: changes needs: changes
if: >- if: ${{ !cancelled() }}
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop != 'false') }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -255,39 +265,53 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
permissions: permissions:
contents: read contents: read
env:
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop != 'false' }}
steps: steps:
- name: Skip unaffected desktop tests
if: env.RUN_TESTS != 'true'
run: echo "No desktop changes detected."
- uses: actions/checkout@v4 - uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with: with:
node-version: "22" node-version: "22"
cache: "npm" cache: "npm"
- name: Install dependencies with retry - name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci run: node scripts/npm-retry.mjs ci
- name: Build server stack - name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server run: npm run build:server
- name: Run desktop tests - name: Run desktop tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/desktop run: npm run test --workspace=@getpaseo/desktop
- name: Build app dependencies for desktop E2E - name: Build app dependencies for desktop E2E
if: matrix.os == 'ubuntu-latest' if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: npm run build:app-deps run: npm run build:app-deps
- name: Install virtual display - name: Install virtual display
if: matrix.os == 'ubuntu-latest' if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y xvfb xauth run: sudo apt-get update && sudo apt-get install -y xvfb xauth
- name: Run real Electron browser tab bridge E2E - name: Run real Electron browser tab bridge E2E
if: matrix.os == 'ubuntu-latest' if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
env: env:
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
- name: Upload browser tab bridge diagnostics - name: Upload browser tab bridge diagnostics
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: failure() && matrix.os == 'ubuntu-latest' if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest'
with: with:
name: browser-tab-bridge-e2e name: browser-tab-bridge-e2e
path: ${{ runner.temp }}/browser-tab-bridge-e2e path: ${{ runner.temp }}/browser-tab-bridge-e2e
@@ -296,7 +320,7 @@ jobs:
- name: Build and smoke unpacked desktop app - name: Build and smoke unpacked desktop app
if: >- if: >-
matrix.os == 'ubuntu-latest' && env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' || needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false') needs.changes.outputs.desktop_package != 'false')
@@ -308,7 +332,7 @@ jobs:
- name: Upload packaged smoke diagnostics - name: Upload packaged smoke diagnostics
if: >- if: >-
failure() && matrix.os == 'ubuntu-latest' && env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' || needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false') needs.changes.outputs.desktop_package != 'false')
@@ -322,7 +346,7 @@ jobs:
app-tests: app-tests:
needs: changes needs: changes
if: >- if: >-
${{ always() && ${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' || needs.changes.result != 'success' ||
needs.changes.outputs.app != 'false') }} needs.changes.outputs.app != 'false') }}
@@ -352,7 +376,7 @@ jobs:
sdk-tests: sdk-tests:
needs: changes needs: changes
if: >- if: >-
${{ always() && ${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' || needs.changes.result != 'success' ||
needs.changes.outputs.sdk != 'false') }} needs.changes.outputs.sdk != 'false') }}
@@ -383,11 +407,7 @@ jobs:
playwright: playwright:
needs: changes needs: changes
if: >- if: ${{ !cancelled() }}
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.playwright != 'false') }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -401,43 +421,57 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1" ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.playwright != 'false' }}
steps: steps:
- name: Skip unaffected Playwright tests
if: env.RUN_TESTS != 'true'
run: echo "No Playwright changes detected."
- uses: actions/checkout@v4 - uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with: with:
node-version: "22" node-version: "22"
cache: "npm" cache: "npm"
- name: Install dependencies with retry - name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers - name: Install Playwright browsers
if: env.RUN_TESTS == 'true'
timeout-minutes: 10 timeout-minutes: 10
run: npx playwright install chromium run: npx playwright install chromium
- name: Build app dependencies - name: Build app dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:app-deps run: npm run build:app-deps
- name: Build server stack - name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server run: npm run build:server
- name: Install agent CLIs for provider tests - name: Install agent CLIs for provider tests
if: ${{ !matrix.desktop }} if: env.RUN_TESTS == 'true' && !matrix.desktop
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests - name: Run Playwright E2E tests
if: ${{ !matrix.desktop }} if: env.RUN_TESTS == 'true' && !matrix.desktop
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4 run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
env: env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run desktop-overlay Playwright tests - name: Run desktop-overlay Playwright tests
if: ${{ matrix.desktop }} if: env.RUN_TESTS == 'true' && matrix.desktop
run: npm run test:e2e:desktop --workspace=@getpaseo/app run: npm run test:e2e:desktop --workspace=@getpaseo/app
- name: Upload test artifacts - name: Upload test artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: failure() if: env.RUN_TESTS == 'true' && failure()
with: with:
name: playwright-results-${{ matrix.shard }} name: playwright-results-${{ matrix.shard }}
path: | path: |
@@ -448,7 +482,7 @@ jobs:
relay-tests: relay-tests:
needs: changes needs: changes
if: >- if: >-
${{ always() && ${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' || needs.changes.result != 'success' ||
needs.changes.outputs.relay != 'false') }} needs.changes.outputs.relay != 'false') }}
@@ -474,11 +508,7 @@ jobs:
cli-tests: cli-tests:
needs: changes needs: changes
if: >- if: ${{ !cancelled() }}
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.cli != 'false') }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -487,24 +517,38 @@ jobs:
name: cli-tests (shard ${{ matrix.shard }}/3) name: cli-tests (shard ${{ matrix.shard }}/3)
env: env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1" ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.cli != 'false' }}
steps: steps:
- name: Skip unaffected CLI tests
if: env.RUN_TESTS != 'true'
run: echo "No CLI changes detected."
- uses: actions/checkout@v4 - uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with: with:
node-version: "22" node-version: "22"
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci run: node scripts/npm-retry.mjs ci
- name: Build server stack - name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server run: npm run build:server
- name: Install agent CLIs for provider tests - name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run CLI tests - name: Run CLI tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/cli run: npm run test --workspace=@getpaseo/cli
env: env:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"

View File

@@ -49,7 +49,7 @@
"speech:download": "tsx scripts/download-speech-models.ts", "speech:download": "tsx scripts/download-speech-models.ts",
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration", "test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts", "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
"test:integration:all": "npm run test:e2e", "test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts", "test:integration:real": "vitest run real.e2e.test.ts",

View File

@@ -253,7 +253,7 @@ function lastNonEmptyLineIsPrompt(state: ReturnType<TerminalSession["getState"]>
} }
function removeZshShellIntegrationRuntimeDir(): void { function removeZshShellIntegrationRuntimeDir(): void {
rmSync(join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh`), { rmSync(join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh-${process.pid}`), {
recursive: true, recursive: true,
force: true, force: true,
}); });
@@ -272,7 +272,9 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
expect(resolvedEnv.TERM).toBe("xterm-256color"); expect(resolvedEnv.TERM).toBe("xterm-256color");
expect(resolvedEnv.TERM_PROGRAM).toBe("kitty"); expect(resolvedEnv.TERM_PROGRAM).toBe("kitty");
expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir"); expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir");
expect(resolvedEnv.ZDOTDIR).not.toBe("/tmp/paseo-zdotdir"); expect(resolvedEnv.ZDOTDIR).toBe(
join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh-${process.pid}`),
);
expect(existsSync(join(resolvedEnv.ZDOTDIR, ".zshenv"))).toBe(true); expect(existsSync(join(resolvedEnv.ZDOTDIR, ".zshenv"))).toBe(true);
expect(existsSync(join(resolvedEnv.ZDOTDIR, "paseo-integration.zsh"))).toBe(true); expect(existsSync(join(resolvedEnv.ZDOTDIR, "paseo-integration.zsh"))).toBe(true);
}); });

View File

@@ -384,7 +384,7 @@ function resolveZshShellIntegrationRuntimeDir(): string {
} catch { } catch {
// keep fallback // keep fallback
} }
return join(tmpdir(), `${username}-paseo-zsh`); return join(tmpdir(), `${username}-paseo-zsh-${process.pid}`);
} }
function prepareZshShellIntegrationRuntimeDir(sourceDir = resolveZshShellIntegrationDir()): string { function prepareZshShellIntegrationRuntimeDir(sourceDir = resolveZshShellIntegrationDir()): string {

View File

@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const workflowPath = new URL("../.github/workflows/ci.yml", import.meta.url);
function jobBlocks(source) {
const jobs = new Map();
let currentJob;
for (const line of source.split("\n")) {
const jobMatch = /^ ([a-z0-9-]+):\s*$/.exec(line);
if (jobMatch) {
currentJob = jobMatch[1];
jobs.set(currentJob, []);
continue;
}
if (currentJob && (/^ \S/.test(line) || /^ \S/.test(line))) {
jobs.get(currentJob).push(line);
}
}
return jobs;
}
test("matrix jobs expand before change gating", () => {
const workflow = readFileSync(workflowPath, "utf8");
const gatedMatrixJobs = [...jobBlocks(workflow)]
.filter(([, lines]) => {
const hasMatrix = lines.some((line) => line.startsWith(" matrix:"));
const unsafeJobCondition = lines.some(
(line) => line.startsWith(" if:") && line.trim() !== "if: ${{ !cancelled() }}",
);
return hasMatrix && unsafeJobCondition;
})
.map(([jobId]) => jobId);
assert.deepEqual(
gatedMatrixJobs,
[],
"change-based job conditions skip a matrix before GitHub can emit its interpolated check names",
);
});
test("change gating allows superseded workflow runs to cancel", () => {
const workflow = readFileSync(workflowPath, "utf8");
const cancellationBlockingJobs = [...jobBlocks(workflow)]
.filter(([, lines]) => lines.some((line) => line.trim().startsWith("${{ always()")))
.map(([jobId]) => jobId);
assert.deepEqual(
cancellationBlockingJobs,
[],
"always() keeps jobs alive after concurrency cancellation; use !cancelled() for fail-open gating",
);
});