diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index bdfabd3f4..8c6c78603 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -20,14 +20,29 @@ ci: - ".github/workflows/ci.yml" format: - - ".github/**/*.yml" - - "docs/**/*.md" - - "packages/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}" - - "*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}" + - ".agents/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}" + - ".github/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}" + - "**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}" quality: - - "packages/**/*.{cjs,js,json,jsx,mjs,ts,tsx}" - - "*.{cjs,js,json,jsx,mjs,ts,tsx}" + - "**/*.{cjs,js,json,jsx,mjs,ts,tsx}" + +daemon_launch: + - "scripts/daemon-launch-contract.test.mjs" + - "packages/server/package.json" + - "packages/app/e2e/global-setup.ts" + - "packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts" + - "packages/desktop/src/daemon/runtime-paths.ts" + - "nix/package.nix" + +hub_cli: + - "packages/cli/src/commands/hub/**" + - "packages/cli/src/output/**" + - "packages/cli/src/utils/client.ts" + - "packages/cli/src/utils/command-options.ts" + - "packages/server/src/server/hub/hub-cli-contract.e2e.test.ts" + - "packages/server/src/server/hub/test-utils/relationship-harness.ts" + - "packages/server/src/server/test-utils/hub-cli-entry.ts" server: - "packages/server/**" @@ -38,6 +53,23 @@ desktop: - "packages/desktop/**" - "vitest.config.ts" +desktop_bridge: + - "packages/desktop/src/main.ts" + - "packages/desktop/src/preload.ts" + - "packages/desktop/src/features/browser-automation/**" + - "packages/desktop/src/features/browser-profile.ts" + - "packages/desktop/src/features/browser-profile.test.ts" + - "packages/desktop/src/features/browser-webviews/**" + - "packages/desktop/scripts/browser-tab-bridge.e2e.mjs" + - "packages/desktop/scripts/dev-runner.mjs" + - "packages/desktop/package.json" + - "packages/desktop/tsconfig.json" + - "packages/app/src/components/browser-pane.electron.tsx" + - "packages/app/src/panels/browser-panel.tsx" + - "packages/app/src/panels/register-panels.ts" + - "packages/app/metro.config.cjs" + - "packages/app/package.json" + app: - "packages/app/**" - "vitest.config.ts" @@ -55,15 +87,16 @@ playwright: - "packages/app/index.ts" - "packages/app/*config.{cjs,js,ts}" - "packages/app/package.json" - - "packages/server/**" playwright_desktop: - - "packages/desktop/**" - "packages/app/src/**/*.electron.{ts,tsx}" + - "packages/app/e2e/fixtures.ts" - "packages/app/e2e/project-picker-desktop.spec.ts" - - "packages/app/e2e/helpers/desktop-updates.ts" + - "packages/app/e2e/helpers/**" - "packages/app/e2e/global-setup.ts" + - "packages/app/metro.config.cjs" - "packages/app/playwright.config.ts" + - "packages/app/package.json" relay: - "packages/relay/**" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a7cb4133..f1c40a66a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,11 @@ jobs: full: ${{ github.event_name != 'pull_request' || steps.filter.outputs.routing != 'false' || steps.filter.outputs.workspace != 'false' || steps.filter.outputs.ci != 'false' }} format: ${{ steps.filter.outputs.format }} quality: ${{ steps.filter.outputs.quality }} + daemon_launch: ${{ steps.filter.outputs.daemon_launch }} + hub_cli: ${{ steps.filter.outputs.hub_cli }} server: ${{ steps.filter.outputs.server }} desktop: ${{ steps.filter.outputs.desktop }} + desktop_bridge: ${{ steps.filter.outputs.desktop_bridge }} app: ${{ steps.filter.outputs.app }} sdk: ${{ steps.filter.outputs.sdk }} playwright: ${{ steps.filter.outputs.playwright }} @@ -128,6 +131,45 @@ jobs: npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server + daemon-launch-contract: + name: daemon-launch-contract + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.daemon_launch != 'false') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Verify daemon launch supervision + run: node --test scripts/daemon-launch-contract.test.mjs + + hub-cli-contract: + name: hub-cli-contract + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.hub_cli != 'false') }} + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + 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 server dependencies + run: npm run build:server-deps + + - name: Verify the Hub CLI/daemon boundary + run: npm run test:hub-cli-contract --workspace=@getpaseo/server + server-tests-ubuntu: name: server-tests (ubuntu-latest) needs: changes @@ -198,29 +240,6 @@ jobs: - name: Run desktop tests run: npm run test --workspace=@getpaseo/desktop - - name: Build app dependencies for desktop E2E - if: runner.os == 'Linux' - run: npm run build:app-deps - - - name: Install virtual display - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y xvfb xauth - - - name: Run real Electron browser tab bridge E2E - if: runner.os == 'Linux' - run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop - env: - PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e - - - name: Upload browser tab bridge diagnostics - uses: actions/upload-artifact@v4 - if: failure() && runner.os == 'Linux' - with: - name: browser-tab-bridge-e2e - path: ${{ runner.temp }}/browser-tab-bridge-e2e - if-no-files-found: ignore - retention-days: 7 - - name: Build and smoke unpacked desktop app if: runner.os == 'Linux' run: npm run build:desktop -- --publish never --linux --x64 --dir @@ -248,6 +267,48 @@ jobs: contents: read steps: *desktop_test_steps + desktop-browser-bridge: + name: desktop-browser-bridge + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop_bridge != 'false') }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies with retry + run: node scripts/npm-retry.mjs ci + + - name: Build server stack + run: npm run build:server + + - name: Build app dependencies + run: npm run build:app-deps + + - name: Install virtual display + run: sudo apt-get update && sudo apt-get install -y xvfb xauth + + - name: Run real Electron browser tab bridge E2E + run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop + env: + PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e + + - name: Upload browser tab bridge diagnostics + uses: actions/upload-artifact@v4 + if: failure() + with: + name: browser-tab-bridge-e2e + path: ${{ runner.temp }}/browser-tab-bridge-e2e + if-no-files-found: ignore + retention-days: 7 + app-tests: name: app-tests needs: changes diff --git a/docs/testing.md b/docs/testing.md index ffa0b8d8a..1238bf8ed 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -128,7 +128,7 @@ npm run build:desktop -- --publish never --linux --x64 --dir The desktop browser tab bridge E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click. -Run it locally with the same command owned by the Ubuntu leg of the existing `desktop-tests` CI check: +Run it locally with the same command owned by the `desktop-browser-bridge` CI contract: ```bash npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop @@ -185,6 +185,8 @@ PR checks are routed by the behavior each suite proves, using `.github/ci-paths. Required matrix legs are declared as statically named jobs. Their shared steps use YAML anchors, while job-level `if` conditions let GitHub report an unaffected leg as genuinely skipped without allocating a runner or losing the exact required-check name. +The smallest meaningful contract wins over package ownership. For example, daemon launch supervision is a source-level assertion across app, server, desktop, and Nix entrypoints; its CI job reads those entrypoints without building any product. The Hub CLI/daemon boundary and the Electron browser bridge likewise run focused integration checks instead of widening the full server or desktop suites. + ## Agent authentication in tests Agent providers handle their own auth. Do not add auth checks, environment variable gates, or conditional skips to tests. If auth fails, report it. diff --git a/packages/cli/tests/26-daemon-launch-supervision.test.ts b/packages/cli/tests/26-daemon-launch-supervision.test.ts deleted file mode 100644 index e0e00bd00..000000000 --- a/packages/cli/tests/26-daemon-launch-supervision.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env npx tsx - -/** - * Regression: executable daemon launch commands must enter the supervisor. - * The worker entry remains an implementation detail of supervisor-entrypoint. - */ - -import assert from "node:assert"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; - -const repoRoot = join(import.meta.dirname, "../../.."); -const serverPackagePath = join(repoRoot, "packages/server/package.json"); -const appGlobalSetupPath = join(repoRoot, "packages/app/e2e/global-setup.ts"); -const serverConnectionOfferE2ePath = join( - repoRoot, - "packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts", -); -const desktopRuntimePathsPath = join(repoRoot, "packages/desktop/src/daemon/runtime-paths.ts"); -const nixPackagePath = join(repoRoot, "nix/package.nix"); - -function assertNoDirectWorkerLaunch(label: string, command: string): void { - assert( - !command.includes("src/server/index.ts"), - `${label} must not launch src/server/index.ts directly: ${command}`, - ); - assert( - !command.includes("dist/server/server/index.js"), - `${label} must not launch dist/server/server/index.js directly: ${command}`, - ); - assert( - !command.includes("src/server/daemon-worker.ts"), - `${label} must not launch src/server/daemon-worker.ts directly: ${command}`, - ); - assert( - !command.includes("dist/server/server/daemon-worker.js"), - `${label} must not launch dist/server/server/daemon-worker.js directly: ${command}`, - ); -} - -function assertNoSpawnedWorkerEntrypoint(label: string, source: string): void { - assertNoDirectWorkerLaunch(label, source); - assert( - !/spawn\([^)]*["'`][^"'`]*\.\.\/index\.ts["'`]/s.test(source), - `${label} must not spawn ../index.ts directly`, - ); -} - -console.log("=== Daemon Launch Supervision Regression ===\n"); - -console.log("Test 1: server package scripts launch supervisor-entrypoint"); -const serverPackage = JSON.parse(await readFile(serverPackagePath, "utf-8")) as { - scripts?: Record; -}; -const startScript = serverPackage.scripts?.start ?? ""; -const devScript = serverPackage.scripts?.dev ?? ""; -const devTsxScript = serverPackage.scripts?.["dev:tsx"] ?? ""; - -assert(startScript.includes("dist/scripts/supervisor-entrypoint.js"), startScript); -assertNoDirectWorkerLaunch("server start script", startScript); -assert(devScript.includes("scripts/dev-runner.ts"), devScript); -assertNoDirectWorkerLaunch("server dev script", devScript); -assert(devTsxScript.includes("scripts/dev-runner.ts"), devTsxScript); -assertNoDirectWorkerLaunch("server dev:tsx script", devTsxScript); -console.log("✓ server package scripts enter supervisor\n"); - -console.log("Test 2: app e2e global setup launches supervisor-entrypoint in dev mode"); -const appGlobalSetup = await readFile(appGlobalSetupPath, "utf-8"); -assert( - appGlobalSetup.includes('spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"]'), - "app e2e setup should spawn supervisor-entrypoint.ts with --dev", -); -assertNoSpawnedWorkerEntrypoint("app e2e global setup", appGlobalSetup); -console.log("✓ app e2e setup enters supervisor\n"); - -console.log("Test 3: server daemon e2e process launch enters supervisor"); -const serverConnectionOfferE2e = await readFile(serverConnectionOfferE2ePath, "utf-8"); -assert( - serverConnectionOfferE2e.includes("scripts/supervisor-entrypoint.ts"), - "server daemon e2e process launch should use supervisor-entrypoint.ts", -); -assertNoSpawnedWorkerEntrypoint("server daemon e2e process launch", serverConnectionOfferE2e); -console.log("✓ server daemon e2e process launch enters supervisor\n"); - -console.log("Test 4: desktop runtime and Nix wrapper point at supervisor-entrypoint"); -const desktopRuntimePaths = await readFile(desktopRuntimePathsPath, "utf-8"); -assert( - desktopRuntimePaths.includes('"dist", "scripts", "supervisor-entrypoint.js"'), - "desktop packaged daemon runner should resolve dist/scripts/supervisor-entrypoint.js", -); -assert( - desktopRuntimePaths.includes('"scripts", "supervisor-entrypoint.ts"'), - "desktop dev daemon runner should resolve scripts/supervisor-entrypoint.ts", -); -assertNoDirectWorkerLaunch("desktop runtime paths", desktopRuntimePaths); - -const nixPackage = await readFile(nixPackagePath, "utf-8"); -assert( - nixPackage.includes("dist/scripts/supervisor-entrypoint.js"), - "Nix paseo-server wrapper should use dist/scripts/supervisor-entrypoint.js", -); -assertNoDirectWorkerLaunch("Nix package wrapper", nixPackage); -console.log("✓ desktop runtime and Nix wrapper enter supervisor\n"); - -console.log("=== Daemon launch supervision regression test passed ==="); diff --git a/packages/server/package.json b/packages/server/package.json index 577cb449c..3c9231f7d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -51,6 +51,7 @@ "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/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts", + "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.e2e.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", "test:integration:local": "vitest run local.e2e.test.ts", diff --git a/packages/server/src/server/hub/hub-cli-contract.e2e.test.ts b/packages/server/src/server/hub/hub-cli-contract.e2e.test.ts new file mode 100644 index 000000000..32aa866ae --- /dev/null +++ b/packages/server/src/server/hub/hub-cli-contract.e2e.test.ts @@ -0,0 +1,28 @@ +import { afterEach, expect, test } from "vitest"; +import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; + +let relationship: HubRelationshipHarness | null = null; + +afterEach(async () => { + await relationship?.close(); + relationship = null; +}); + +test("the Hub CLI connects, reports status, and disconnects through the daemon", async () => { + relationship = await HubRelationshipHarness.start(); + const connected = await relationship.beginConnect().result; + relationship.connectLatestSocket(); + + const status = await relationship.status(); + const enrollment = relationship.enrollmentAttempts()[0]; + const secret = relationship.relationshipFile()?.credential?.secret; + const disconnected = await relationship.disconnect(); + + expect(connected.state).toBe("connecting"); + expect(status.state).toBe("connected"); + expect(relationship.loggableValues(status)).not.toContain(secret); + expect(relationship.loggableValues(status)).not.toContain(enrollment.credentialVerifier); + expect(relationship.loggableValues(status)).not.toContain(enrollment.token); + expect(relationship.loggableValues(status)).not.toContain(enrollment.idempotencyKey); + expect(disconnected.state).toBe("not_connected"); +}, 30_000); diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index d7bbe1f68..763d33692 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -24,25 +24,6 @@ describe("Hub relationship", () => { relationship = null; }); - test("the CLI connects, reports status, and disconnects through the daemon", async () => { - relationship = await HubRelationshipHarness.start(); - const connected = await relationship.beginConnect().result; - relationship.connectLatestSocket(); - - const status = await relationship.status(); - const enrollment = relationship.enrollmentAttempts()[0]; - const secret = relationship.relationshipFile()?.credential?.secret; - const disconnected = await relationship.disconnect(); - - expect(connected.state).toBe("connecting"); - expect(status.state).toBe("connected"); - expect(relationship.loggableValues(status)).not.toContain(secret); - expect(relationship.loggableValues(status)).not.toContain(enrollment.credentialVerifier); - expect(relationship.loggableValues(status)).not.toContain(enrollment.token); - expect(relationship.loggableValues(status)).not.toContain(enrollment.idempotencyKey); - expect(disconnected.state).toBe("not_connected"); - }, 30_000); - test("Hub URLs cannot persist embedded credentials", async () => { relationship = await HubRelationshipHarness.start(); diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 5c11f06bf..cfb61c22f 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -9,14 +9,17 @@ const dockerWorkflowPath = new URL(".github/workflows/docker.yml", repoRoot); const nixWorkflowPath = new URL(".github/workflows/nix.yml", repoRoot); const filtersPath = new URL(".github/ci-paths.yml", repoRoot); -const requiredCiJobs = new Map([ +const gatedCiJobs = new Map([ ["format", { name: "format", contract: "format" }], ["lint", { name: "lint", contract: "quality" }], ["typecheck", { name: "typecheck", contract: "quality" }], + ["daemon-launch-contract", { name: "daemon-launch-contract", contract: "daemon_launch" }], + ["hub-cli-contract", { name: "hub-cli-contract", contract: "hub_cli" }], ["server-tests-ubuntu", { name: "server-tests (ubuntu-latest)", contract: "server" }], ["server-tests-windows", { name: "server-tests (windows-latest)", contract: "server" }], ["desktop-tests-ubuntu", { name: "desktop-tests (ubuntu-latest)", contract: "desktop" }], ["desktop-tests-windows", { name: "desktop-tests (windows-latest)", contract: "desktop" }], + ["desktop-browser-bridge", { name: "desktop-browser-bridge", contract: "desktop_bridge" }], ["app-tests", { name: "app-tests", contract: "app" }], ["sdk-tests", { name: "sdk-tests", contract: "sdk" }], ["playwright-1", { name: "playwright (shard 1/4)", contract: "playwright" }], @@ -80,14 +83,14 @@ function affectedContracts(filters, changedPath) { return direct.filter((filterName) => !["routing", "workspace", "ci"].includes(filterName)).sort(); } -test("required checks are statically named jobs with real job-level gating", () => { +test("gated checks are statically named jobs with real job-level gating", () => { const workflowSource = readFileSync(ciWorkflowPath, "utf8"); const jobs = jobBlocks(workflowSource); assert.doesNotMatch(workflowSource, /strategy:\s*\n\s+matrix:/); assert.doesNotMatch(workflowSource, /RUN_TESTS|Skip unaffected|No .* changes detected/); - for (const [jobId, expected] of requiredCiJobs) { + for (const [jobId, expected] of gatedCiJobs) { const job = jobs.get(jobId)?.join("\n"); assert.ok(job, `missing static job ${jobId}`); assert.match(job, new RegExp(`^ name: ${expected.name.replace(/[()]/g, "\\$&")}$`, "m")); @@ -107,18 +110,67 @@ test("change gating allows superseded workflow runs to cancel", () => { } }); +test("cross-package contracts run focused checks instead of package builds", () => { + const jobs = jobBlocks(readFileSync(ciWorkflowPath, "utf8")); + const daemonLaunch = jobs.get("daemon-launch-contract")?.join("\n") ?? ""; + const hubCli = jobs.get("hub-cli-contract")?.join("\n") ?? ""; + const desktop = jobs.get("desktop-tests-ubuntu")?.join("\n") ?? ""; + const desktopBridge = jobs.get("desktop-browser-bridge")?.join("\n") ?? ""; + + assert.match(daemonLaunch, /node --test scripts\/daemon-launch-contract\.test\.mjs/); + assert.doesNotMatch(daemonLaunch, /Install dependencies|npm run build/); + + assert.match(hubCli, /test:hub-cli-contract/); + assert.doesNotMatch(hubCli, /npm run test --workspace=@getpaseo\/server/); + + assert.doesNotMatch(desktop, /browser-tab-bridge/); + assert.match(desktopBridge, /test:e2e:browser-tab-bridge/); +}); + test("PR routing follows test contracts instead of package consumers", () => { const filters = loadFilters(filtersPath); const cases = new Map([ ["packages/app/src/components/message.tsx", ["app", "format", "playwright", "quality"]], - ["packages/server/src/server/bootstrap.ts", ["format", "playwright", "quality", "server"]], + ["packages/server/src/server/bootstrap.ts", ["format", "quality", "server"]], ["packages/cli/src/commands/agent/ls.ts", ["cli", "format", "quality"]], - ["packages/desktop/src/main.ts", ["desktop", "format", "playwright_desktop", "quality"]], + ["packages/desktop/src/main.ts", ["desktop", "desktop_bridge", "format", "quality"]], + [ + "packages/app/src/components/browser-pane.electron.tsx", + ["app", "desktop_bridge", "format", "playwright", "playwright_desktop", "quality"], + ], + ["packages/cli/src/commands/hub/index.ts", ["cli", "format", "hub_cli", "quality"]], ["packages/protocol/src/messages.ts", ["format", "quality", "sdk"]], ["packages/client/src/client.ts", ["format", "quality", "sdk"]], ["packages/relay/src/index.ts", ["format", "quality", "relay"]], ["docker/base/Dockerfile", ["docker"]], - ["nix/package.nix", ["nix"]], + ["nix/package.nix", ["daemon_launch", "nix"]], + ]); + + for (const [changedPath, expected] of cases) { + assert.deepEqual(affectedContracts(filters, changedPath), expected, changedPath); + } +}); + +test("tooling and narrow cross-package contracts follow their actual inputs", () => { + const filters = loadFilters(filtersPath); + const cases = new Map([ + ["public-docs/cli.md", ["format"]], + ["skills/paseo/SKILL.md", ["format"]], + [".github/PULL_REQUEST_TEMPLATE.md", ["format"]], + [".agents/skills/release-beta/SKILL.md", ["format"]], + ["docker/docker-compose.example.yml", ["docker", "format"]], + [ + "packages/app/e2e/helpers/project-picker-ui.ts", + ["app", "format", "playwright", "playwright_desktop", "quality"], + ], + [ + "packages/app/e2e/global-setup.ts", + ["app", "daemon_launch", "format", "playwright", "playwright_desktop", "quality"], + ], + [ + "packages/desktop/src/daemon/runtime-paths.ts", + ["daemon_launch", "desktop", "format", "quality"], + ], ]); for (const [changedPath, expected] of cases) { diff --git a/scripts/daemon-launch-contract.test.mjs b/scripts/daemon-launch-contract.test.mjs new file mode 100644 index 000000000..3dd459fb1 --- /dev/null +++ b/scripts/daemon-launch-contract.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; + +const repoRoot = join(import.meta.dirname, ".."); + +function assertNoDirectWorkerLaunch(label, command) { + for (const workerEntrypoint of [ + "src/server/index.ts", + "dist/server/server/index.js", + "src/server/daemon-worker.ts", + "dist/server/server/daemon-worker.js", + ]) { + assert.ok( + !command.includes(workerEntrypoint), + `${label} must not launch ${workerEntrypoint} directly: ${command}`, + ); + } +} + +function assertNoSpawnedWorkerEntrypoint(label, source) { + assertNoDirectWorkerLaunch(label, source); + assert.doesNotMatch( + source, + /spawn\([^)]*["'`][^"'`]*\.\.\/index\.ts["'`]/s, + `${label} must not spawn ../index.ts directly`, + ); +} + +test("every executable daemon entrypoint enters the supervisor", async () => { + const [ + serverPackageSource, + appGlobalSetup, + serverConnectionOfferE2e, + desktopRuntimePaths, + nixPackage, + ] = await Promise.all([ + readFile(join(repoRoot, "packages/server/package.json"), "utf8"), + readFile(join(repoRoot, "packages/app/e2e/global-setup.ts"), "utf8"), + readFile( + join(repoRoot, "packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts"), + "utf8", + ), + readFile(join(repoRoot, "packages/desktop/src/daemon/runtime-paths.ts"), "utf8"), + readFile(join(repoRoot, "nix/package.nix"), "utf8"), + ]); + + const serverPackage = JSON.parse(serverPackageSource); + const startScript = serverPackage.scripts?.start ?? ""; + const devScript = serverPackage.scripts?.dev ?? ""; + const devTsxScript = serverPackage.scripts?.["dev:tsx"] ?? ""; + + assert.match(startScript, /dist\/scripts\/supervisor-entrypoint\.js/); + assertNoDirectWorkerLaunch("server start script", startScript); + assert.match(devScript, /scripts\/dev-runner\.ts/); + assertNoDirectWorkerLaunch("server dev script", devScript); + assert.match(devTsxScript, /scripts\/dev-runner\.ts/); + assertNoDirectWorkerLaunch("server dev:tsx script", devTsxScript); + + assert.match(appGlobalSetup, /spawn\(tsxBin, \["scripts\/supervisor-entrypoint\.ts", "--dev"\]/); + assertNoSpawnedWorkerEntrypoint("app e2e global setup", appGlobalSetup); + + assert.match(serverConnectionOfferE2e, /scripts\/supervisor-entrypoint\.ts/); + assertNoSpawnedWorkerEntrypoint("server daemon e2e process launch", serverConnectionOfferE2e); + + assert.match(desktopRuntimePaths, /"dist", "scripts", "supervisor-entrypoint\.js"/); + assert.match(desktopRuntimePaths, /"scripts", "supervisor-entrypoint\.ts"/); + assertNoDirectWorkerLaunch("desktop runtime paths", desktopRuntimePaths); + + assert.match(nixPackage, /dist\/scripts\/supervisor-entrypoint\.js/); + assertNoDirectWorkerLaunch("Nix package wrapper", nixPackage); +});