Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 130 additions & 26 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ jobs:
code: ${{ steps.plan.outputs.code }}
e2e: ${{ steps.plan.outputs.e2e }}
headless: ${{ steps.plan.outputs.headless }}
runtime_host: ${{ steps.plan.outputs.runtime_host }}
runtime_sandbox: ${{ steps.plan.outputs.runtime_sandbox }}
script_mode: ${{ steps.plan.outputs.script_mode }}
standard_workspaces: ${{ steps.plan.outputs.standard_workspaces }}
storage_stress: ${{ steps.plan.outputs.storage_stress }}
storybook: ${{ steps.plan.outputs.storybook }}
unit: ${{ steps.plan.outputs.unit }}
Expand Down Expand Up @@ -81,25 +83,18 @@ jobs:
- name: Story annotations
run: node scripts/check-story-annotations.mjs

test:
test_workspaces:
needs: changes
if: needs.changes.outputs.unit == 'true' || needs.changes.outputs.script_mode != 'none'
if: needs.changes.outputs.standard_workspaces != '' || needs.changes.outputs.script_mode != 'none'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
- uses: astral-sh/setup-uv@v6
if: needs.changes.outputs.headless == 'true'
- name: Install pinned Harbor contract runtime
if: needs.changes.outputs.headless == 'true'
run: |
uv tool install "harbor==0.13.2"
uv tool dir --bin >> "$GITHUB_PATH"
- name: Install Linux runtime dependencies
if: needs.changes.outputs.headless == 'true' || needs.changes.outputs.runtime_sandbox == 'true'
if: needs.changes.outputs.runtime_sandbox == 'true'
run: sudo apt-get update && sudo apt-get install -y ripgrep bubblewrap
# Ubuntu 24.04 hosted runners gate unprivileged user namespaces through
# AppArmor, which otherwise makes bwrap fail while configuring loopback.
Expand All @@ -117,7 +112,6 @@ jobs:
# tsc outputs (dist/main + dist/renderer side-files), never the vite
# bundle. e2e builds its own renderer in a separate job.
- run: npm run build:test
if: needs.changes.outputs.unit == 'true' || needs.changes.outputs.script_mode != 'none'
- name: Linux sandbox smoke
if: needs.changes.outputs.runtime_sandbox == 'true'
env:
Expand All @@ -132,46 +126,156 @@ jobs:
# Workspaces consume the dist built above. Selection includes reverse
# dependencies, while bounded concurrency avoids both the old serial
# critical path and an unbounded process stampede on two-core runners.
- name: Run affected workspace tests
if: needs.changes.outputs.unit == 'true'
- name: Run affected standard workspace tests
if: needs.changes.outputs.standard_workspaces != ''
env:
HEADLESS_AFFECTED: ${{ needs.changes.outputs.headless }}
STORAGE_STRESS: ${{ needs.changes.outputs.storage_stress }}
WORKSPACES: ${{ needs.changes.outputs.workspaces }}
WORKSPACES: ${{ needs.changes.outputs.standard_workspaces }}
run: |
if [[ "$HEADLESS_AFFECTED" == "true" ]]; then
export MAKA_REQUIRE_HARBOR_CONTRACT=1
fi
if [[ "$STORAGE_STRESS" == "true" ]]; then
export MAKA_STORAGE_STRESS=1
fi
node scripts/run-workspace-tests-parallel.mjs --concurrency=3 --workspaces="$WORKSPACES"

e2e:
test_runtime_host:
needs: changes
if: needs.changes.outputs.runtime_host == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
- run: npm ci
- run: npm run build:test
- name: Run Runtime Host tests
run: npm --workspace @maka/runtime-host run test:dist

test_headless:
needs: changes
if: needs.changes.outputs.headless == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
- uses: astral-sh/setup-uv@v6
- name: Install pinned Harbor contract runtime
run: |
uv tool install "harbor==0.13.2"
uv tool dir --bin >> "$GITHUB_PATH"
- name: Install Linux runtime dependencies
run: sudo apt-get update && sudo apt-get install -y ripgrep bubblewrap
- run: npm ci
- run: npm run build:test
- name: Run Headless tests
env:
MAKA_REQUIRE_HARBOR_CONTRACT: '1'
run: npm --workspace @maka/headless run test:dist

# Preserve one stable required check while letting independent heavy suites
# occupy their own runners. The aggregator waits for every selected lane.
test:
needs: [changes, test_workspaces, test_runtime_host, test_headless]
if: always()
runs-on: ubuntu-latest
steps:
- name: Require successful test lanes
env:
CHANGES_RESULT: ${{ needs.changes.result }}
HEADLESS_RESULT: ${{ needs.test_headless.result }}
HEADLESS_SELECTED: ${{ needs.changes.outputs.headless }}
RUNTIME_HOST_RESULT: ${{ needs.test_runtime_host.result }}
RUNTIME_HOST_SELECTED: ${{ needs.changes.outputs.runtime_host }}
SCRIPT_MODE: ${{ needs.changes.outputs.script_mode }}
STANDARD_WORKSPACES: ${{ needs.changes.outputs.standard_workspaces }}
WORKSPACES_RESULT: ${{ needs.test_workspaces.result }}
run: |
if [[ "$CHANGES_RESULT" != "success" ]]; then
echo "changes failed: $CHANGES_RESULT" >&2
exit 1
fi
require_lane() {
local name="$1"
local selected="$2"
local result="$3"
local expected="skipped"
if [[ "$selected" == "true" ]]; then
expected="success"
fi
if [[ "$result" != "$expected" ]]; then
echo "$name expected $expected but was $result" >&2
exit 1
fi
}
workspaces_selected="false"
if [[ -n "$STANDARD_WORKSPACES" || "$SCRIPT_MODE" != "none" ]]; then
workspaces_selected="true"
fi
require_lane "workspace tests" "$workspaces_selected" "$WORKSPACES_RESULT"
require_lane "Runtime Host tests" "$RUNTIME_HOST_SELECTED" "$RUNTIME_HOST_RESULT"
require_lane "Headless tests" "$HEADLESS_SELECTED" "$HEADLESS_RESULT"

e2e_shard:
needs: changes
if: needs.changes.outputs.e2e == 'true'
name: e2e_shard (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
- run: npm ci
# Electron is a GUI app; on a headless Linux runner it needs a virtual
# display. The suite already runs with show:false, but the process still
# requires an X server to start.
# Each shard keeps one Playwright worker on its own runner and X display.
# This overlaps independent Electron cold starts without reintroducing
# the OS-focus races observed with multiple workers on one display.
- name: Ensure xvfb
run: command -v xvfb-run >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y xvfb; }
- name: E2E
run: xvfb-run -a npm --workspace @maka/desktop run e2e
- name: E2E shard ${{ matrix.shard }}/2
run: xvfb-run -a npm --workspace @maka/desktop run e2e -- --shard=${{ matrix.shard }}/2
# Design governance: the CDP alignment auditor walks the e2e-fixture
# fixtures and fails on same-type height mismatches, mixed-type
# centerline drift, or radius-family splits. Reuses the renderer the
# e2e step just built; same xvfb pattern.
# centerline drift, or radius-family splits. One shard reuses its built
# renderer so the audit does not pay for a third runner and build.
- name: Alignment audit
if: matrix.shard == 1
run: xvfb-run -a node scripts/audit-alignment.mjs

# Preserve the existing required check while every selected shard retains
# its own failure and diagnostic output.
e2e:
needs: [changes, e2e_shard]
if: always()
runs-on: ubuntu-latest
steps:
- name: Require successful E2E shards
env:
CHANGES_RESULT: ${{ needs.changes.result }}
E2E_SELECTED: ${{ needs.changes.outputs.e2e }}
SHARDS_RESULT: ${{ needs.e2e_shard.result }}
run: |
if [[ "$CHANGES_RESULT" != "success" ]]; then
echo "changes failed: $CHANGES_RESULT" >&2
exit 1
fi
expected="skipped"
if [[ "$E2E_SELECTED" == "true" ]]; then
expected="success"
fi
if [[ "$SHARDS_RESULT" != "$expected" ]]; then
echo "E2E shards expected $expected but were $SHARDS_RESULT" >&2
exit 1
fi

# Storybook build + render smoke. Independent of Electron e2e: the smoke
# launches Playwright Chromium, not `_electron.launch`, so it does not
# need xvfb or the desktop cold-start suite. See stories/FIDELITY.md.
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@ import { defineConfig } from '@playwright/test';
* What parallel windows DO share is OS focus. Specs that assert
* `toBeFocused()` (e.g. plan-reminders) fail when another window steals
* activation mid-assertion — Chromium blurs the document when its window
* deactivates. CI stays at 1 until those are hardened; local runs take the
* parallel win, and a local focus failure re-runs alone to confirm.
* deactivates. Each CI shard therefore keeps one worker on an isolated X
* display; local runs take the parallel win, and a local focus failure re-runs
* alone to confirm.
*
* Run from apps/desktop via `npm run e2e`, which builds the app first.
*/
export default defineConfig({
testDir: '.',
fullyParallel: true,
workers: process.env.CI ? 1 : 4,
// CI publishes no Playwright report that consumes Git metadata. Disable its
// best-effort shallow-history fetch, which otherwise waits on a fixed timeout.
captureGitInfo: { commit: false, diff: false },
// No retries: flakes should fail loudly. The fixture waits for the composer
// to mount (the cold-start convergence point — connection seed, onboarding
// clear, renderer hydrated), so cold-start variance never reaches the test.
Expand Down
19 changes: 16 additions & 3 deletions scripts/ci-test-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const TYPECHECK_ONLY_FILES = new Set([
'tsconfig.lib.json',
]);

const DEDICATED_WORKSPACE_LANES = new Set(['packages/runtime-host', 'packages/headless']);

// Scripts the Electron e2e job runs. Editing one of these changes what that
// job verifies, so it has to re-run — a unit test on the runner is not
// evidence that the run it drives still works.
Expand Down Expand Up @@ -127,17 +129,25 @@ export function reverseDependencyClosure(seedDirs, graph) {
return graph.dirs.filter((dir) => selected.has(dir));
}

function workspaceLanes(workspaces) {
return {
headless: workspaces.includes('packages/headless'),
runtimeHost: workspaces.includes('packages/runtime-host'),
standardWorkspaces: workspaces.filter((dir) => !DEDICATED_WORKSPACE_LANES.has(dir)),
};
}

export function planTests(changedFiles, options = {}) {
const graph = options.graph ?? loadWorkspaceGraph(options.repoRoot);
const files = [...new Set(changedFiles.map(normalizePath).filter(Boolean))];
const forceFull = options.forceFull ?? false;
const full = forceFull || files.some((path) => FULL_SUITE_FILES.has(path));
if (full) {
const workspaces = [...graph.dirs];
return {
code: true,
e2e: true,
full: true,
headless: graph.dirs.includes('packages/headless'),
runtimeSandbox: graph.dirs.includes('packages/cli'),
scriptMode: 'full',
// A complete functional suite is still the default release/main gate.
Expand All @@ -146,7 +156,8 @@ export function planTests(changedFiles, options = {}) {
// every unrelated merge into a 10K-chunk pressure run.
storageStress: false,
storybook: true,
workspaces: [...graph.dirs],
workspaces,
...workspaceLanes(workspaces),
};
}

Expand Down Expand Up @@ -204,7 +215,6 @@ export function planTests(changedFiles, options = {}) {
directWorkspaces.has('packages/ui') ||
files.some((path) => E2E_DRIVING_SCRIPTS.has(path)),
full: false,
headless: workspaces.includes('packages/headless'),
// packages/cli/src/__tests__/runtime-bootstrap.test.ts executes real sandboxed
// shell tools, so the bubblewrap + user-namespace setup is required whenever
// the cli workspace runs in the dependency closure, not only for direct
Expand All @@ -217,6 +227,7 @@ export function planTests(changedFiles, options = {}) {
// isStorybookPath.
storybook: files.some((path) => isStorybookPath(path)),
workspaces,
...workspaceLanes(workspaces),
};
}

Expand All @@ -226,11 +237,13 @@ export function formatGitHubOutputs(plan) {
`e2e=${plan.e2e}`,
`full=${plan.full}`,
`headless=${plan.headless}`,
`runtime_host=${plan.runtimeHost}`,
`runtime_sandbox=${plan.runtimeSandbox}`,
`script_mode=${plan.scriptMode}`,
`storage_stress=${plan.storageStress}`,
`storybook=${plan.storybook}`,
`unit=${plan.workspaces.length > 0}`,
`standard_workspaces=${plan.standardWorkspaces.join(',')}`,
`workspaces=${plan.workspaces.join(',')}`,
].join('\n');
}
Expand Down
32 changes: 31 additions & 1 deletion scripts/ci-test-plan.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { loadWorkspaceGraph, planTests } from './ci-test-plan.mjs';
import { formatGitHubOutputs, loadWorkspaceGraph, planTests } from './ci-test-plan.mjs';

const graph = loadWorkspaceGraph();

Expand Down Expand Up @@ -100,6 +100,36 @@ test('sandbox is flagged whenever the cli workspace runs in the closure', () =>
}
});

test('heavy workspaces are projected onto dedicated CI lanes', () => {
const runtimeHost = planTests(['packages/runtime-host/src/server/index.ts'], { graph });
assert.equal(runtimeHost.runtimeHost, true);
assert.equal(runtimeHost.headless, false);
assert.deepEqual(runtimeHost.standardWorkspaces, ['packages/cli']);

const runtime = planTests(['packages/runtime/src/index.ts'], { graph });
assert.equal(runtime.runtimeHost, true);
assert.equal(runtime.headless, true);
assert.deepEqual(runtime.standardWorkspaces, [
'packages/runtime',
'packages/computer-use',
'packages/cli',
'apps/desktop',
]);

const full = planTests([], { graph, forceFull: true });
assert.equal(full.runtimeHost, true);
assert.equal(full.headless, true);
assert.deepEqual(
full.standardWorkspaces,
graph.dirs.filter((dir) => dir !== 'packages/runtime-host' && dir !== 'packages/headless'),
);

const outputs = formatGitHubOutputs(runtimeHost).split('\n');
assert.ok(outputs.includes('runtime_host=true'));
assert.ok(outputs.includes('headless=false'));
assert.ok(outputs.includes('standard_workspaces=packages/cli'));
});

test('global and unknown production changes fail safe to the complete suite', () => {
for (const path of ['package-lock.json', 'scripts/ci-test-plan.mjs', 'new-root/file.ts']) {
const plan = planTests([path], { graph });
Expand Down
Loading