From 2fda4b69d115469e296ecc3e155a294a6ff94e2f Mon Sep 17 00:00:00 2001 From: Soorya U Date: Mon, 27 Jul 2026 00:27:40 +0530 Subject: [PATCH 1/2] Remove the end-to-end test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Playwright/PTY e2e harness (tests/e2e) carried real infrastructure cost — a compiled cyrusd binary rebuilt per run, wrangler dev + local D1 + Vite orchestrated through process-compose, a PTY driver, Playwright browsers — but only ever ran on manual workflow_dispatch, never on PRs or main, and never reached the stable pass history its own docs said cron was waiting on. That's pure maintenance surface with no day-to-day safety net, so it's removed along with the nightly workflow that ran it and the process-compose/shell-use tooling that only existed to run it. tooling/test keeps the two pieces genuinely shared outside tests/e2e (cyrus-home.ts, used by apps/cli's own tests; auth-env.ts, used by the root @cyrus/server Vitest project) and drops everything else that was e2e-only. See docs/adr/0019-remove-end-to-end-test-suite.md for the full record, including which OpenSpecs lose cross-process/browser coverage as a result and how to recover the removed harness from git history if a future need re-justifies its cost. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/nightly.yml | 76 ---- apps/cli/__tests__/integration/wiring.test.ts | 6 +- apps/cli/package.json | 1 + apps/cli/src/commands/service/status.test.ts | 17 +- apps/cli/src/store/health.test.ts | 18 +- biome.json | 2 + bun.lock | 27 +- ...017-vitest-default-bun-only-cli-desktop.md | 2 +- docs/adr/0019-remove-end-to-end-test-suite.md | 11 + docs/guides/TESTING_FRAMEWORK.md | 53 +-- docs/guides/VERIFY_LOOP.md | 45 +-- knip.json | 15 +- mise.toml | 2 - package.json | 4 +- tests/e2e/harness/auth.ts | 115 ------ tests/e2e/harness/cli-doctor-terminal.test.ts | 125 ------ tests/e2e/harness/cli-login-terminal.test.ts | 157 -------- tests/e2e/harness/cli-login.test.ts | 95 ----- tests/e2e/harness/cli-login.ts | 166 -------- .../e2e/harness/cli-service-terminal.test.ts | 277 ------------- tests/e2e/harness/cli-worker.ts | 75 ---- tests/e2e/harness/database.test.ts | 55 --- tests/e2e/harness/database.ts | 61 --- tests/e2e/harness/dev-servers.ts | 2 - tests/e2e/harness/env.ts | 66 ---- tests/e2e/harness/process-compose.test.ts | 92 ----- tests/e2e/harness/process-compose.ts | 367 ------------------ tests/e2e/harness/process.ts | 20 - tests/e2e/harness/shell-use.ts | 173 --------- tests/e2e/harness/stack.ts | 148 ------- tests/e2e/package.json | 19 - tests/e2e/process-compose.yaml | 75 ---- tests/e2e/tsconfig.json | 9 - tests/e2e/web/device-auth.ts | 82 ---- tests/e2e/web/fixtures.ts | 67 ---- tests/e2e/web/helpers.ts | 176 --------- tests/e2e/web/playwright.config.ts | 22 -- tests/e2e/web/prepare-database.ts | 5 - tests/e2e/web/specs/catalog.spec.ts | 61 --- tests/e2e/web/specs/cold-resume.spec.ts | 55 --- tests/e2e/web/specs/smoke.spec.ts | 18 - tests/e2e/web/specs/thread-lifecycle.spec.ts | 37 -- tests/e2e/web/specs/thread-sync.spec.ts | 18 - tests/e2e/web/specs/worker-connects.spec.ts | 18 - tooling/test/fixtures/cyrus-home.ts | 29 ++ tooling/test/mocks/auth-env.ts | 10 + tooling/test/mocks/data-channel.ts | 61 --- tooling/test/package.json | 3 +- tooling/test/setup/bun.setup.ts | 2 - turbo.json | 3 - vitest.config.ts | 25 +- 51 files changed, 92 insertions(+), 2976 deletions(-) delete mode 100644 .github/workflows/nightly.yml create mode 100644 docs/adr/0019-remove-end-to-end-test-suite.md delete mode 100644 tests/e2e/harness/auth.ts delete mode 100644 tests/e2e/harness/cli-doctor-terminal.test.ts delete mode 100644 tests/e2e/harness/cli-login-terminal.test.ts delete mode 100644 tests/e2e/harness/cli-login.test.ts delete mode 100644 tests/e2e/harness/cli-login.ts delete mode 100644 tests/e2e/harness/cli-service-terminal.test.ts delete mode 100644 tests/e2e/harness/cli-worker.ts delete mode 100644 tests/e2e/harness/database.test.ts delete mode 100644 tests/e2e/harness/database.ts delete mode 100644 tests/e2e/harness/dev-servers.ts delete mode 100644 tests/e2e/harness/env.ts delete mode 100644 tests/e2e/harness/process-compose.test.ts delete mode 100644 tests/e2e/harness/process-compose.ts delete mode 100644 tests/e2e/harness/process.ts delete mode 100644 tests/e2e/harness/shell-use.ts delete mode 100644 tests/e2e/harness/stack.ts delete mode 100644 tests/e2e/package.json delete mode 100644 tests/e2e/process-compose.yaml delete mode 100644 tests/e2e/tsconfig.json delete mode 100644 tests/e2e/web/device-auth.ts delete mode 100644 tests/e2e/web/fixtures.ts delete mode 100644 tests/e2e/web/helpers.ts delete mode 100644 tests/e2e/web/playwright.config.ts delete mode 100644 tests/e2e/web/prepare-database.ts delete mode 100644 tests/e2e/web/specs/catalog.spec.ts delete mode 100644 tests/e2e/web/specs/cold-resume.spec.ts delete mode 100644 tests/e2e/web/specs/smoke.spec.ts delete mode 100644 tests/e2e/web/specs/thread-lifecycle.spec.ts delete mode 100644 tests/e2e/web/specs/thread-sync.spec.ts delete mode 100644 tests/e2e/web/specs/worker-connects.spec.ts create mode 100644 tooling/test/fixtures/cyrus-home.ts create mode 100644 tooling/test/mocks/auth-env.ts delete mode 100644 tooling/test/mocks/data-channel.ts delete mode 100644 tooling/test/setup/bun.setup.ts diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml deleted file mode 100644 index f163c12..0000000 --- a/.github/workflows/nightly.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Nightly - -# Cron disabled for now — manual runs only via workflow_dispatch. -on: - workflow_dispatch: - -concurrency: - group: nightly-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -env: - TURBO_TELEMETRY_DISABLED: 1 - NODE_ENV: "testing" - -jobs: - test-e2e: - name: End-to-End Tests - runs-on: ubuntu-latest - environment: testing - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v5 - with: - persist-credentials: false - - uses: ./tooling/github/setup - - - uses: jdx/mise-action@v3 - with: - install_args: process-compose github:microsoft/shell-use - github_token: ${{ secrets.GITHUB_TOKEN }} - - - name: Install Playwright browsers - run: bunx playwright install --with-deps chromium - working-directory: tests/e2e - - - name: End-to-end tests - run: bun test:e2e - env: - NODE_ENV: "testing" - - build-smoke: - name: Build Smoke - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - uses: actions/checkout@v5 - with: - persist-credentials: false - - uses: ./tooling/github/setup - - - name: Build web - run: bun build:web - - - name: Build CLI binary - run: bun -F @cyrus/cli build - - webrtc-nightly: - name: Real WebRTC - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - uses: actions/checkout@v5 - with: - persist-credentials: false - - uses: ./tooling/github/setup - - - name: node-datachannel integration - run: bunx vitest run --project @cyrus/connections - env: - CYRUS_NIGHTLY_WEBRTC: "1" diff --git a/apps/cli/__tests__/integration/wiring.test.ts b/apps/cli/__tests__/integration/wiring.test.ts index a1034dd..e9e046f 100644 --- a/apps/cli/__tests__/integration/wiring.test.ts +++ b/apps/cli/__tests__/integration/wiring.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { rm } from "node:fs/promises"; import { join } from "node:path"; import type { AgentEvent } from "@cyrus/schemas/rtc/chat"; +import { createTempCyrusHome } from "@cyrus/test/fixtures/cyrus-home"; import { Result } from "better-result"; import { runTurn } from "../../src/utils/run-turn"; import { createMockPromptStream } from "../helpers/acp-runtime"; @@ -87,7 +87,7 @@ describe("acp mock runtime", () => { describe("cli process integration", () => { test("exits when start is invoked without a login token", async () => { - const home = await mkdtemp(join(tmpdir(), "cyrus-cli-test-")); + const home = await createTempCyrusHome("cyrus-cli-test-"); try { const proc = Bun.spawn(["bun", "src/cli.ts", "start"], { diff --git a/apps/cli/package.json b/apps/cli/package.json index c082517..00dead1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,6 +43,7 @@ "zod": "catalog:rpc" }, "devDependencies": { + "@cyrus/test": "workspace:*", "@cyrus/typescript": "workspace:*", "@types/bun": "catalog:core", "@types/diff": "^7.0.0", diff --git a/apps/cli/src/commands/service/status.test.ts b/apps/cli/src/commands/service/status.test.ts index bdbe46f..c858a24 100644 --- a/apps/cli/src/commands/service/status.test.ts +++ b/apps/cli/src/commands/service/status.test.ts @@ -1,17 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; import { join } from "node:path"; +import { tempCyrusHomeFixture } from "@cyrus/test/fixtures/cyrus-home"; import { markHealthReady, markHealthStarting } from "@/store/health"; const CLI = join(import.meta.dir, "../../cli.ts"); -const homes: string[] = []; - -async function tempHome(): Promise { - const home = await mkdtemp(join(tmpdir(), "cyrus-status-")); - homes.push(home); - return home; -} +const tempHome = tempCyrusHomeFixture(afterEach, "cyrus-status-"); async function runStatus(home: string): Promise<{ exitCode: number; @@ -37,12 +30,6 @@ async function runStatus(home: string): Promise<{ return { exitCode, stdout, stderr }; } -afterEach(async () => { - await Promise.all( - homes.splice(0).map((home) => rm(home, { recursive: true, force: true })) - ); -}); - describe("cyrusd status", () => { test("exits 1 when the worker is not running", async () => { const home = await tempHome(); diff --git a/apps/cli/src/store/health.test.ts b/apps/cli/src/store/health.test.ts index 0a3d50a..8b99ffa 100644 --- a/apps/cli/src/store/health.test.ts +++ b/apps/cli/src/store/health.test.ts @@ -1,7 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { tempCyrusHomeFixture } from "@cyrus/test/fixtures/cyrus-home"; import { clearHealth, DEFAULT_HEALTH_STALE_MS, @@ -12,19 +10,7 @@ import { touchHeartbeat, } from "@/store/health"; -const homes: string[] = []; - -async function tempHome(): Promise { - const home = await mkdtemp(join(tmpdir(), "cyrus-health-")); - homes.push(home); - return home; -} - -afterEach(async () => { - await Promise.all( - homes.splice(0).map((home) => rm(home, { recursive: true, force: true })) - ); -}); +const tempHome = tempCyrusHomeFixture(afterEach, "cyrus-health-"); describe("worker health file", () => { test("isHealthy is false when no health file exists", async () => { diff --git a/biome.json b/biome.json index 58d8490..0f6454d 100644 --- a/biome.json +++ b/biome.json @@ -161,6 +161,8 @@ "!@cyrus/errors/**", "!@cyrus/schemas", "!@cyrus/schemas/**", + "!@cyrus/test", + "!@cyrus/test/**", "!@cyrus/utils", "!@cyrus/utils/**" ], diff --git a/bun.lock b/bun.lock index be95f91..ee10c90 100644 --- a/bun.lock +++ b/bun.lock @@ -59,6 +59,7 @@ "zod": "catalog:rpc", }, "devDependencies": { + "@cyrus/test": "workspace:*", "@cyrus/typescript": "workspace:*", "@types/bun": "catalog:core", "@types/diff": "^7.0.0", @@ -394,20 +395,6 @@ "vitest": "catalog:testing", }, }, - "tests/e2e": { - "name": "@cyrus/e2e", - "dependencies": { - "better-result": "catalog:core", - "yaml": "2.9.0", - }, - "devDependencies": { - "@cyrus/typescript": "workspace:*", - "@microsoft/shell-use": "0.0.1-beta.5", - "@playwright/test": "^1.61.1", - "@types/bun": "catalog:core", - "vitest": "catalog:testing", - }, - }, "tooling/github": { "name": "@cyrus/github", }, @@ -742,8 +729,6 @@ "@cyrus/desktop": ["@cyrus/desktop@workspace:apps/desktop"], - "@cyrus/e2e": ["@cyrus/e2e@workspace:tests/e2e"], - "@cyrus/errors": ["@cyrus/errors@workspace:shared/errors"], "@cyrus/github": ["@cyrus/github@workspace:tooling/github"], @@ -1086,8 +1071,6 @@ "@mantine/hooks": ["@mantine/hooks@9.4.1", "", { "peerDependencies": { "react": "^19.2.0" } }, "sha512-eTI8wmzPx3r98zgKIEuvukmoGTHBhmtI6+9E6o2DbTmEU2eM1bCdjE2vFdf0op2AlRO0KEYEcZhNQi4T/SJk0A=="], - "@microsoft/shell-use": ["@microsoft/shell-use@0.0.1-beta.5", "", {}, "sha512-vDdV1niyhc6NGLNofQAQFaQB3gLgbnZMUFiRCLEpTDn5LMxsF0B24J+FfagozmlF7ErjKCe+yjVdsP10WAbH7A=="], - "@mikkelscheike/email-provider-links": ["@mikkelscheike/email-provider-links@5.1.8", "", {}, "sha512-I499oaqSgwpzIE8zkAJaKvenUm2au4SVCnrBNxScuPYTFsVvxsJ4kYrkWs23zg1czwDTaN9C+4k8eDHmIONeFw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], @@ -1244,8 +1227,6 @@ "@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="], - "@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="], - "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], @@ -2990,10 +2971,6 @@ "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], - "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], - - "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], - "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], "png-to-ico": ["png-to-ico@2.1.8", "", { "dependencies": { "@types/node": "^17.0.36", "minimist": "^1.2.6", "pngjs": "^6.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w=="], @@ -3812,8 +3789,6 @@ "partyserver/nanoid": ["nanoid@5.1.15", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw=="], - "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], "png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="], diff --git a/docs/adr/0017-vitest-default-bun-only-cli-desktop.md b/docs/adr/0017-vitest-default-bun-only-cli-desktop.md index 75a343d..e59c06e 100644 --- a/docs/adr/0017-vitest-default-bun-only-cli-desktop.md +++ b/docs/adr/0017-vitest-default-bun-only-cli-desktop.md @@ -1,6 +1,6 @@ # Vitest is the default test runner; Bun stays only for `apps/cli` and `apps/desktop` -_Decided 2026-07-20. Supersedes [0016](./0016-server-test-runner-split-by-cloudflare-api-surface.md)._ +_Decided 2026-07-20. Supersedes [0016](./0016-server-test-runner-split-by-cloudflare-api-surface.md). Its `tests/e2e` claims (paragraphs 3–4) are superseded by [0019](./0019-remove-end-to-end-test-suite.md)._ The repo's test-runner rule flips: Vitest is now the default everywhere, and `bun:test` is kept only where the app itself must keep running on Bun at runtime — `apps/cli` and `apps/desktop`. This is permanent, not transitional: there is no Vitest execution environment or pool for Bun or Deno (no `vitest-environment-bun`, no `@vitest/pool-bun`, no Deno equivalent — Vitest's only documented Bun awareness is being invoked via `bun run test` as a package-manager convenience), so if `apps/cli` later moves off Bun onto Deno, its tests migrate with it into whatever Deno's own tooling is, not into this Vitest setup. Every other package moves to Vitest specifically to shrink what has to be touched when that migration happens — including the `shared/*` packages `apps/cli` itself depends on (`connections`, `database`, `schemas`, `utils`), since a package's test-runner choice is independent of which runtime later imports it, and none of them have Bun-specific runtime code today; their only Bun coupling was the `bun:test` import itself. diff --git a/docs/adr/0019-remove-end-to-end-test-suite.md b/docs/adr/0019-remove-end-to-end-test-suite.md new file mode 100644 index 0000000..9464bf6 --- /dev/null +++ b/docs/adr/0019-remove-end-to-end-test-suite.md @@ -0,0 +1,11 @@ +# The end-to-end test suite is removed + +_Decided 2026-07-27. Supersedes the `tests/e2e`-specific claims in [0017](./0017-vitest-default-bun-only-cli-desktop.md) (paragraphs 3–4)._ + +`tests/e2e/` is deleted in full: both the harness-driven Vitest + shell-use terminal tier (`tests/e2e/harness/`, covering `cyrusd login`/`start`/`stop`/`status`/`agents doctor` through a real PTY) and the Playwright browser suite (`tests/e2e/web/`, covering cross-peer flows — `worker-connects`, `catalog`, `thread-lifecycle`, `thread-sync`, `cold-resume`). `.github/workflows/nightly.yml` goes with it, all three jobs: the e2e run itself, build smoke (`build:web`, CLI compile), and the real-WebRTC check (`node-datachannel` against `shared/connections`) — none of the latter two were e2e-specific, but nothing else depended on the nightly workflow file existing. The tooling that only existed to run the suite goes too: `mise.toml`'s `process-compose` and `github:microsoft/shell-use` tools, and the e2e-only fixtures in `tooling/test/` (`fixtures/wrangler-env-file.ts`, `fixtures/terminal-session.ts`, `fixtures/cli-worker-state.ts`, `mocks/cli-worker-config.ts`, `helpers/process.ts`). `tooling/test/fixtures/cyrus-home.ts` and `tooling/test/mocks/auth-env.ts` stay — both are used outside `tests/e2e` too (`apps/cli`'s own unit tests, and the root `@cyrus/server` Vitest project's Miniflare bindings, respectively) — so removing them would have cut real, unrelated coverage for no reason tied to this decision. + +The reason is scope, not fidelity. The suite's entire cost — a compiled `cyrusd` binary rebuilt fresh on every run, a `wrangler dev` + local D1 + Vite stack orchestrated through process-compose, a PTY driver for the terminal tier, Playwright browsers — was carried off the path every contributor actually feels: it ran only on manual `workflow_dispatch`, never on PRs or pushes to `main`, and cron was deliberately deferred pending "a stable pass history" (per the now-superseded `docs/guides/TESTING_FRAMEWORK.md` text) that it never reached in its short life. Real infrastructure cost, zero automatic runs, no established reliability track record: that combination is pure maintenance surface without a safety net anyone could actually depend on day to day. Nothing about the approach itself — real process orchestration over mocking, PTY-driven terminal assertions, Playwright for cross-peer browser flows — was found lacking; this trades that fidelity for not carrying its cost right now. + +Some OpenSpecs lose their only cross-process/browser-level coverage as a result: `acp-provider-cli`, `acp-session-router`, `connection-providers`, and `conversation-persistence` had it in the removed terminal tier or Playwright specs (see the prior `docs/guides/TESTING_FRAMEWORK.md` OpenSpec coverage map) and now rely solely on their existing unit/integration tests. `docs/guides/VERIFY_LOOP.md`'s manual full-stack loop — starting the server, web controller, and CLI worker by hand and driving the browser with `playwright-cli` — is now the only way to exercise these flows end to end, where `bun test:e2e` previously gave a repeatable automated substitute. + +If a future need re-justifies this cost — e.g. a recurring class of bug that only manifests across the real process boundary — the removed harness is fully recoverable from git history (branch `chore/e2e-harness-cleanup`, the session that did this removal). A fresh implementation should reconsider the fidelity-vs-cost tradeoff from scratch rather than resurrect the same shape verbatim, given it never reached the "stable pass history" bar this ADR's predecessor was written against. diff --git a/docs/guides/TESTING_FRAMEWORK.md b/docs/guides/TESTING_FRAMEWORK.md index 3cb9dda..9557beb 100644 --- a/docs/guides/TESTING_FRAMEWORK.md +++ b/docs/guides/TESTING_FRAMEWORK.md @@ -8,20 +8,16 @@ Cyrus uses a layered test setup so each part of the system is tested with the ru | --- | --- | --- | | `apps/cli`, `apps/desktop` | Bun test | Colocated `*.test.ts` or package `__tests__/integration/` | | Vitest workspace packages (`apps/web`, `apps/server`, `shared/*`) | Root Vitest Projects | `vitest.config.ts` at the repo root; shared DOM setup in `tooling/test/setup/vitest.shared.ts` | -| Worker CLI terminal tier | Vitest + shell-use (PTY) | Root `tests/e2e/harness/*-terminal.test.ts` | -| Cross-peer + browser user flows | Playwright | Root `tests/e2e/web/` (process-compose lifecycle) | Vitest is the default runner (ADR 0017). Bun stays permanently for `apps/cli` and `apps/desktop`. -Root `bun test:unit` runs `vitest run --project='@cyrus/*'` (every unit project; `e2e` and `database-integration` sit outside that glob) plus `apps/cli`'s Bun unit suite. Use `vitest run --project ` to scope a single project. DOM packages (`apps/web`, `shared/hooks`, `shared/providers`) share Testing Library jest-dom matchers and DOM cleanup via `@cyrus/test/setup/vitest.shared`. +Root `bun test:unit` runs `vitest run --project='@cyrus/*'` (every unit project; `database-integration` sits outside that glob) plus `apps/cli`'s Bun unit suite. Use `vitest run --project ` to scope a single project. DOM packages (`apps/web`, `shared/hooks`, `shared/providers`) share Testing Library jest-dom matchers and DOM cleanup via `@cyrus/test/setup/vitest.shared`. ## Layout ```text /src/**/*.test.ts /__tests__/integration/ -tests/e2e/harness/ -tests/e2e/web/ tooling/test/ ``` @@ -34,53 +30,28 @@ Unit tests stay close to the code they cover. In `apps/server`, every colocated | 0 | pre-commit | Ultracite only | | 1 | pre-push | Typecheck and unit tests | | 2 | pull request / push to `main` | Lint, typecheck, unit tests, integration | -| 3 | nightly (`workflow_dispatch` only) | E2E (Playwright cross-peer + Worker CLI terminal tier), build smoke, real WebRTC | -| 4 | deploy | Health and WebSocket smoke | - -No E2E subset runs on pull requests. The suite needs Playwright browsers, process-compose, a compiled `cyrusd` binary, and a PTY (shell-use); keep that cost off PR CI now that local D1 removed the old Neon branch provisioning. Nightly stays manual-only for now (cron deferred until the suite has a stable pass history). - -## Phase 4 notes - -- Cross-peer scenarios live under `tests/e2e/web/specs/` (`worker-connects`, `catalog`, `thread-lifecycle`, `thread-sync`, `cold-resume`) and run on Playwright with a real browser as the Controller. -- The harness in `tests/e2e/harness/` plus `tests/e2e/process-compose.yaml` starts `wrangler dev`, `vite`, and an isolated `CYRUS_HOME` CLI worker against **local D1** (migrations applied via `wrangler d1 migrations apply cyrus --local --persist-to `). -- Specs can call `cliWorker.restart()` to replace only the CLI worker while preserving the server, authentication, and isolated `CYRUS_HOME`. `cold-resume.spec.ts` uses this to verify a thread resumes with its persisted session after a worker restart. -- The Playwright suite uses process-compose for peer lifecycle: - 1. The worker-scoped `stack` fixture starts sync server + Controller web via `tests/e2e/process-compose.yaml` (D1 migrations applied by the `prepare-database` process). - 2. The worker-scoped `auth` fixture creates a unique account and drives the real device-authorization page (`/auth/device`) via compiled `cyrusd login`, then writes the token into the stack's `CYRUS_HOME`. - 3. The worker-scoped `cliWorker` fixture starts the Worker process through process-compose and exposes `restart()` for mid-scenario Worker-only restarts (cold-resume). - 4. Specs install the fixture's session cookie in the browser and exercise the real Controller UI against the connected Worker. - 5. After the worker's tests finish, process-compose tears down all managed peers and the temporary `CYRUS_HOME` and Wrangler persist directory are removed. -- The Worker CLI terminal tier (`tests/e2e/harness/*-terminal.test.ts`) drives the compiled `cyrusd` binary through a shell-use PTY with fixed columns/rows, asserting on rendered output (including ANSI colors) and exit codes. Covered commands: `login`, `start`/`stop`/`status`, and `agents doctor`. Non-interactive commands keep their existing in-process coverage. Nightly CI installs the matching `shell-use` binary via mise (`github:microsoft/shell-use`). -- **Per-run D1 isolation:** each E2E stack creates a temporary Wrangler `--persist-to` directory (`WRANGLER_PERSIST_TO`) and passes it to both `prepare-database` (migrations) and `wrangler dev`. That replaces the old shared Neon `test` branch: concurrent or repeated local/CI runs do not share Miniflare/D1 state under `.wrangler/state`. Unique auth emails remain as defense in depth. Manual interactive `wrangler dev` without the harness still uses the default `.wrangler` path and is outside this isolation contract. -- Local E2E runs do not need an external database URL. Wrangler local D1 with a run-scoped `--persist-to` directory is enough. -- Playwright server setup ensures the schema exists before starting the signaling server. -- Programmatic session creation for tests uses Better Auth email sign-in (`tests/e2e/harness/auth.ts`); device approval goes through the real `/auth/device` UI (`tests/e2e/web/device-auth.ts`). Email/password auth is enabled when the server runs with `NODE_ENV=testing`. -- Playwright specs and their worker-scoped fixtures live in `tests/e2e/web/`. -- E2E runs via `.github/workflows/nightly.yml` (`workflow_dispatch` only; cron deferred). +| 3 | deploy | Health and WebSocket smoke | -## Phase 5 notes - -- Deploy smoke runs after every server deploy via `tooling/test/smoke/deploy.ts`. Optional `DEPLOY_SMOKE_TOKEN` and `DEPLOY_SMOKE_ROOM_ID` secrets enable a signaling WebSocket check in addition to `GET /health`. -- Nightly also runs build smoke (`build:web`, CLI compile) and real `node-datachannel` checks (`CYRUS_NIGHTLY_WEBRTC=1`). -- `pre-push` now runs `test:unit` locally; integration and E2E stay in CI only. +`pre-push` runs `test:unit` locally; integration stays in CI only. ## OpenSpec coverage map | OpenSpec | Nearest automated tests | | --- | --- | -| `conversation-view` | `shared/utils/src/fold.test.ts`; Playwright `tests/e2e/web/specs/thread-lifecycle.spec.ts` | +| `conversation-view` | `shared/utils/src/fold.test.ts` | | `wire-schemas` | `shared/schemas/src/**/*.test.ts` | -| `acp-provider-cli` | `apps/cli/src/core/acp/events.test.ts`, `run-turn.test.ts`; Worker CLI terminal tier `tests/e2e/harness/cli-login-terminal.test.ts`, `cli-service-terminal.test.ts`, `cli-doctor-terminal.test.ts`; Playwright `tests/e2e/web/specs/catalog.spec.ts` | -| `acp-session-router` | `apps/cli/__tests__/integration/wiring.test.ts`; Playwright `tests/e2e/web/specs/cold-resume.spec.ts` | -| `connection-providers` | `shared/connections/src/rtc/session.test.ts`; Playwright `tests/e2e/web/specs/worker-connects.spec.ts`, `thread-sync.spec.ts` | -| `conversation-persistence` | `shared/database/__tests__/integration/repositories.test.ts`; Playwright `tests/e2e/web/specs/thread-lifecycle.spec.ts`, `cold-resume.spec.ts` | +| `acp-provider-cli` | `apps/cli/src/core/acp/events.test.ts`, `run-turn.test.ts` | +| `acp-session-router` | `apps/cli/__tests__/integration/wiring.test.ts` | +| `connection-providers` | `shared/connections/src/rtc/session.test.ts` | +| `conversation-persistence` | `shared/database/__tests__/integration/repositories.test.ts` | ## Deferred platform tracks -- `@cyrus/desktop` — thin Bun unit tests for `lib/env` and `lib/auth`; browser E2E can reuse the web Playwright suite against built assets. +- `@cyrus/desktop` — thin Bun unit tests for `lib/env` and `lib/auth`. - `@cyrus/mobile` — Maestro or Detox when the app matures. - `@cyrus/styles` — out of scope for unit tests. - Visual regression — deferred. +- End-to-end (Playwright cross-peer flows, Worker CLI terminal tier) — removed; revisit if a future need justifies the process-compose/Playwright/PTY infrastructure cost. ## Phase 3 notes @@ -90,3 +61,7 @@ No E2E subset runs on pull requests. The suite needs Playwright browsers, proces - `@cyrus/database` integration tests use isolated in-memory Turso databases via `shared/database/__tests__/helpers/turso.ts`. - `@cyrus/server` Workers-pool tests exercise the real D1 binding (`env.DB`) under `@cloudflare/vitest-pool-workers`, with Drizzle migrations applied in setup. No Neon or Postgres driver is involved. + +## Phase 5 notes + +- Deploy smoke runs after every server deploy via `tooling/test/smoke/deploy.ts`. Optional `DEPLOY_SMOKE_TOKEN` and `DEPLOY_SMOKE_ROOM_ID` secrets enable a signaling WebSocket check in addition to `GET /health`. diff --git a/docs/guides/VERIFY_LOOP.md b/docs/guides/VERIFY_LOOP.md index e2d7db1..0c80613 100644 --- a/docs/guides/VERIFY_LOOP.md +++ b/docs/guides/VERIFY_LOOP.md @@ -11,44 +11,11 @@ Start narrow, then move outward until the changed behavior has been observed: 3. Run the full local stack for changes that cross the database, signaling server, controller, worker, agent runtime, or browser. 4. Use deployment and CI tools only when the failure is remote or cannot be reproduced locally. -Do not run the full stack for documentation-only changes. Do not claim an E2E pass unless the browser/controller, server, and worker were connected and the changed user flow was exercised. +Do not run the full stack for documentation-only changes. Do not claim an end-to-end pass unless the browser/controller, server, and worker were connected and the changed user flow was exercised. -## Preferred: managed E2E harness +## Full-stack loop -The harness under `tests/e2e/` is the repeatable default. It: - -- creates a run-scoped Wrangler `--persist-to` directory for local D1/Miniflare state; -- applies local D1 migrations for the `cyrus` database into that directory; -- starts the signaling server on `127.0.0.1:8787` (`wrangler dev` with the same `--persist-to`); -- starts the web controller on `127.0.0.1:5173` when required; -- creates a unique email/password account; -- completes the real CLI device authorization flow; -- starts a worker with an isolated `CYRUS_HOME`; and -- stops processes and removes temporary auth files, `CYRUS_HOME`, and the Wrangler persist directory afterward. - -Run the full suite from the repository root: - -```sh -bun test:e2e -``` - -For a faster tracer bullet, run a single Playwright cross-peer spec (from `tests/e2e/`): - -```sh -NODE_ENV=testing bunx playwright test --config web/playwright.config.ts web/specs/.spec.ts -``` - -Or a Vitest harness/terminal-tier test from the repository root: - -```sh -NODE_ENV=testing vitest run --project e2e harness/.test.ts -``` - -Session creation helpers live in `tests/e2e/harness/auth.ts`. Playwright scenarios drive device approval through the UI fixture in `tests/e2e/web/device-auth.ts` instead of inventing test-only auth bypasses. - -## Manual full-stack loop - -Use the manual loop when developing interactively or diagnosing a failing E2E scenario. Start each long-running process in its own terminal and preserve its logs. +There is no automated end-to-end suite (removed; see `docs/guides/TESTING_FRAMEWORK.md`'s "Deferred platform tracks"). Verify cross-process/browser changes by driving the real stack manually. Start each long-running process in its own terminal and preserve its logs. ### 1. Local D1 @@ -67,13 +34,13 @@ wrangler d1 execute cyrus --local --command "SELECT name FROM sqlite_master WHER wrangler d1 info cyrus ``` -Use unique test users and records so concurrent verification sessions do not collide. Managed E2E runs isolate D1 via a temporary `--persist-to` directory; bare `wrangler dev` / `wrangler d1 … --local` without `--persist-to` still share `.wrangler/state`. +Use unique test users and records so concurrent verification sessions do not collide. Bare `wrangler dev` / `wrangler d1 … --local` without `--persist-to` share `.wrangler/state`. ### 2. Signaling server The server is the Cloudflare Worker in `apps/server/`. Local Wrangler loads bindings from the repo-root `.dev.vars` symlink (→ `apps/server/.env`). Put the variables from `apps/server/.env.example` there. Shell exports alone do not configure the Worker; edit that file (or pass Wrangler `--env-file`). Persistence uses the `DB` D1 binding — there is no `DATABASE_URL`. -For local email/password auth, `NODE_ENV` must not be `production`. Use `development` for interactive manual work; use `testing` when matching the E2E harness. +For local email/password auth, `NODE_ENV` must not be `production`. Use `development` for interactive manual work. At minimum, local URLs in `apps/server/.env` must agree with the web controller: @@ -117,7 +84,7 @@ curl -fsSI http://localhost:5173 ### 4. Authentication and worker -Use a unique address such as `verify-@cyrus.test` and a test-only password. Email/password auth is enabled by the local/test server. The exact sign-up, sign-in, device-code claim, approval, and token exchange sequence lives in `tests/e2e/harness/auth.ts`. +Use a unique address such as `verify-@cyrus.test` and a test-only password. Email/password auth is enabled by the local/test server via Better Auth's sign-up/sign-in and OAuth Device Authorization Grant endpoints (`/api/auth/sign-up/email`, `/api/auth/sign-in/email`, `/api/auth/device`, `/api/auth/device/approve`). For manual CLI verification, isolate worker state: diff --git a/knip.json b/knip.json index 13837fe..a3293aa 100644 --- a/knip.json +++ b/knip.json @@ -60,19 +60,14 @@ "src/**": ["types"] } }, - "tests/e2e": { + "tooling/test": { "entry": [ - "harness/**/*.ts", - "manual/**/*.ts", - "web/playwright.config.ts", - "web/fixtures.ts", - "web/prepare-database.ts", - "web/specs/**/*.ts" + "setup/**/*.ts", + "mocks/**/*.ts", + "fixtures/**/*.ts", + "smoke/**/*.ts" ] }, - "tooling/test": { - "entry": ["setup/**/*.ts", "mocks/**/*.ts", "smoke/**/*.ts"] - }, "tooling/typescript": { "entry": ["tsconfig.base.json"] }, diff --git a/mise.toml b/mise.toml index 570254c..4fa749b 100644 --- a/mise.toml +++ b/mise.toml @@ -2,8 +2,6 @@ bun = "1.3" vercel = "54.16" wrangler = "4.104" -process-compose = "1.120.0" -"github:microsoft/shell-use" = "0.0.1-beta.5" "npm:@soorya-u/dotagents" = "0.1.3" "npm:@playwright/cli" = "0.1.17" diff --git a/package.json b/package.json index 94645e4..75613e1 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,7 @@ "packages": [ "apps/*", "shared/*", - "tooling/*", - "tests/e2e" + "tooling/*" ], "catalogs": { "auth": { @@ -59,7 +58,6 @@ "test:unit": "vitest run --project='@cyrus/*' && bun --filter @cyrus/cli test:unit", "test:unit:ui": "vitest --ui --watch --project='@cyrus/*'", "test:integration": "turbo test:integration", - "test:e2e": "turbo test:e2e", "dev:mobile": "turbo -F @cyrus/mobile dev", "dev:web": "turbo -F @cyrus/web dev", "dev:desktop": "turbo -F @cyrus/desktop dev", diff --git a/tests/e2e/harness/auth.ts b/tests/e2e/harness/auth.ts deleted file mode 100644 index 3c29831..0000000 --- a/tests/e2e/harness/auth.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { E2E_WEB_URL } from "./env"; - -export type E2eAuth = { - token: string; - userId: string; - sessionCookie: string; - sessionToken: string; - email: string; -}; - -type AuthSession = { - sessionCookie: string; - sessionToken: string; - userId: string; -}; - -const SESSION_COOKIE_PATTERN = /better-auth\.session_token=([^;]+)/; - -function authHeaders( - extra: Record = {} -): Record { - return { - origin: E2E_WEB_URL, - referer: `${E2E_WEB_URL}/`, - ...extra, - }; -} - -function parseSessionCookie(setCookie: string | null): { - sessionCookie: string; - sessionToken: string; -} { - if (!setCookie) throw new Error("Missing session cookie from auth response."); - - const match = setCookie.match(SESSION_COOKIE_PATTERN); - if (!match?.[1]) - throw new Error("Could not parse better-auth.session_token cookie."); - - return { - sessionCookie: `better-auth.session_token=${match[1]}`, - sessionToken: match[1], - }; -} - -/** Creates a unique email/password account via the auth API (testing-only). */ -export async function createE2eAuthSession( - serverUrl: string, - email: string, - password: string -): Promise { - const signUp = await fetch(`${serverUrl}/api/auth/sign-up/email`, { - method: "POST", - headers: authHeaders({ "content-type": "application/json" }), - body: JSON.stringify({ email, name: "E2E User", password }), - }); - if (!signUp.ok && signUp.status !== 422) - throw new Error(`sign-up failed: ${signUp.status} ${await signUp.text()}`); - - const signIn = await fetch(`${serverUrl}/api/auth/sign-in/email`, { - method: "POST", - headers: authHeaders({ "content-type": "application/json" }), - body: JSON.stringify({ email, password }), - }); - if (!signIn.ok) - throw new Error(`sign-in failed: ${signIn.status} ${await signIn.text()}`); - - const body = (await signIn.json()) as { user?: { id: string } }; - const { sessionCookie, sessionToken } = parseSessionCookie( - signIn.headers.get("set-cookie") - ); - const userId = body.user?.id; - if (!userId) { - throw new Error("sign-in response missing user id."); - } - - return { sessionCookie, sessionToken, userId }; -} - -/** - * Approves a device user code for an already-signed-in session (claim + approve). - * Used when the Worker CLI has already started the device-code flow itself. - */ -export async function approveDeviceUserCode( - serverUrl: string, - sessionCookie: string, - userCode: string -): Promise { - const formattedUserCode = userCode.replace(/-/g, ""); - - const claim = await fetch( - `${serverUrl}/api/auth/device?user_code=${encodeURIComponent(formattedUserCode)}`, - { - headers: authHeaders({ cookie: sessionCookie }), - } - ); - if (!claim.ok) { - throw new Error( - `device claim failed: ${claim.status} ${await claim.text()}` - ); - } - - const approve = await fetch(`${serverUrl}/api/auth/device/approve`, { - method: "POST", - headers: authHeaders({ - "content-type": "application/json", - cookie: sessionCookie, - }), - body: JSON.stringify({ userCode: formattedUserCode }), - }); - if (!approve.ok) { - throw new Error( - `device approve failed: ${approve.status} ${await approve.text()}` - ); - } -} diff --git a/tests/e2e/harness/cli-doctor-terminal.test.ts b/tests/e2e/harness/cli-doctor-terminal.test.ts deleted file mode 100644 index c8b194e..0000000 --- a/tests/e2e/harness/cli-doctor-terminal.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { afterEach, describe, expect, test } from "vitest"; -import { - buildCompiledCliBinaryOnce, - CLI_WORKER_BINARY, - CLI_WORKER_RUNTIME_DIRECTORY, -} from "./cli-worker"; -import { - buildCliEnv, - createTempCyrusHome, - isE2eEnabled, - requireE2e, -} from "./env"; -import { - buildTerminalCliEnv, - TERMINAL_COLS, - TERMINAL_ROWS, - withTerminalSession, -} from "./shell-use"; - -/** Bun.color("red", "ansi-256") index used by red() / print.error. */ -const RED_FG = 196; - -const AGENT_ID = "claude-acp"; -const UNHEALTHY_PATTERN = new RegExp(`${AGENT_ID}:\\s*unhealthy`); -const NO_DISTRIBUTION_PATTERN = /no supported distribution/i; - -const e2eDescribe = isE2eEnabled() ? describe : describe.skip; - -async function writeEnabledAgent(home: string): Promise { - await writeFile( - join(home, "agents.yml"), - [ - `${AGENT_ID}:`, - ` registryId: "${AGENT_ID}"`, - ' name: "Claude Agent"', - ' icon: "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg"', - "", - ].join("\n"), - { mode: 0o600 } - ); -} - -/** - * Fresh registry cache with an agent that has no installable distribution so - * health fails locally (no CDN fetch, no agent subprocess). - */ -async function writeUnhealthyRegistryCache(home: string): Promise { - const acpDir = join(home, "acp"); - await mkdir(acpDir, { recursive: true }); - await writeFile( - join(acpDir, "registry.json"), - `${JSON.stringify({ - version: "1.0.0", - agents: [ - { - id: AGENT_ID, - name: "Claude Agent", - distribution: { binary: {} }, - }, - ], - })}\n`, - { mode: 0o600 } - ); - await writeFile( - join(acpDir, "registry_cache.json"), - `${JSON.stringify({ - timestamp: Math.floor(Date.now() / 1000), - version: "1.0.0", - })}\n`, - { mode: 0o600 } - ); -} - -e2eDescribe("cyrusd doctor terminal tier", () => { - let cyrusHome: string | undefined; - - afterEach(async () => { - if (cyrusHome) { - await rm(cyrusHome, { recursive: true, force: true }).catch( - () => undefined - ); - cyrusHome = undefined; - } - }); - - test("checks an enabled agent and prints unhealthy in red", async () => { - requireE2e(); - await buildCompiledCliBinaryOnce(); - - cyrusHome = await createTempCyrusHome(); - await writeEnabledAgent(cyrusHome); - await writeUnhealthyRegistryCache(cyrusHome); - - const cliEnv = buildTerminalCliEnv(buildCliEnv(cyrusHome)); - - await withTerminalSession(async (su) => { - await su.run( - CLI_WORKER_BINARY, - ["agents", "doctor", "--name", AGENT_ID], - { - cols: TERMINAL_COLS, - rows: TERMINAL_ROWS, - cwd: CLI_WORKER_RUNTIME_DIRECTORY, - env: cliEnv, - } - ); - - const size = await su.getSize(); - expect(size).toEqual({ cols: TERMINAL_COLS, rows: TERMINAL_ROWS }); - - await su.waitText("unhealthy", { timeout: 60_000 }); - await su.expectText("unhealthy", { - fg: String(RED_FG), - strict: false, - }); - await su.waitExit({ timeout: 15_000 }); - - const state = await su.state(); - expect(state.text).toMatch(UNHEALTHY_PATTERN); - expect(state.text).toMatch(NO_DISTRIBUTION_PATTERN); - }); - }, 120_000); -}); diff --git a/tests/e2e/harness/cli-login-terminal.test.ts b/tests/e2e/harness/cli-login-terminal.test.ts deleted file mode 100644 index c5a5002..0000000 --- a/tests/e2e/harness/cli-login-terminal.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { rm } from "node:fs/promises"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, test } from "vitest"; -import { approveDeviceUserCode, createE2eAuthSession } from "./auth"; -import { parseCliLoginPrompt } from "./cli-login"; -import { - buildCompiledCliBinaryOnce, - CLI_WORKER_BINARY, - CLI_WORKER_RUNTIME_DIRECTORY, -} from "./cli-worker"; -import { - createTempWranglerPersistDir, - WRANGLER_PERSIST_TO_ENV, -} from "./database"; -import { - buildCliEnv, - buildServerEnv, - createTempCyrusHome, - E2E_SERVER_URL, - isE2eEnabled, - removeWranglerEnvFile, - requireE2e, - writeWranglerEnvFile, -} from "./env"; -import { - type ProcessComposeHandle, - startProcessCompose, - stopProcessCompose, -} from "./process-compose"; -import { - buildTerminalCliEnv, - TERMINAL_COLS, - TERMINAL_ROWS, - withTerminalSession, -} from "./shell-use"; - -/** Bun.color("cyan"/"blue", "ansi-256") indexes used by the CLI style helpers. */ -const CYAN_FG = 51; -const BLUE_FG = 21; -const LOGGED_IN_PATTERN = /Logged in/; - -const REPO_ROOT = join(fileURLToPath(new URL("../../..", import.meta.url))); -const PROCESS_COMPOSE_CONFIG = join( - REPO_ROOT, - "tests/e2e/process-compose.yaml" -); - -const e2eDescribe = isE2eEnabled() ? describe : describe.skip; - -e2eDescribe("cyrusd login terminal tier", () => { - let compose: ProcessComposeHandle | undefined; - let wranglerEnvFile: string | undefined; - let wranglerPersistDir: string | undefined; - let cyrusHome: string | undefined; - - afterEach(async () => { - if (compose) { - await stopProcessCompose(compose); - compose = undefined; - } - await removeWranglerEnvFile(wranglerEnvFile); - wranglerEnvFile = undefined; - if (cyrusHome) { - await rm(cyrusHome, { recursive: true, force: true }).catch( - () => undefined - ); - cyrusHome = undefined; - } - if (wranglerPersistDir) { - await rm(wranglerPersistDir, { recursive: true, force: true }).catch( - () => undefined - ); - wranglerPersistDir = undefined; - } - }); - - test("prints the device code and URL, then completes after approval", async () => { - requireE2e(); - await buildCompiledCliBinaryOnce(); - - const serverEnv = buildServerEnv(); - cyrusHome = await createTempCyrusHome(); - wranglerPersistDir = await createTempWranglerPersistDir(); - wranglerEnvFile = await writeWranglerEnvFile(serverEnv); - - compose = await startProcessCompose({ - configPath: PROCESS_COMPOSE_CONFIG, - cwd: REPO_ROOT, - processes: ["server"], - readyProcesses: ["server"], - env: { - ...process.env, - ...serverEnv, - WRANGLER_ENV_FILE: wranglerEnvFile, - [WRANGLER_PERSIST_TO_ENV]: wranglerPersistDir, - NODE_ENV: "testing", - }, - }); - - const email = `e2e-terminal-${crypto.randomUUID()}@cyrus.test`; - const password = "e2e-test-password-32chars-min"; - const session = await createE2eAuthSession(E2E_SERVER_URL, email, password); - - const cliEnv = buildTerminalCliEnv(buildCliEnv(cyrusHome)); - - await withTerminalSession(async (su) => { - await su.run(CLI_WORKER_BINARY, ["login"], { - cols: TERMINAL_COLS, - rows: TERMINAL_ROWS, - cwd: CLI_WORKER_RUNTIME_DIRECTORY, - env: cliEnv, - }); - - const size = await su.getSize(); - expect(size).toEqual({ cols: TERMINAL_COLS, rows: TERMINAL_ROWS }); - - await su.waitText("Waiting for approval", { timeout: 60_000 }); - const prompt = parseCliLoginPrompt(await su.text({ full: true })); - - // URL is blue; the code also appears in the query string, so match path only. - await su.expectText("/auth/device", { - fg: String(BLUE_FG), - strict: false, - }); - - // User code on the "enter the code" line is cyan (ansi-256 51) + bold. - const cells = await su.cells(0, 0, TERMINAL_COLS, TERMINAL_ROWS); - const cyanRun = cells - .filter((cell) => cell.fg === CYAN_FG) - .map((cell) => cell.char) - .join(""); - expect(cyanRun).toContain(prompt.userCode); - expect( - cells.some( - (cell) => - cell.bold && - cell.fg === CYAN_FG && - prompt.userCode.includes(cell.char) - ) - ).toBe(true); - - await approveDeviceUserCode( - E2E_SERVER_URL, - session.sessionCookie, - prompt.userCode - ); - - await su.waitText("Logged in", { timeout: 60_000 }); - await su.waitExit({ timeout: 30_000 }); - - const state = await su.state(); - expect(state.exited).toBe(0); - expect(state.text).toMatch(LOGGED_IN_PATTERN); - }); - }, 180_000); -}); diff --git a/tests/e2e/harness/cli-login.test.ts b/tests/e2e/harness/cli-login.test.ts deleted file mode 100644 index 880e512..0000000 --- a/tests/e2e/harness/cli-login.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, test } from "vitest"; -import { parseCliLoginPrompt, readAccessTokenFromHome } from "./cli-login"; - -const temps: string[] = []; - -afterEach(async () => { - await Promise.all( - temps.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) - ); -}); - -describe("parseCliLoginPrompt", () => { - test("extracts verification URL and user code from plain login output", () => { - const output = ` -To sign in, visit: - - http://localhost:5173/auth/device?user_code=ABCD-EFGH - -and enter the code: ABCD-EFGH - -Waiting for approval… -`; - - expect(parseCliLoginPrompt(output)).toEqual({ - verificationUrl: "http://localhost:5173/auth/device?user_code=ABCD-EFGH", - userCode: "ABCD-EFGH", - }); - }); - - test("accepts user codes without a hyphen", () => { - const output = ` -To sign in, visit: - - http://localhost:5173/auth/device?user_code=PRUT2NME - -and enter the code: PRUT2NME - -Waiting for approval… -`; - - expect(parseCliLoginPrompt(output)).toEqual({ - verificationUrl: "http://localhost:5173/auth/device?user_code=PRUT2NME", - userCode: "PRUT2NME", - }); - }); - - test("strips ANSI styling around the URL and code", () => { - const output = [ - "To sign in, visit:", - "", - " \x1b[4m\x1b[38;5;33mhttp://localhost:5173/auth/device?user_code=WXYZ-1234\x1b[39m\x1b[24m", - "", - "and enter the code: \x1b[1m\x1b[38;5;51mWXYZ-1234\x1b[39m\x1b[22m", - "", - ].join("\n"); - - expect(parseCliLoginPrompt(output)).toEqual({ - verificationUrl: "http://localhost:5173/auth/device?user_code=WXYZ-1234", - userCode: "WXYZ-1234", - }); - }); -}); - -describe("readAccessTokenFromHome", () => { - test("reads the token written by cyrusd login", async () => { - const home = await mkdtemp(join(tmpdir(), "cyrus-cli-login-")); - temps.push(home); - await writeFile( - join(home, "config.yml"), - ['token: "cli-access-token-value"', 'name: "E2E Worker"', ""].join("\n"), - { mode: 0o600 } - ); - - await expect(readAccessTokenFromHome(home)).resolves.toBe( - "cli-access-token-value" - ); - }); - - test("reads Bun YAML flow-style config written by the compiled binary", async () => { - const home = await mkdtemp(join(tmpdir(), "cyrus-cli-login-")); - temps.push(home); - await writeFile( - join(home, "config.yml"), - "{token: bareAccessTokenValue,name: damaged-answer}\n", - { mode: 0o600 } - ); - - await expect(readAccessTokenFromHome(home)).resolves.toBe( - "bareAccessTokenValue" - ); - }); -}); diff --git a/tests/e2e/harness/cli-login.ts b/tests/e2e/harness/cli-login.ts deleted file mode 100644 index 548b89f..0000000 --- a/tests/e2e/harness/cli-login.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { parse as parseYaml } from "yaml"; -import { - buildCompiledCliBinaryOnce, - CLI_WORKER_BINARY, - CLI_WORKER_RUNTIME_DIRECTORY, -} from "./cli-worker"; -import { buildCliEnv } from "./env"; -import { waitForExit } from "./process"; - -const ESC = String.fromCharCode(27); -const ANSI_PATTERN = new RegExp(`${ESC}\\[[0-9;]*m`, "g"); -const VERIFICATION_URL_PATTERN = /https?:\/\/\S*\/auth\/device(?:\?[^\s]*)?/i; -const USER_CODE_PATTERN = /enter the code:\s*([A-Z0-9]{4}-?[A-Z0-9]{4})/i; -const TRAILING_URL_PUNCTUATION_PATTERN = /[)\].,]+$/; - -export type CliLoginPrompt = { - verificationUrl: string; - userCode: string; -}; - -export type CliLoginSession = { - prompt: CliLoginPrompt; - waitUntilDone: () => Promise; - kill: () => void; -}; - -function stripAnsi(value: string): string { - return value.replace(ANSI_PATTERN, ""); -} - -export function parseCliLoginPrompt(output: string): CliLoginPrompt { - const plain = stripAnsi(output); - const urlMatch = plain.match(VERIFICATION_URL_PATTERN); - const codeMatch = plain.match(USER_CODE_PATTERN); - - if (!(urlMatch?.[0] && codeMatch?.[1])) { - throw new Error( - `Could not parse CLI login prompt. Recent output: ${plain.slice(-500)}` - ); - } - - return { - verificationUrl: urlMatch[0].replace(TRAILING_URL_PUNCTUATION_PATTERN, ""), - userCode: codeMatch[1].toUpperCase(), - }; -} - -export async function readAccessTokenFromHome(home: string): Promise { - const configPath = join(home, "config.yml"); - let raw: string; - try { - raw = await readFile(configPath, "utf8"); - } catch (error) { - throw new Error( - `config.yml missing in ${home}: ${error instanceof Error ? error.message : String(error)}` - ); - } - - let parsed: unknown; - try { - parsed = parseYaml(raw); - } catch (error) { - throw new Error( - `config.yml in ${home} is not valid YAML: ${error instanceof Error ? error.message : String(error)}\nContents:\n${raw}` - ); - } - - const token = - parsed && - typeof parsed === "object" && - typeof (parsed as { token?: unknown }).token === "string" - ? (parsed as { token: string }).token - : undefined; - if (!token) { - throw new Error( - `config.yml in ${home} does not contain a token. Contents:\n${raw}` - ); - } - return token; -} - -function collectLoginPrompt( - proc: ChildProcessWithoutNullStreams -): Promise { - return new Promise((resolve, reject) => { - let output = ""; - const timeout = setTimeout(() => { - cleanup(); - reject( - new Error( - `Timed out waiting for CLI login prompt. Recent output: ${stripAnsi(output).slice(-500)}` - ) - ); - }, 60_000); - - const handleOutput = (chunk: Buffer) => { - output = `${output}${chunk.toString()}`.slice(-8000); - try { - const prompt = parseCliLoginPrompt(output); - cleanup(); - resolve(prompt); - } catch { - // keep buffering until the prompt is complete - } - }; - const handleError = (error: Error) => { - cleanup(); - reject(error); - }; - const handleExit = (code: number | null) => { - cleanup(); - reject( - new Error( - `CLI login exited with code ${code} before printing a prompt. Recent output: ${stripAnsi(output).slice(-500)}` - ) - ); - }; - const cleanup = () => { - clearTimeout(timeout); - proc.stdout.off("data", handleOutput); - proc.stderr.off("data", handleOutput); - proc.off("error", handleError); - proc.off("exit", handleExit); - }; - - proc.stdout.on("data", handleOutput); - proc.stderr.on("data", handleOutput); - proc.once("error", handleError); - proc.once("exit", handleExit); - }); -} - -/** - * Spawns the compiled `cyrusd login` binary and resolves once it prints the - * device-code verification URL. Callers approve in a browser, then await - * `waitUntilDone` and read the token from `home`. - */ -export async function startCliLogin(home: string): Promise { - await buildCompiledCliBinaryOnce(); - - const proc = spawn(CLI_WORKER_BINARY, ["login"], { - cwd: CLI_WORKER_RUNTIME_DIRECTORY, - env: buildCliEnv(home), - stdio: "pipe", - }); - - const prompt = await collectLoginPrompt(proc); - const done = waitForExit(proc).then((code) => { - if (code !== 0) { - throw new Error(`CLI login exited with code ${code}.`); - } - }); - - return { - prompt, - waitUntilDone: () => done, - kill: () => { - if (proc.exitCode === null) { - proc.kill("SIGTERM"); - } - }, - }; -} diff --git a/tests/e2e/harness/cli-service-terminal.test.ts b/tests/e2e/harness/cli-service-terminal.test.ts deleted file mode 100644 index f7afa34..0000000 --- a/tests/e2e/harness/cli-service-terminal.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { spawn } from "node:child_process"; -import { rm } from "node:fs/promises"; -import { join } from "node:path"; -import { setTimeout as sleep } from "node:timers/promises"; -import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, test } from "vitest"; -import { approveDeviceUserCode, createE2eAuthSession } from "./auth"; -import { readAccessTokenFromHome, startCliLogin } from "./cli-login"; -import { - buildCompiledCliBinaryOnce, - CLI_WORKER_BINARY, - CLI_WORKER_RUNTIME_DIRECTORY, - writeCliWorkerState, -} from "./cli-worker"; -import { - createTempWranglerPersistDir, - WRANGLER_PERSIST_TO_ENV, -} from "./database"; -import { - buildCliEnv, - buildServerEnv, - createTempCyrusHome, - E2E_SERVER_URL, - isE2eEnabled, - removeWranglerEnvFile, - requireE2e, - writeWranglerEnvFile, -} from "./env"; -import { waitForExit } from "./process"; -import { - type ProcessComposeHandle, - startProcessCompose, - stopProcessCompose, -} from "./process-compose"; -import { - buildTerminalCliEnv, - TERMINAL_COLS, - TERMINAL_ROWS, - withTerminalSession, -} from "./shell-use"; - -/** Bun.color("green", "ansi-256") index used by print.success. */ -const GREEN_FG = 28; - -const NOT_RUNNING_PATTERN = /Not running/; -const E2E_WORKER_PATTERN = /E2E Worker/; -const RUNNING_PID_PATTERN = /Running \(pid/; - -const REPO_ROOT = join(fileURLToPath(new URL("../../..", import.meta.url))); -const PROCESS_COMPOSE_CONFIG = join( - REPO_ROOT, - "tests/e2e/process-compose.yaml" -); - -const e2eDescribe = isE2eEnabled() ? describe : describe.skip; - -/** Parent `start --bg` must not inherit `CYRUS_DAEMON=1` (that skips the fork path). */ -function buildServiceCliEnv(home: string): Record { - const { CYRUS_DAEMON: _, ...env } = buildTerminalCliEnv(buildCliEnv(home)); - return env; -} - -async function stopWorkerBestEffort(home: string): Promise { - const proc = spawn(CLI_WORKER_BINARY, ["stop"], { - cwd: CLI_WORKER_RUNTIME_DIRECTORY, - env: { - ...process.env, - CYRUS_HOME: home, - CLI_PUBLIC_SERVER_URL: E2E_SERVER_URL, - }, - stdio: "ignore", - }); - await waitForExit(proc); -} - -async function workerStatusExitCode(home: string): Promise { - const proc = spawn(CLI_WORKER_BINARY, ["status"], { - cwd: CLI_WORKER_RUNTIME_DIRECTORY, - env: { - ...process.env, - CYRUS_HOME: home, - CLI_PUBLIC_SERVER_URL: E2E_SERVER_URL, - }, - stdio: ["ignore", "ignore", "ignore"], - }); - return await waitForExit(proc); -} - -/** Polls `cyrusd status` until exit 0 (ready + fresh heartbeat + live pid). */ -async function waitForHealthy( - home: string, - { - timeoutMs = 120_000, - intervalMs = 500, - }: { timeoutMs?: number; intervalMs?: number } = {} -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if ((await workerStatusExitCode(home)) === 0) { - return; - } - await sleep(intervalMs); - } - throw new Error(`Timed out waiting for healthy worker in ${home}.`); -} - -async function seedWorkerHome(home: string): Promise { - const email = `e2e-service-${crypto.randomUUID()}@cyrus.test`; - const password = "e2e-test-password-32chars-min"; - const session = await createE2eAuthSession(E2E_SERVER_URL, email, password); - const login = await startCliLogin(home); - try { - await approveDeviceUserCode( - E2E_SERVER_URL, - session.sessionCookie, - login.prompt.userCode - ); - await login.waitUntilDone(); - } catch (error) { - login.kill(); - throw error; - } - await writeCliWorkerState(home, await readAccessTokenFromHome(home)); -} - -e2eDescribe("cyrusd service terminal tier", () => { - let compose: ProcessComposeHandle | undefined; - let wranglerEnvFile: string | undefined; - let wranglerPersistDir: string | undefined; - let cyrusHome: string | undefined; - - afterEach(async () => { - if (cyrusHome) { - await stopWorkerBestEffort(cyrusHome); - } - if (compose) { - await stopProcessCompose(compose); - compose = undefined; - } - await removeWranglerEnvFile(wranglerEnvFile); - wranglerEnvFile = undefined; - if (cyrusHome) { - await rm(cyrusHome, { recursive: true, force: true }).catch( - () => undefined - ); - cyrusHome = undefined; - } - if (wranglerPersistDir) { - await rm(wranglerPersistDir, { recursive: true, force: true }).catch( - () => undefined - ); - wranglerPersistDir = undefined; - } - }); - - test("status reports Not running when no worker is present", async () => { - requireE2e(); - await buildCompiledCliBinaryOnce(); - - cyrusHome = await createTempCyrusHome(); - const cliEnv = buildServiceCliEnv(cyrusHome); - - await withTerminalSession(async (su) => { - await su.run(CLI_WORKER_BINARY, ["status"], { - cols: TERMINAL_COLS, - rows: TERMINAL_ROWS, - cwd: CLI_WORKER_RUNTIME_DIRECTORY, - env: cliEnv, - }); - - const size = await su.getSize(); - expect(size).toEqual({ cols: TERMINAL_COLS, rows: TERMINAL_ROWS }); - - await su.waitText("Not running", { timeout: 30_000 }); - await su.waitExit({ timeout: 15_000 }); - - // shell-use does not reliably surface non-zero exit codes from the - // compiled Bun binary; assert on the rendered failure line instead. - expect((await su.state()).text).toMatch(NOT_RUNNING_PATTERN); - }); - }, 120_000); - - test("start --bg, status, and stop render lifecycle output", async () => { - requireE2e(); - await buildCompiledCliBinaryOnce(); - - const serverEnv = buildServerEnv(); - cyrusHome = await createTempCyrusHome(); - wranglerPersistDir = await createTempWranglerPersistDir(); - wranglerEnvFile = await writeWranglerEnvFile(serverEnv); - - compose = await startProcessCompose({ - configPath: PROCESS_COMPOSE_CONFIG, - cwd: REPO_ROOT, - processes: ["server"], - readyProcesses: ["server"], - env: { - ...process.env, - ...serverEnv, - WRANGLER_ENV_FILE: wranglerEnvFile, - [WRANGLER_PERSIST_TO_ENV]: wranglerPersistDir, - NODE_ENV: "testing", - }, - }); - - await seedWorkerHome(cyrusHome); - const cliEnv = buildServiceCliEnv(cyrusHome); - - await withTerminalSession(async (su) => { - await su.run(CLI_WORKER_BINARY, ["start", "--bg"], { - cols: TERMINAL_COLS, - rows: TERMINAL_ROWS, - cwd: CLI_WORKER_RUNTIME_DIRECTORY, - env: cliEnv, - }); - - await su.waitText("started in background", { timeout: 60_000 }); - await su.expectText("started in background", { - fg: String(GREEN_FG), - strict: false, - }); - await su.waitExit({ timeout: 30_000 }); - expect((await su.state()).exited).toBe(0); - }); - - await waitForHealthy(cyrusHome, { timeoutMs: 120_000 }); - - await withTerminalSession(async (su) => { - await su.run(CLI_WORKER_BINARY, ["status"], { - cols: TERMINAL_COLS, - rows: TERMINAL_ROWS, - cwd: CLI_WORKER_RUNTIME_DIRECTORY, - env: cliEnv, - }); - - await su.waitText("Running (pid", { timeout: 30_000 }); - await su.expectText("Running (pid", { - fg: String(GREEN_FG), - strict: false, - }); - await su.waitExit({ timeout: 15_000 }); - expect((await su.state()).exited).toBe(0); - expect((await su.state()).text).toMatch(E2E_WORKER_PATTERN); - expect((await su.state()).text).toMatch(RUNNING_PID_PATTERN); - }); - - await withTerminalSession(async (su) => { - await su.run(CLI_WORKER_BINARY, ["stop"], { - cols: TERMINAL_COLS, - rows: TERMINAL_ROWS, - cwd: CLI_WORKER_RUNTIME_DIRECTORY, - env: cliEnv, - }); - - await su.waitText("stopped (pid", { timeout: 30_000 }); - await su.expectText("stopped (pid", { - fg: String(GREEN_FG), - strict: false, - }); - await su.waitExit({ timeout: 15_000 }); - expect((await su.state()).exited).toBe(0); - }); - - await withTerminalSession(async (su) => { - await su.run(CLI_WORKER_BINARY, ["status"], { - cols: TERMINAL_COLS, - rows: TERMINAL_ROWS, - cwd: CLI_WORKER_RUNTIME_DIRECTORY, - env: cliEnv, - }); - - await su.waitText("Not running", { timeout: 30_000 }); - await su.waitExit({ timeout: 15_000 }); - expect((await su.state()).text).toMatch(NOT_RUNNING_PATTERN); - }); - }, 300_000); -}); diff --git a/tests/e2e/harness/cli-worker.ts b/tests/e2e/harness/cli-worker.ts deleted file mode 100644 index 9ca3c7d..0000000 --- a/tests/e2e/harness/cli-worker.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { spawn } from "node:child_process"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { waitForExit } from "./process"; - -export const E2E_CLI_WORKER_ID = "e2e-worker-1"; -export const E2E_CLI_WORKER_NAME = "E2E Worker"; -export const CLI_WORKER_DIRECTORY = fileURLToPath( - new URL("../../../apps/cli", import.meta.url) -); -/** Directory that contains the compiled `cyrusd` binary. */ -export const CLI_WORKER_RUNTIME_DIRECTORY = join(CLI_WORKER_DIRECTORY, "dist"); -export const CLI_WORKER_BINARY = join(CLI_WORKER_RUNTIME_DIRECTORY, "cyrusd"); -export const CLI_WORKER_COMMAND = [CLI_WORKER_BINARY, "start"] as const; - -const BUILD_SCRIPT = join(CLI_WORKER_DIRECTORY, "scripts/build.ts"); - -let buildPromise: Promise | undefined; - -async function runCompiledCliBuild(): Promise { - const proc = spawn("bun", [BUILD_SCRIPT], { - cwd: CLI_WORKER_DIRECTORY, - env: process.env, - stdio: "inherit", - }); - const exitCode = await waitForExit(proc); - if (exitCode !== 0) - throw new Error( - `CLI compiled-binary build failed with exit code ${exitCode ?? "null"}` - ); -} - -/** - * Rebuilds the CLI binary once per process (always runs the compile script; - * never skips because a prior binary exists on disk). - */ -export function buildCompiledCliBinaryOnce(): Promise { - if (!buildPromise) - buildPromise = runCompiledCliBuild().catch((error: unknown) => { - buildPromise = undefined; - throw error; - }); - - return buildPromise; -} - -export async function writeCliWorkerState( - home: string, - token: string -): Promise { - await Promise.all([ - writeFile( - join(home, "config.yml"), - [ - `token: ${JSON.stringify(token)}`, - `id: ${JSON.stringify(E2E_CLI_WORKER_ID)}`, - `name: ${JSON.stringify(E2E_CLI_WORKER_NAME)}`, - "", - ].join("\n"), - { mode: 0o600 } - ), - writeFile( - join(home, "agents.yml"), - [ - "claude-acp:", - ' registryId: "claude-acp"', - ' name: "Claude Agent"', - ' icon: "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg"', - "", - ].join("\n"), - { mode: 0o600 } - ), - ]); -} diff --git a/tests/e2e/harness/database.test.ts b/tests/e2e/harness/database.test.ts deleted file mode 100644 index b0ec7ec..0000000 --- a/tests/e2e/harness/database.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - buildD1MigrateLocalArgs, - requireWranglerPersistTo, - WRANGLER_PERSIST_TO_ENV, -} from "./database"; -import { WRANGLER_PACKAGE } from "./dev-servers"; - -const MISSING_PERSIST_TO = /WRANGLER_PERSIST_TO/; - -describe("E2E D1 isolation", () => { - test("migrate args pin --local and --persist-to for a run-scoped directory", () => { - const persistTo = "/tmp/cyrus-e2e-wrangler-abc"; - expect(buildD1MigrateLocalArgs(persistTo)).toEqual([ - WRANGLER_PACKAGE, - "d1", - "migrations", - "apply", - "cyrus", - "--local", - "--persist-to", - persistTo, - "--config", - "wrangler.json", - ]); - }); - - test("requireWranglerPersistTo reads the run-scoped env var", () => { - const previous = process.env[WRANGLER_PERSIST_TO_ENV]; - process.env[WRANGLER_PERSIST_TO_ENV] = "/tmp/cyrus-e2e-wrangler-xyz"; - try { - expect(requireWranglerPersistTo()).toBe("/tmp/cyrus-e2e-wrangler-xyz"); - } finally { - if (previous === undefined) { - delete process.env[WRANGLER_PERSIST_TO_ENV]; - } else { - process.env[WRANGLER_PERSIST_TO_ENV] = previous; - } - } - }); - - test("requireWranglerPersistTo rejects a missing persist directory", () => { - const previous = process.env[WRANGLER_PERSIST_TO_ENV]; - delete process.env[WRANGLER_PERSIST_TO_ENV]; - try { - expect(() => requireWranglerPersistTo()).toThrow(MISSING_PERSIST_TO); - } finally { - if (previous === undefined) { - delete process.env[WRANGLER_PERSIST_TO_ENV]; - } else { - process.env[WRANGLER_PERSIST_TO_ENV] = previous; - } - } - }); -}); diff --git a/tests/e2e/harness/database.ts b/tests/e2e/harness/database.ts deleted file mode 100644 index bb16143..0000000 --- a/tests/e2e/harness/database.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { spawn } from "node:child_process"; -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { WRANGLER_PACKAGE } from "./dev-servers"; -import { waitForExit } from "./process"; - -const REPO_ROOT = join(fileURLToPath(new URL("../../..", import.meta.url))); - -/** Env var shared by prepare-database and `wrangler dev` for one E2E stack run. */ -export const WRANGLER_PERSIST_TO_ENV = "WRANGLER_PERSIST_TO"; - -/** Temp Miniflare/D1 state directory for a single E2E stack run. */ -export function createTempWranglerPersistDir(): Promise { - return mkdtemp(join(tmpdir(), "cyrus-e2e-wrangler-")); -} - -export function requireWranglerPersistTo( - env: NodeJS.ProcessEnv = process.env -): string { - const persistTo = env[WRANGLER_PERSIST_TO_ENV]?.trim(); - if (!persistTo) { - throw new Error( - `${WRANGLER_PERSIST_TO_ENV} is required for E2E local D1 isolation.` - ); - } - return persistTo; -} - -/** Args for applying D1 migrations into a run-scoped local persist directory. */ -export function buildD1MigrateLocalArgs(persistTo: string): string[] { - return [ - WRANGLER_PACKAGE, - "d1", - "migrations", - "apply", - "cyrus", - "--local", - "--persist-to", - persistTo, - "--config", - "wrangler.json", - ]; -} - -/** Applies pending local D1 migrations so wrangler-dev auth/app tables exist. */ -export async function ensureDatabaseSchema( - persistTo = requireWranglerPersistTo() -): Promise { - const proc = spawn("bunx", buildD1MigrateLocalArgs(persistTo), { - cwd: REPO_ROOT, - env: process.env, - stdio: "inherit", - }); - const exitCode = await waitForExit(proc); - if (exitCode !== 0) - throw new Error( - `D1 local migrations failed for E2E database setup (exit ${exitCode ?? "null"}).` - ); -} diff --git a/tests/e2e/harness/dev-servers.ts b/tests/e2e/harness/dev-servers.ts deleted file mode 100644 index b902f10..0000000 --- a/tests/e2e/harness/dev-servers.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const WRANGLER_VERSION = "4.104.0"; -export const WRANGLER_PACKAGE = `wrangler@${WRANGLER_VERSION}`; diff --git a/tests/e2e/harness/env.ts b/tests/e2e/harness/env.ts deleted file mode 100644 index 4ea02f9..0000000 --- a/tests/e2e/harness/env.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdtemp, unlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -export const E2E_SERVER_URL = "http://localhost:8787"; -export const E2E_WEB_URL = "http://localhost:5173"; -export const E2E_AUTH_FILE = "e2e-auth.json"; - -export function isE2eEnabled(): boolean { - return process.env.NODE_ENV === "testing"; -} - -export function requireE2e(): void { - if (!isE2eEnabled()) { - throw new Error("Set NODE_ENV=testing to run end-to-end tests."); - } -} - -export function createTempCyrusHome(): Promise { - return mkdtemp(join(tmpdir(), "cyrus-e2e-home-")); -} - -export function buildServerEnv(): Record { - return { - BETTER_AUTH_SECRET: "e2e-test-secret-minimum-32-characters", - PRODUCTION_URL: E2E_WEB_URL, - WEB_APP_URL: E2E_WEB_URL, - OAUTH_GITHUB_CLIENT_ID: "e2e-github-client-id", - OAUTH_GITHUB_CLIENT_SECRET: "e2e-github-client-secret", - OAUTH_PROXY_SECRET: "e2e-oauth-proxy-secret", - ALLOWED_ORIGINS: `${E2E_WEB_URL},http://127.0.0.1:5173`, - NODE_ENV: "testing", - LOG_LEVEL: "warn", - }; -} - -export function buildCliEnv(home: string): Record { - return { - ...process.env, - CYRUS_HOME: home, - CLI_PUBLIC_SERVER_URL: E2E_SERVER_URL, - CYRUS_DAEMON: "1", - }; -} - -export async function writeWranglerEnvFile( - env: Record -): Promise { - const path = join(tmpdir(), `cyrus-e2e-wrangler-${randomUUID()}.dev.vars`); - const contents = Object.entries(env) - .filter(([, value]) => value !== undefined && value !== "") - .map(([key, value]) => `${key}=${JSON.stringify(value)}`) - .join("\n"); - await writeFile(path, `${contents}\n`, { mode: 0o600 }); - return path; -} - -export async function removeWranglerEnvFile( - path: string | undefined -): Promise { - if (!path) { - return; - } - await unlink(path).catch(() => undefined); -} diff --git a/tests/e2e/harness/process-compose.test.ts b/tests/e2e/harness/process-compose.test.ts deleted file mode 100644 index 5387d71..0000000 --- a/tests/e2e/harness/process-compose.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, test } from "vitest"; -import { - type ProcessComposeHandle, - restartManagedProcess, - startProcessCompose, - stopProcessCompose, - waitForProcessReady, -} from "./process-compose"; - -const FIXTURE_PORTS = { - api: 19_050, - server: 19_051, -}; - -async function writeFixture(dir: string): Promise { - const healthFile = join(dir, "worker-health.json"); - const configPath = join(dir, "process-compose.yaml"); - await writeFile( - configPath, - `version: "0.5" -processes: - server: - command: "bun -e 'Bun.serve({port:${FIXTURE_PORTS.server},fetch:()=>new Response(\\"ok\\")}); await Bun.sleep(999999)'" - readiness_probe: - http_get: - host: "127.0.0.1" - port: ${FIXTURE_PORTS.server} - path: "/" - period_seconds: 1 - failure_threshold: 60 - worker: - command: "bun -e 'const f=\\"${healthFile}\\"; const write=()=>Bun.write(f, JSON.stringify({ready:true,t:Date.now()})); write(); setInterval(write, 1000); await Bun.sleep(999999)'" - depends_on: - server: - condition: process_healthy - readiness_probe: - exec: - command: "bun -e 'const f=Bun.file(\\"${healthFile}\\"); if (!(await f.exists())) process.exit(1)'" - period_seconds: 1 - failure_threshold: 60 -`, - "utf8" - ); - return configPath; -} - -describe("process-compose lifecycle", () => { - let handle: ProcessComposeHandle | undefined; - let dir: string | undefined; - - afterEach(async () => { - if (handle) { - await stopProcessCompose(handle); - handle = undefined; - } - if (dir) { - await rm(dir, { recursive: true, force: true }); - dir = undefined; - } - }); - - test("starts dependents after readiness and restarts worker alone", async () => { - dir = await mkdtemp(join(tmpdir(), "cyrus-pc-")); - await mkdir(dir, { recursive: true }); - const configPath = await writeFixture(dir); - - handle = await startProcessCompose({ - configPath, - apiPort: FIXTURE_PORTS.api, - readyProcesses: ["server", "worker"], - }); - - const serverBefore = await waitForProcessReady(handle, "server"); - const workerBefore = await waitForProcessReady(handle, "worker"); - expect(serverBefore.is_ready).toBe("Ready"); - expect(workerBefore.is_ready).toBe("Ready"); - - await restartManagedProcess(handle, "worker"); - const workerAfter = await waitForProcessReady(handle, "worker", { - previousPid: workerBefore.pid, - }); - const serverAfter = await waitForProcessReady(handle, "server"); - - expect(workerAfter.pid).not.toBe(workerBefore.pid); - expect(serverAfter.pid).toBe(serverBefore.pid); - expect(workerAfter.is_ready).toBe("Ready"); - expect(serverAfter.is_ready).toBe("Ready"); - }, 60_000); -}); diff --git a/tests/e2e/harness/process-compose.ts b/tests/e2e/harness/process-compose.ts deleted file mode 100644 index 2747698..0000000 --- a/tests/e2e/harness/process-compose.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { type ChildProcess, spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { createServer } from "node:net"; -import { delimiter, join } from "node:path"; -import { setTimeout as sleep } from "node:timers/promises"; -import { fileURLToPath } from "node:url"; -import { waitForExit } from "./process"; - -const REPO_ROOT = join(fileURLToPath(new URL("../../..", import.meta.url))); -const PROCESS_COMPOSE_NAME = "process-compose"; - -export type ProcessComposeState = { - name: string; - status: string; - is_ready: string; - has_ready_probe: boolean; - pid: number; - is_running: boolean; - restarts: number; -}; - -export type ProcessComposeHandle = { - apiPort: number; - configPath: string; - proc: ChildProcess; - cwd: string; -}; - -export type StartProcessComposeOptions = { - configPath: string; - apiPort?: number; - cwd?: string; - env?: Record; - /** Process names passed to `process-compose up` (deps are pulled in). */ - processes?: string[]; - readyProcesses?: string[]; - readyTimeoutMs?: number; -}; - -function findOnPath(binary: string): string | undefined { - const pathEnv = process.env.PATH ?? ""; - for (const dir of pathEnv.split(delimiter)) { - if (!dir) continue; - const candidate = join(dir, binary); - if (existsSync(candidate)) { - return candidate; - } - } -} - -function processComposeBin(): string { - if (process.env.PROCESS_COMPOSE_BIN) { - return process.env.PROCESS_COMPOSE_BIN; - } - - const onPath = findOnPath(PROCESS_COMPOSE_NAME); - if (onPath) { - return onPath; - } - - const home = process.env.HOME; - if (home) { - const miseCandidate = join( - home, - ".local/share/mise/installs/process-compose/1/process-compose" - ); - if (existsSync(miseCandidate)) { - return miseCandidate; - } - } - - throw new Error( - "process-compose not found. Install via `mise install` (see mise.toml) or set PROCESS_COMPOSE_BIN." - ); -} - -async function reserveApiPort(): Promise { - return await new Promise((resolve, reject) => { - const server = createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - reject(new Error("Failed to reserve a process-compose API port.")); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - server.on("error", reject); - }); -} - -async function runProcessCompose( - args: string[], - options: { - apiPort: number; - cwd?: string; - env?: Record; - stdio?: "pipe" | "inherit" | "ignore"; - } -): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { - const proc = spawn(processComposeBin(), args, { - cwd: options.cwd ?? REPO_ROOT, - env: { - ...process.env, - ...options.env, - PC_PORT_NUM: String(options.apiPort), - // Quiet the client looking for a missing XDG config home. - PC_DISABLE_TUI: "1", - }, - stdio: - options.stdio === "inherit" - ? "inherit" - : ["ignore", options.stdio ?? "pipe", options.stdio ?? "pipe"], - }); - - let stdout = ""; - let stderr = ""; - if (proc.stdout) { - proc.stdout.setEncoding("utf8"); - proc.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - } - if (proc.stderr) { - proc.stderr.setEncoding("utf8"); - proc.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - } - - const exitCode = await waitForExit(proc); - return { stdout, stderr, exitCode }; -} - -export async function getProcessState( - handle: ProcessComposeHandle, - name: string -): Promise { - const { stdout, stderr, exitCode } = await runProcessCompose( - ["process", "get", name, "-o", "json", "-p", String(handle.apiPort)], - { apiPort: handle.apiPort, cwd: handle.cwd } - ); - if (exitCode !== 0) { - throw new Error( - `process-compose get ${name} failed (${exitCode}): ${stderr || stdout}` - ); - } - - const parsed = JSON.parse(stdout) as - | ProcessComposeState - | ProcessComposeState[]; - const state = Array.isArray(parsed) ? parsed[0] : parsed; - if (!state) { - throw new Error(`process-compose get ${name} returned no state.`); - } - return state; -} - -export async function waitForProcessReady( - handle: ProcessComposeHandle, - name: string, - { - timeoutMs = 120_000, - previousPid, - }: { timeoutMs?: number; previousPid?: number } = {} -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - - while (Date.now() < deadline) { - try { - const state = await getProcessState(handle, name); - const ready = state.is_ready === "Ready" && state.is_running; - const pidChanged = previousPid === undefined || state.pid !== previousPid; - if (ready && pidChanged) { - return state; - } - } catch (error) { - lastError = error; - } - await sleep(250); - } - - throw new Error( - `Timed out waiting for process-compose process "${name}" to become ready.${ - lastError ? ` Last error: ${String(lastError)}` : "" - }` - ); -} - -export async function startProcessCompose( - options: StartProcessComposeOptions -): Promise { - const apiPort = options.apiPort ?? (await reserveApiPort()); - const cwd = options.cwd ?? REPO_ROOT; - const readyProcesses = options.readyProcesses ?? []; - - const proc = spawn( - processComposeBin(), - [ - "up", - "-f", - options.configPath, - "-t=false", - "--ordered-shutdown", - "-p", - String(apiPort), - ...(options.processes ?? []), - ], - { - cwd, - env: { - ...process.env, - ...options.env, - PC_PORT_NUM: String(apiPort), - }, - stdio: ["ignore", "pipe", "pipe"], - detached: true, - } - ); - proc.unref(); - - const handle: ProcessComposeHandle = { - apiPort, - configPath: options.configPath, - proc, - cwd, - }; - - try { - for (const name of readyProcesses) { - await waitForProcessReady(handle, name, { - timeoutMs: options.readyTimeoutMs, - }); - } - return handle; - } catch (error) { - await stopProcessCompose(handle); - throw error; - } -} - -export async function startManagedProcess( - handle: ProcessComposeHandle, - name: string, - { - waitUntil = "ready", - timeoutMs = 120_000, - }: { - waitUntil?: "ready" | "completed"; - timeoutMs?: number; - } = {} -): Promise { - const { stderr, exitCode } = await runProcessCompose( - ["process", "start", name, "-p", String(handle.apiPort)], - { apiPort: handle.apiPort, cwd: handle.cwd } - ); - if (exitCode !== 0) { - throw new Error( - `process-compose start ${name} failed (${exitCode}): ${stderr}` - ); - } - if (waitUntil === "completed") { - return await waitForProcessCompleted(handle, name, { timeoutMs }); - } - return await waitForProcessReady(handle, name, { timeoutMs }); -} - -export async function waitForProcessCompleted( - handle: ProcessComposeHandle, - name: string, - { timeoutMs = 120_000 }: { timeoutMs?: number } = {} -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - let lastState: ProcessComposeState | undefined; - - while (Date.now() < deadline) { - try { - const state = await getProcessState(handle, name); - lastState = state; - const status = state.status.toLowerCase(); - const completed = - !state.is_running && - (status.includes("completed") || - status.includes("finished") || - status.includes("done")); - if (completed) { - return state; - } - const failed = - !state.is_running && - (status.includes("error") || - status.includes("failed") || - status.includes("terminated")); - if (failed) { - throw new ProcessComposeProcessFailedError(name, state.status); - } - lastError = undefined; - } catch (error) { - if (error instanceof ProcessComposeProcessFailedError) { - throw error; - } - lastError = error; - } - await sleep(250); - } - - throw new Error( - `Timed out waiting for process-compose process "${name}" to complete.${ - lastState ? ` Last status: ${lastState.status}.` : "" - }${lastError ? ` Last error: ${String(lastError)}` : ""}` - ); -} - -class ProcessComposeProcessFailedError extends Error { - constructor(name: string, status: string) { - super(`process-compose process "${name}" ended unsuccessfully: ${status}`); - this.name = "ProcessComposeProcessFailedError"; - } -} - -export async function restartManagedProcess( - handle: ProcessComposeHandle, - name: string -): Promise { - const before = await getProcessState(handle, name); - const { stderr, exitCode } = await runProcessCompose( - ["process", "restart", name, "-p", String(handle.apiPort)], - { apiPort: handle.apiPort, cwd: handle.cwd } - ); - if (exitCode !== 0) { - throw new Error( - `process-compose restart ${name} failed (${exitCode}): ${stderr}` - ); - } - return await waitForProcessReady(handle, name, { - previousPid: before.pid, - }); -} - -export async function stopProcessCompose( - handle: ProcessComposeHandle -): Promise { - const down = await runProcessCompose(["down", "-p", String(handle.apiPort)], { - apiPort: handle.apiPort, - cwd: handle.cwd, - }); - - if (handle.proc.exitCode === null && handle.proc.pid) { - try { - process.kill(-handle.proc.pid, "SIGTERM"); - } catch { - handle.proc.kill("SIGTERM"); - } - await waitForExit(handle.proc).catch(() => undefined); - } - - if (down.exitCode !== 0 && down.exitCode !== null) { - // Best-effort: the project may already be down. - } -} diff --git a/tests/e2e/harness/process.ts b/tests/e2e/harness/process.ts deleted file mode 100644 index e6fd4f9..0000000 --- a/tests/e2e/harness/process.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ChildProcess } from "node:child_process"; - -export function waitForExit(proc: ChildProcess): Promise { - if (proc.exitCode !== null) { - return Promise.resolve(proc.exitCode); - } - - return new Promise((resolve, reject) => { - const onExit = (code: number | null) => { - proc.off("error", onError); - resolve(code); - }; - const onError = (error: Error) => { - proc.off("exit", onExit); - reject(error); - }; - proc.once("exit", onExit); - proc.once("error", onError); - }); -} diff --git a/tests/e2e/harness/shell-use.ts b/tests/e2e/harness/shell-use.ts deleted file mode 100644 index b43a80a..0000000 --- a/tests/e2e/harness/shell-use.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { delimiter, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { daemonStop, ShellUse } from "@microsoft/shell-use"; - -/** Fixed PTY size so terminal assertions stay stable across local and CI. */ -export const TERMINAL_COLS = 80; -export const TERMINAL_ROWS = 24; - -const SHELL_USE_NAME = "shell-use"; - -function readPinnedShellUseVersion(): string { - const packagePath = fileURLToPath( - new URL("../package.json", import.meta.url) - ); - const pkg = JSON.parse(readFileSync(packagePath, "utf8")) as { - devDependencies?: Record; - }; - const version = pkg.devDependencies?.["@microsoft/shell-use"]; - if (!version) { - throw new Error( - "@microsoft/shell-use is missing from tests/e2e/package.json devDependencies." - ); - } - return version; -} - -/** Keep in lockstep with `@microsoft/shell-use` in `tests/e2e/package.json`. */ -const SHELL_USE_VERSION = readPinnedShellUseVersion(); - -function findOnPath(binary: string): string | undefined { - const pathEnv = process.env.PATH ?? ""; - for (const dir of pathEnv.split(delimiter)) { - if (!dir) continue; - const candidate = join(dir, binary); - if (existsSync(candidate)) { - return candidate; - } - } -} - -function findMiseShellUseBinary(home: string): string | undefined { - const pinned = join( - home, - ".local/share/mise/installs/github-microsoft-shell-use", - SHELL_USE_VERSION, - SHELL_USE_NAME - ); - if (existsSync(pinned)) { - return pinned; - } -} - -/** - * Resolves the `shell-use` native binary. The npm package is only the client; - * the binary must match `@microsoft/shell-use`'s version (see mise.toml). - * - * Preference order: `SHELL_USE_BIN` → mise-pinned install → PATH → `~/.local/bin`. - */ -export function resolveShellUseBinary(): string { - if (process.env.SHELL_USE_BIN) { - return process.env.SHELL_USE_BIN; - } - - const home = process.env.HOME; - if (home) { - const fromMise = findMiseShellUseBinary(home); - if (fromMise) { - return fromMise; - } - } - - const onPath = findOnPath(SHELL_USE_NAME); - if (onPath) { - return onPath; - } - - if (home) { - const localBin = join(home, ".local/bin/shell-use"); - if (existsSync(localBin)) { - return localBin; - } - } - - throw new Error( - `shell-use binary not found (need ${SHELL_USE_VERSION}). Install via \`mise install\` (see mise.toml) or set SHELL_USE_BIN.` - ); -} - -export type TerminalSessionOptions = { - session?: string; -}; - -function restoreEnvVar( - key: "NO_COLOR" | "FORCE_COLOR" | "TERM", - previous: string | undefined -): void { - if (previous === undefined) { - delete process.env[key]; - } else { - process.env[key] = previous; - } -} - -/** - * Opens an isolated shell-use session (unique daemon home), runs `fn`, then - * always closes the session and deletes the temp home. - * - * Clears `NO_COLOR` on the Node process for the duration so a freshly started - * daemon does not inherit a color-suppressing sandbox env into the PTY child. - */ -export async function withTerminalSession( - fn: (su: ShellUse) => Promise, - options: TerminalSessionOptions = {} -): Promise { - const binary = resolveShellUseBinary(); - const home = await mkdtemp(join(tmpdir(), "cyrus-shell-use-")); - const session = options.session ?? `cyrus-${crypto.randomUUID()}`; - const previousNoColor = process.env.NO_COLOR; - const previousForceColor = process.env.FORCE_COLOR; - const previousTerm = process.env.TERM; - - delete process.env.NO_COLOR; - process.env.FORCE_COLOR = "1"; - process.env.TERM = "xterm-256color"; - - const clientOpts = { binary, home }; - let su: ShellUse | undefined; - - try { - su = new ShellUse(session, clientOpts); - await daemonStop(session, clientOpts).catch(() => undefined); - await fn(su); - } finally { - await su?.close().catch(() => undefined); - await daemonStop(session, clientOpts).catch(() => undefined); - await rm(home, { recursive: true, force: true }).catch(() => undefined); - restoreEnvVar("NO_COLOR", previousNoColor); - restoreEnvVar("FORCE_COLOR", previousForceColor); - restoreEnvVar("TERM", previousTerm); - } -} - -/** Drops undefined values so the result is safe for shell-use `env`. */ -export function toShellEnv( - env: Record -): Record { - const out: Record = {}; - for (const [key, value] of Object.entries(env)) { - if (value !== undefined) { - out[key] = value; - } - } - return out; -} - -/** - * Env for a Worker CLI process under a real PTY. Forces ANSI colors and a - * 256-color TERM so styled output is stable in CI and agent sandboxes that - * set `NO_COLOR` / `TERM=dumb`. - */ -export function buildTerminalCliEnv( - base: Record -): Record { - const { NO_COLOR: _noColor, ...env } = toShellEnv(base); - return { - ...env, - FORCE_COLOR: "1", - TERM: "xterm-256color", - }; -} diff --git a/tests/e2e/harness/stack.ts b/tests/e2e/harness/stack.ts deleted file mode 100644 index 90d073d..0000000 --- a/tests/e2e/harness/stack.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { rm } from "node:fs/promises"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { Result } from "better-result"; -import { - buildCompiledCliBinaryOnce, - CLI_WORKER_BINARY, - CLI_WORKER_RUNTIME_DIRECTORY, -} from "./cli-worker"; -import { - createTempWranglerPersistDir, - WRANGLER_PERSIST_TO_ENV, -} from "./database"; -import { - buildServerEnv, - createTempCyrusHome, - removeWranglerEnvFile, - writeWranglerEnvFile, -} from "./env"; -import { - type ProcessComposeHandle, - restartManagedProcess, - startManagedProcess, - startProcessCompose, - stopProcessCompose, -} from "./process-compose"; - -const REPO_ROOT = join(fileURLToPath(new URL("../../..", import.meta.url))); -const PROCESS_COMPOSE_CONFIG = join( - REPO_ROOT, - "tests/e2e/process-compose.yaml" -); - -/** process-compose stack for Playwright: server+web first, worker on demand. */ -export type PlaywrightE2eStack = { - cyrusHome: string; - wranglerPersistDir: string; - wranglerEnvFile?: string; - compose: ProcessComposeHandle; - startWorker: () => Promise; - restartWorker: () => Promise; -}; - -function composeEnv( - cyrusHome: string, - wranglerEnvFile: string, - wranglerPersistDir: string, - serverEnv: Record -): Record { - return { - ...process.env, - ...serverEnv, - CYRUS_HOME: cyrusHome, - WRANGLER_ENV_FILE: wranglerEnvFile, - [WRANGLER_PERSIST_TO_ENV]: wranglerPersistDir, - CYRUS_WORKER_BIN: CLI_WORKER_BINARY, - CYRUS_WORKER_CWD: CLI_WORKER_RUNTIME_DIRECTORY, - NODE_ENV: "testing", - }; -} - -/** - * Starts sync server + Controller web via process-compose. The Worker is - * started later (after Playwright device-UI auth writes CYRUS_HOME state). - */ -export async function startPlaywrightE2eStack(): Promise { - const serverEnv = buildServerEnv(); - const cyrusHome = await createTempCyrusHome(); - const wranglerPersistDir = await createTempWranglerPersistDir(); - let wranglerEnvFile: string | undefined; - let compose: ProcessComposeHandle | undefined; - - const stackResult = await Result.tryPromise(async () => { - await buildCompiledCliBinaryOnce(); - const envFile = await writeWranglerEnvFile(serverEnv); - wranglerEnvFile = envFile; - - compose = await startProcessCompose({ - configPath: PROCESS_COMPOSE_CONFIG, - cwd: REPO_ROOT, - processes: ["web"], - readyProcesses: ["server", "web"], - env: composeEnv(cyrusHome, envFile, wranglerPersistDir, serverEnv), - }); - - let workerStarted = false; - const startWorker = async (): Promise => { - if (!compose) { - throw new Error("process-compose stack is not running."); - } - if (workerStarted) return; - await startManagedProcess(compose, "worker"); - workerStarted = true; - }; - - const restartWorker = async (): Promise => { - if (!compose) { - throw new Error("process-compose stack is not running."); - } - if (!workerStarted) { - throw new Error("Worker has not been started yet."); - } - await restartManagedProcess(compose, "worker"); - }; - - return { - cyrusHome, - wranglerPersistDir, - wranglerEnvFile, - compose, - startWorker, - restartWorker, - }; - }); - - if (stackResult.isErr()) { - const started = compose; - if (started) { - (await Result.tryPromise(() => stopProcessCompose(started))).tapError( - () => { - // best-effort cleanup after partial stack startup - } - ); - } - await removeWranglerEnvFile(wranglerEnvFile); - await rm(cyrusHome, { recursive: true, force: true }).catch( - () => undefined - ); - await rm(wranglerPersistDir, { recursive: true, force: true }).catch( - () => undefined - ); - } - - return stackResult.unwrap(); -} - -export async function stopPlaywrightE2eStack( - stack: PlaywrightE2eStack -): Promise { - await stopProcessCompose(stack.compose); - await removeWranglerEnvFile(stack.wranglerEnvFile); - await rm(stack.cyrusHome, { recursive: true, force: true }).catch( - () => undefined - ); - await rm(stack.wranglerPersistDir, { recursive: true, force: true }).catch( - () => undefined - ); -} diff --git a/tests/e2e/package.json b/tests/e2e/package.json deleted file mode 100644 index 3184914..0000000 --- a/tests/e2e/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@cyrus/e2e", - "private": true, - "type": "module", - "scripts": { - "test:e2e": "NODE_ENV=testing vitest run --config ../../vitest.config.ts --project e2e && NODE_ENV=testing playwright test --config web/playwright.config.ts" - }, - "dependencies": { - "better-result": "catalog:core", - "yaml": "2.9.0" - }, - "devDependencies": { - "@cyrus/typescript": "workspace:*", - "@microsoft/shell-use": "0.0.1-beta.5", - "@playwright/test": "^1.61.1", - "@types/bun": "catalog:core", - "vitest": "catalog:testing" - } -} diff --git a/tests/e2e/process-compose.yaml b/tests/e2e/process-compose.yaml deleted file mode 100644 index b5050f4..0000000 --- a/tests/e2e/process-compose.yaml +++ /dev/null @@ -1,75 +0,0 @@ -version: "0.5" - -processes: - # Apply local D1 migrations before wrangler dev binds env.DB. - prepare-database: - command: "bun tests/e2e/web/prepare-database.ts" - availability: - restart: "no" - - server: - command: "bunx wrangler@4.104.0 dev --config wrangler.json --port 8787 --ip 127.0.0.1 --log-level warn --show-interactive-dev-session false --env-file ${WRANGLER_ENV_FILE} --persist-to ${WRANGLER_PERSIST_TO}" - depends_on: - prepare-database: - condition: process_completed_successfully - readiness_probe: - http_get: - host: "127.0.0.1" - port: 8787 - path: "/health" - initial_delay_seconds: 1 - period_seconds: 2 - timeout_seconds: 2 - failure_threshold: 60 - availability: - restart: "no" - shutdown: - signal: 15 - timeout_seconds: 10 - - web: - command: "bun run dev -- --host 127.0.0.1 --port 5173" - working_dir: "apps/web" - depends_on: - server: - condition: process_healthy - readiness_probe: - http_get: - host: "127.0.0.1" - port: 5173 - path: "/" - initial_delay_seconds: 1 - period_seconds: 2 - timeout_seconds: 2 - failure_threshold: 60 - environment: - - "VITE_SERVER_URL=http://localhost:8787" - availability: - restart: "no" - shutdown: - signal: 15 - timeout_seconds: 10 - - worker: - command: "${CYRUS_WORKER_BIN} start" - working_dir: "${CYRUS_WORKER_CWD}" - depends_on: - server: - condition: process_healthy - readiness_probe: - exec: - command: "cd \"${CYRUS_WORKER_CWD}\" && CYRUS_HOME=\"${CYRUS_HOME}\" CLI_PUBLIC_SERVER_URL=http://localhost:8787 \"${CYRUS_WORKER_BIN}\" status" - initial_delay_seconds: 1 - period_seconds: 2 - timeout_seconds: 5 - failure_threshold: 60 - environment: - - "CYRUS_HOME=${CYRUS_HOME}" - - "CYRUS_DAEMON=1" - - "CLI_PUBLIC_SERVER_URL=http://localhost:8787" - availability: - restart: "no" - backoff_seconds: 1 - shutdown: - signal: 15 - timeout_seconds: 10 diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json deleted file mode 100644 index ece4aa6..0000000 --- a/tests/e2e/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "@cyrus/typescript/tsconfig.base.json", - "compilerOptions": { - "types": ["bun"], - "moduleResolution": "Bundler", - "noEmit": true - }, - "include": ["harness/**/*.ts", "web/**/*.ts"] -} diff --git a/tests/e2e/web/device-auth.ts b/tests/e2e/web/device-auth.ts deleted file mode 100644 index 7055661..0000000 --- a/tests/e2e/web/device-auth.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { rm } from "node:fs/promises"; -import { type Browser, expect } from "@playwright/test"; -import { createE2eAuthSession, type E2eAuth } from "../harness/auth"; -import { readAccessTokenFromHome, startCliLogin } from "../harness/cli-login"; -import { - createTempCyrusHome, - E2E_SERVER_URL, - E2E_WEB_URL, -} from "../harness/env"; - -/** - * Completes device authorization the way a real user does: compiled `cyrusd - * login` prints a code + URL, then a browser session approves it on - * `/auth/device`. Account creation stays programmatic (email API). - */ -export async function seedCliAccessTokenViaDeviceUi( - browser: Browser, - serverUrl = E2E_SERVER_URL -): Promise { - const email = `e2e-${crypto.randomUUID()}@cyrus.test`; - const password = "e2e-test-password-32chars-min"; - const session = await createE2eAuthSession(serverUrl, email, password); - const home = await createTempCyrusHome(); - let login: Awaited> | undefined; - - try { - login = await startCliLogin(home); - await approveDeviceInBrowser( - browser, - session.sessionToken, - login.prompt.verificationUrl - ); - await login.waitUntilDone(); - const token = await readAccessTokenFromHome(home); - - return { - token, - userId: session.userId, - sessionCookie: session.sessionCookie, - sessionToken: session.sessionToken, - email, - }; - } catch (error) { - login?.kill(); - throw error; - } finally { - await rm(home, { force: true, recursive: true }); - } -} - -async function approveDeviceInBrowser( - browser: Browser, - sessionToken: string, - verificationUrl: string -): Promise { - const context = await browser.newContext({ baseURL: E2E_WEB_URL }); - try { - await context.addCookies([ - { - name: "better-auth.session_token", - value: sessionToken, - domain: "localhost", - path: "/", - httpOnly: true, - secure: false, - sameSite: "Lax", - }, - ]); - - const page = await context.newPage(); - await page.goto(verificationUrl); - await expect( - page.getByRole("heading", { name: "Authorize device" }) - ).toBeVisible({ timeout: 30_000 }); - await page.getByRole("button", { name: "Approve" }).click(); - await expect( - page.getByRole("heading", { name: "Device connected" }) - ).toBeVisible({ timeout: 30_000 }); - } finally { - await context.close(); - } -} diff --git a/tests/e2e/web/fixtures.ts b/tests/e2e/web/fixtures.ts deleted file mode 100644 index c628a1e..0000000 --- a/tests/e2e/web/fixtures.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { test as base } from "@playwright/test"; -import type { E2eAuth } from "../harness/auth"; -import { - E2E_CLI_WORKER_NAME, - writeCliWorkerState, -} from "../harness/cli-worker"; -import { E2E_AUTH_FILE, requireE2e } from "../harness/env"; -import { - type PlaywrightE2eStack, - startPlaywrightE2eStack, - stopPlaywrightE2eStack, -} from "../harness/stack"; -import { seedCliAccessTokenViaDeviceUi } from "./device-auth"; - -export type AuthFixture = E2eAuth; - -type CliWorkerFixture = { - name: string; - restart: () => Promise; -}; - -type WorkerFixtures = { - stack: PlaywrightE2eStack; - auth: AuthFixture; - cliWorker: CliWorkerFixture; -}; - -export const test = base.extend({ - stack: [ - // Playwright fixture callbacks must destructure even when unused. - async ({ browser: _browser }, use) => { - requireE2e(); - const stack = await startPlaywrightE2eStack(); - try { - await use(stack); - } finally { - await stopPlaywrightE2eStack(stack); - } - }, - { scope: "worker", timeout: 180_000 }, - ], - auth: [ - async ({ browser, stack }, use) => { - const auth = await seedCliAccessTokenViaDeviceUi(browser); - await writeCliWorkerState(stack.cyrusHome, auth.token); - await writeFile( - join(stack.cyrusHome, E2E_AUTH_FILE), - `${JSON.stringify(auth, null, 2)}\n`, - { mode: 0o600 } - ); - await use(auth); - }, - { scope: "worker", timeout: 120_000 }, - ], - cliWorker: [ - async ({ stack, auth: _auth }, use) => { - await stack.startWorker(); - await use({ - name: E2E_CLI_WORKER_NAME, - restart: stack.restartWorker, - }); - }, - { scope: "worker", timeout: 120_000 }, - ], -}); diff --git a/tests/e2e/web/helpers.ts b/tests/e2e/web/helpers.ts deleted file mode 100644 index 820191c..0000000 --- a/tests/e2e/web/helpers.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { mkdir } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { type BrowserContext, expect, type Page } from "@playwright/test"; -import type { AuthFixture } from "./fixtures"; - -const ADD_PROJECT_SUBMIT_NAME = /Add|Create & Add/; -const CLAUDE_AGENT_NAME = /Claude Agent|claude/i; -const AGENT_PLACEHOLDER = /^Agent$/; -const MODEL_MENU = - '[data-slot="dropdown-menu-content"] button, [role="menu"] button'; - -export const THREAD_ROUTE = /\/t\/[^/]+/; - -export function projectNameFor(scenario: string): string { - return `e2e-${scenario}`; -} - -export async function installSessionCookie( - context: BrowserContext, - auth: AuthFixture -): Promise { - await context.addCookies([ - { - name: "better-auth.session_token", - value: auth.sessionToken, - domain: "localhost", - path: "/", - httpOnly: true, - secure: false, - sameSite: "Lax", - }, - ]); -} - -export async function openConnectedController( - page: Page, - context: BrowserContext, - auth: AuthFixture, - workerName: string -): Promise { - await installSessionCookie(context, auth); - await page.goto("/workers"); - await expect(page.getByRole("combobox")).toBeVisible({ timeout: 30_000 }); - await page.getByRole("combobox").click(); - await expect(page.getByRole("option", { name: workerName })).toBeVisible({ - timeout: 30_000, - }); - await page.getByRole("option", { name: workerName }).click(); - await expect(page.getByText("No project selected")).toBeVisible({ - timeout: 30_000, - }); -} - -function projectRow(page: Page, projectName: string) { - return page - .locator('[data-sidebar="menu-item"]') - .filter({ has: page.getByText(projectName, { exact: true }) }); -} - -export async function addProject( - page: Page, - projectName: string -): Promise { - const projectPath = join(tmpdir(), projectName); - await mkdir(projectPath, { recursive: true }); - - const addButton = page.getByRole("main").getByRole("button", { - name: "Add project", - }); - if (await addButton.isVisible().catch(() => false)) { - await addButton.click(); - } else { - await page.getByRole("button", { name: "Add project" }).first().click(); - } - - const dialog = page.getByRole("dialog", { name: "Add project" }); - await expect(dialog).toBeVisible({ timeout: 15_000 }); - const input = dialog.getByRole("combobox"); - await input.fill(projectPath); - const submit = dialog.getByRole("button", { name: ADD_PROJECT_SUBMIT_NAME }); - await expect(submit).toBeVisible({ timeout: 5000 }); - await submit.click(); - await expect(dialog).toBeHidden({ timeout: 15_000 }); - await expect(projectRow(page, projectName)).toBeVisible({ timeout: 30_000 }); - await expect( - projectRow(page, projectName).getByText("No threads yet") - ).toBeVisible({ timeout: 15_000 }); - return projectPath; -} - -export async function openNewDraft( - page: Page, - projectName: string -): Promise { - const row = projectRow(page, projectName); - await row.hover(); - const newThread = row.getByRole("button", { - name: `Create new thread in ${projectName}`, - }); - await expect(newThread).toBeVisible({ timeout: 15_000 }); - // The project header button sits above the action until hover styles apply; - // force avoids flake from the sortable header intercepting the hit target. - await newThread.click({ force: true }); - await expect(page.getByText("New thread").first()).toBeVisible({ - timeout: 30_000, - }); - await expect(page.locator('[data-chat-composer-form="true"]')).toBeVisible({ - timeout: 30_000, - }); -} - -export async function selectDraftAgent(page: Page): Promise { - const footer = page.locator('[data-chat-composer-footer="true"]'); - await expect(footer).toBeVisible({ timeout: 30_000 }); - const picker = footer.getByRole("button").first(); - await expect(picker).toBeEnabled({ timeout: 60_000 }); - await picker.click(); - - const agentButton = page - .getByRole("button", { name: CLAUDE_AGENT_NAME }) - .first(); - await expect(agentButton).toBeVisible({ timeout: 30_000 }); - await agentButton.click(); - - const modelOption = page - .locator(MODEL_MENU) - .filter({ hasNotText: CLAUDE_AGENT_NAME }) - .first(); - if (await modelOption.isVisible().catch(() => false)) { - await modelOption.click(); - } else { - await page.keyboard.press("Escape"); - } - - await expect(picker).not.toHaveText(AGENT_PLACEHOLDER, { timeout: 30_000 }); -} - -export async function sendComposerMessage( - page: Page, - text: string -): Promise { - const editor = page.locator( - '[data-chat-composer-form="true"] [contenteditable="true"]' - ); - await expect(editor).toBeVisible({ timeout: 30_000 }); - await waitForComposerIdle(page); - await editor.click(); - await editor.pressSequentially(text, { delay: 15 }); - const send = page.getByRole("button", { name: "Send message" }); - await expect(send).toBeEnabled({ timeout: 15_000 }); - await send.click(); -} - -/** Wait until the composer is not mid-turn (Send message, not Stop generation). */ -export async function waitForComposerIdle(page: Page): Promise { - await expect(page.getByRole("button", { name: "Send message" })).toBeVisible({ - timeout: 120_000, - }); -} - -export async function expectThreadCount( - page: Page, - projectName: string, - count: number -): Promise { - const row = projectRow(page, projectName); - await expect(row.getByText(String(count), { exact: true })).toBeVisible({ - timeout: 60_000, - }); - if (count === 0) { - await expect(row.getByText("No threads yet")).toBeVisible(); - } else { - await expect(row.getByText("No threads yet")).toHaveCount(0); - } -} diff --git a/tests/e2e/web/playwright.config.ts b/tests/e2e/web/playwright.config.ts deleted file mode 100644 index 2bc95ad..0000000 --- a/tests/e2e/web/playwright.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from "@playwright/test"; -import { E2E_WEB_URL } from "../harness/env"; - -export default defineConfig({ - testDir: "./specs", - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, - workers: 1, - timeout: 180_000, - reporter: [["list"]], - use: { - baseURL: E2E_WEB_URL, - trace: "on-first-retry", - }, - projects: [ - { - name: "chromium", - use: { browserName: "chromium" }, - }, - ], -}); diff --git a/tests/e2e/web/prepare-database.ts b/tests/e2e/web/prepare-database.ts deleted file mode 100644 index 6603508..0000000 --- a/tests/e2e/web/prepare-database.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ensureDatabaseSchema } from "../harness/database"; -import { requireE2e } from "../harness/env"; - -requireE2e(); -await ensureDatabaseSchema(); diff --git a/tests/e2e/web/specs/catalog.spec.ts b/tests/e2e/web/specs/catalog.spec.ts deleted file mode 100644 index 5bd08be..0000000 --- a/tests/e2e/web/specs/catalog.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { expect } from "@playwright/test"; -import { test } from "../fixtures"; -import { - addProject, - openConnectedController, - openNewDraft, - projectNameFor, - selectDraftAgent, - sendComposerMessage, - THREAD_ROUTE, -} from "../helpers"; - -const e2eDescribe = - process.env.NODE_ENV === "testing" ? test.describe : test.describe.skip; - -const NON_EMPTY = /./; - -e2eDescribe("catalog", () => { - test("controller gets and sets a bound thread catalog", async ({ - page, - context, - auth, - cliWorker, - }) => { - const projectName = projectNameFor("catalog"); - await openConnectedController(page, context, auth, cliWorker.name); - await addProject(page, projectName); - await openNewDraft(page, projectName); - await selectDraftAgent(page); - - await sendComposerMessage(page, "catalog verify"); - await expect(page).toHaveURL(THREAD_ROUTE, { timeout: 90_000 }); - - const footer = page.locator('[data-chat-composer-footer="true"]'); - await expect(footer).toBeVisible({ timeout: 30_000 }); - - const modelTrigger = footer.getByRole("button").first(); - await expect(modelTrigger).toBeEnabled({ timeout: 60_000 }); - await modelTrigger.click(); - const modelOptions = page.locator( - '[role="menu"] button, [data-slot="dropdown-menu-content"] button' - ); - await expect(modelOptions.first()).toBeVisible({ timeout: 30_000 }); - const modelCount = await modelOptions.count(); - expect(modelCount).toBeGreaterThan(0); - await page.keyboard.press("Escape"); - - const modeTrigger = footer - .getByRole("combobox") - .filter({ hasText: NON_EMPTY }) - .first(); - if (await modeTrigger.isVisible().catch(() => false)) { - await modeTrigger.click(); - const modeOption = page.getByRole("option").first(); - await expect(modeOption).toBeVisible({ timeout: 15_000 }); - const modeName = (await modeOption.innerText()).trim(); - await modeOption.click(); - await expect(modeTrigger).toContainText(modeName, { timeout: 15_000 }); - } - }); -}); diff --git a/tests/e2e/web/specs/cold-resume.spec.ts b/tests/e2e/web/specs/cold-resume.spec.ts deleted file mode 100644 index 0bc9ed2..0000000 --- a/tests/e2e/web/specs/cold-resume.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { expect } from "@playwright/test"; -import { test } from "../fixtures"; -import { - addProject, - expectThreadCount, - installSessionCookie, - openConnectedController, - openNewDraft, - projectNameFor, - selectDraftAgent, - sendComposerMessage, - THREAD_ROUTE, -} from "../helpers"; - -const e2eDescribe = - process.env.NODE_ENV === "testing" ? test.describe : test.describe.skip; - -e2eDescribe("cold session resume", () => { - test("thread resumes with the same session after a worker restart", async ({ - page, - context, - auth, - cliWorker, - }) => { - const projectName = projectNameFor("cold-resume"); - await openConnectedController(page, context, auth, cliWorker.name); - await addProject(page, projectName); - await openNewDraft(page, projectName); - await selectDraftAgent(page); - await sendComposerMessage(page, "cold resume ping"); - await expect(page).toHaveURL(THREAD_ROUTE, { timeout: 90_000 }); - await expectThreadCount(page, projectName, 1); - const threadUrl = page.url(); - - await cliWorker.restart(); - - await installSessionCookie(context, auth); - await page.goto(threadUrl); - await expect(page.getByRole("combobox")).toBeVisible({ timeout: 30_000 }); - - const combobox = page.getByRole("combobox"); - if (!(await combobox.innerText()).includes(cliWorker.name)) { - await combobox.click(); - await page.getByRole("option", { name: cliWorker.name }).click(); - await page.goto(threadUrl); - } - - await expect(page.locator('[data-chat-composer-form="true"]')).toBeVisible({ - timeout: 60_000, - }); - await sendComposerMessage(page, "after restart"); - await expectThreadCount(page, projectName, 1); - await expect(page).toHaveURL(threadUrl); - }); -}); diff --git a/tests/e2e/web/specs/smoke.spec.ts b/tests/e2e/web/specs/smoke.spec.ts deleted file mode 100644 index a049de8..0000000 --- a/tests/e2e/web/specs/smoke.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expect } from "@playwright/test"; -import { test } from "../fixtures"; -import { openConnectedController } from "../helpers"; - -const e2eDescribe = - process.env.NODE_ENV === "testing" ? test.describe : test.describe.skip; - -e2eDescribe("web smoke", () => { - test("authenticated workspace shows the connected worker", async ({ - page, - context, - auth, - cliWorker, - }) => { - await openConnectedController(page, context, auth, cliWorker.name); - await expect(page.getByRole("combobox")).toContainText(cliWorker.name); - }); -}); diff --git a/tests/e2e/web/specs/thread-lifecycle.spec.ts b/tests/e2e/web/specs/thread-lifecycle.spec.ts deleted file mode 100644 index be19480..0000000 --- a/tests/e2e/web/specs/thread-lifecycle.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { expect } from "@playwright/test"; -import { test } from "../fixtures"; -import { - addProject, - expectThreadCount, - openConnectedController, - openNewDraft, - projectNameFor, - selectDraftAgent, - sendComposerMessage, - THREAD_ROUTE, -} from "../helpers"; - -const e2eDescribe = - process.env.NODE_ENV === "testing" ? test.describe : test.describe.skip; - -e2eDescribe("thread lifecycle", () => { - test("drafts leave no worker state; startThread births exactly one thread", async ({ - page, - context, - auth, - cliWorker, - }) => { - const projectName = projectNameFor("lifecycle"); - await openConnectedController(page, context, auth, cliWorker.name); - await addProject(page, projectName); - await expectThreadCount(page, projectName, 0); - - await openNewDraft(page, projectName); - await selectDraftAgent(page); - await expectThreadCount(page, projectName, 0); - - await sendComposerMessage(page, "hello lifecycle"); - await expectThreadCount(page, projectName, 1); - await expect(page).toHaveURL(THREAD_ROUTE, { timeout: 60_000 }); - }); -}); diff --git a/tests/e2e/web/specs/thread-sync.spec.ts b/tests/e2e/web/specs/thread-sync.spec.ts deleted file mode 100644 index 8cb2b55..0000000 --- a/tests/e2e/web/specs/thread-sync.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expect } from "@playwright/test"; -import { test } from "../fixtures"; -import { openConnectedController } from "../helpers"; - -const e2eDescribe = - process.env.NODE_ENV === "testing" ? test.describe : test.describe.skip; - -e2eDescribe("thread metadata sync", () => { - test("controller sees worker metadata after hub join", async ({ - page, - context, - auth, - cliWorker, - }) => { - await openConnectedController(page, context, auth, cliWorker.name); - await expect(page.getByRole("combobox")).toContainText(cliWorker.name); - }); -}); diff --git a/tests/e2e/web/specs/worker-connects.spec.ts b/tests/e2e/web/specs/worker-connects.spec.ts deleted file mode 100644 index 65cf2f0..0000000 --- a/tests/e2e/web/specs/worker-connects.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expect } from "@playwright/test"; -import { test } from "../fixtures"; -import { openConnectedController } from "../helpers"; - -const e2eDescribe = - process.env.NODE_ENV === "testing" ? test.describe : test.describe.skip; - -e2eDescribe("worker connects", () => { - test("authenticated controller sees the logged-in worker", async ({ - page, - context, - auth, - cliWorker, - }) => { - await openConnectedController(page, context, auth, cliWorker.name); - await expect(page.getByRole("combobox")).toContainText(cliWorker.name); - }); -}); diff --git a/tooling/test/fixtures/cyrus-home.ts b/tooling/test/fixtures/cyrus-home.ts new file mode 100644 index 0000000..6fd9f07 --- /dev/null +++ b/tooling/test/fixtures/cyrus-home.ts @@ -0,0 +1,29 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Temp `CYRUS_HOME` directory for a single test run. */ +export function createTempCyrusHome(prefix: string): Promise { + return mkdtemp(join(tmpdir(), prefix)); +} + +type AfterEachHook = (fn: () => unknown) => void; + +export function tempCyrusHomeFixture( + afterEachHook: AfterEachHook, + prefix: string +): () => Promise { + const homes: string[] = []; + + afterEachHook(async () => { + await Promise.all( + homes.splice(0).map((home) => rm(home, { recursive: true, force: true })) + ); + }); + + return async () => { + const home = await createTempCyrusHome(prefix); + homes.push(home); + return home; + }; +} diff --git a/tooling/test/mocks/auth-env.ts b/tooling/test/mocks/auth-env.ts new file mode 100644 index 0000000..2aff22d --- /dev/null +++ b/tooling/test/mocks/auth-env.ts @@ -0,0 +1,10 @@ +export const FAKE_BETTER_AUTH_ENV = { + NODE_ENV: "testing", + BETTER_AUTH_SECRET: "cyrus-test-secret-that-is-at-least-32-characters", + OAUTH_GITHUB_CLIENT_ID: "cyrus-test-github-client-id", + OAUTH_GITHUB_CLIENT_SECRET: "cyrus-test-github-client-secret", + OAUTH_PROXY_SECRET: "cyrus-test-oauth-proxy-secret", + ALLOWED_ORIGINS: "https://cyrus.soorya-u.dev", + PRODUCTION_URL: "https://cyrus.soorya-u.dev", + WEB_APP_URL: "https://cyrus.soorya-u.dev", +} as const; diff --git a/tooling/test/mocks/data-channel.ts b/tooling/test/mocks/data-channel.ts deleted file mode 100644 index 93f3a22..0000000 --- a/tooling/test/mocks/data-channel.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -type MockChannelState = "connecting" | "open" | "closing" | "closed"; - -type MockChannel = { - readyState: MockChannelState; - listeners: Map void>>; -}; - -export function createMockDataChannel( - initialState: MockChannelState = "connecting" -): MockChannel { - return { - readyState: initialState, - listeners: new Map(), - }; -} - -export function openMockDataChannel(channel: MockChannel): void { - channel.readyState = "open"; - for (const listener of channel.listeners.get("open") ?? []) { - listener(); - } -} - -export function attachMockChannelListeners( - channel: MockChannel, - handlers: { - onOpen?: () => void; - onError?: () => void; - onClose?: () => void; - } -): void { - const register = (type: string, handler?: () => void) => { - if (!handler) return; - const listeners = channel.listeners.get(type) ?? new Set(); - listeners.add(handler); - channel.listeners.set(type, listeners); - }; - - register("open", handlers.onOpen); - register("error", handlers.onError); - register("close", handlers.onClose); -} - -describe("mock data channel", () => { - test("opens and notifies listeners", () => { - const channel = createMockDataChannel(); - let opened = false; - - attachMockChannelListeners(channel, { - onOpen: () => { - opened = true; - }, - }); - openMockDataChannel(channel); - - expect(channel.readyState).toBe("open"); - expect(opened).toBe(true); - }); -}); diff --git a/tooling/test/package.json b/tooling/test/package.json index a30e5b2..5831708 100644 --- a/tooling/test/package.json +++ b/tooling/test/package.json @@ -4,7 +4,8 @@ "type": "module", "exports": { "./setup/vitest.shared": "./setup/vitest.shared.ts", - "./setup/bun.setup": "./setup/bun.setup.ts" + "./fixtures/cyrus-home": "./fixtures/cyrus-home.ts", + "./mocks/auth-env": "./mocks/auth-env.ts" }, "scripts": { "smoke:deploy": "bun smoke/deploy.ts" diff --git a/tooling/test/setup/bun.setup.ts b/tooling/test/setup/bun.setup.ts deleted file mode 100644 index 0a3af9b..0000000 --- a/tooling/test/setup/bun.setup.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Shared Bun test setup belongs here when suites need process-wide hooks. -export {}; diff --git a/turbo.json b/turbo.json index f47c2bc..16e020a 100644 --- a/turbo.json +++ b/turbo.json @@ -29,9 +29,6 @@ "dependsOn": ["^build"], "cache": false }, - "test:e2e": { - "cache": false - }, "dev": { "dependsOn": ["dev:db"], "cache": false, diff --git a/vitest.config.ts b/vitest.config.ts index 8dc660a..efcf185 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { FAKE_BETTER_AUTH_ENV as bindings } from "@cyrus/test/mocks/auth-env"; import react from "@vitejs/plugin-react"; import tsconfigPaths from "vite-tsconfig-paths"; import { defineConfig } from "vitest/config"; @@ -29,19 +30,7 @@ export default defineConfig({ plugins: [ cloudflareTest({ wrangler: { configPath: packageRoot("wrangler.json") }, - miniflare: { - bindings: { - ALLOWED_ORIGINS: "https://cyrus.soorya-u.dev", - BETTER_AUTH_SECRET: - "test-secret-that-is-at-least-thirty-two-characters", - NODE_ENV: "testing", - OAUTH_GITHUB_CLIENT_ID: "test-client-id", - OAUTH_GITHUB_CLIENT_SECRET: "test-client-secret", - OAUTH_PROXY_SECRET: "test-oauth-proxy-secret", - PRODUCTION_URL: "https://cyrus.soorya-u.dev", - WEB_APP_URL: "https://cyrus.soorya-u.dev", - }, - }, + miniflare: { bindings }, }), ], test: { @@ -117,16 +106,6 @@ export default defineConfig({ include: ["src/**/*.test.ts"], }, }, - { - root: packageRoot("tests/e2e"), - test: { - name: "e2e", - environment: "node", - include: ["harness/**/*.test.ts"], - testTimeout: 180_000, - fileParallelism: false, - }, - }, ], coverage: { provider: "istanbul" }, }, From 46342c160493ecfa06626f7dba1230ad9211c13b Mon Sep 17 00:00:00 2001 From: Soorya U Date: Mon, 27 Jul 2026 00:39:21 +0530 Subject: [PATCH 2/2] Address CodeRabbit review: fix dangling harness reference, clarify smoke secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VERIFY_LOOP.md still pointed at the deleted "canonical harness flow" section for the manual email/password verification step; inline the actual sign-up/sign-in/device-approve steps instead (already described earlier in the same section). TESTING_FRAMEWORK.md's smoke-test secret names were flagged as mismatching tooling/test/smoke/deploy.ts's env vars. Checked .github/workflows/deploy.yml: DEPLOY_SMOKE_TOKEN/DEPLOY_SMOKE_ROOM_ID are the actual GitHub secret names a maintainer configures, mapped to the script's SMOKE_BEARER_TOKEN/SMOKE_ROOM_ID inside the workflow — the doc was right to lead with the secret names, just needed the mapping spelled out. The ADR 0019 date finding was already withdrawn on the PR (the review timestamp isn't a valid reference point for the decision date). Co-Authored-By: Claude Sonnet 5 --- docs/guides/TESTING_FRAMEWORK.md | 2 +- docs/guides/VERIFY_LOOP.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/TESTING_FRAMEWORK.md b/docs/guides/TESTING_FRAMEWORK.md index 9557beb..d470fc5 100644 --- a/docs/guides/TESTING_FRAMEWORK.md +++ b/docs/guides/TESTING_FRAMEWORK.md @@ -64,4 +64,4 @@ Unit tests stay close to the code they cover. In `apps/server`, every colocated ## Phase 5 notes -- Deploy smoke runs after every server deploy via `tooling/test/smoke/deploy.ts`. Optional `DEPLOY_SMOKE_TOKEN` and `DEPLOY_SMOKE_ROOM_ID` secrets enable a signaling WebSocket check in addition to `GET /health`. +- Deploy smoke runs after every server deploy via `tooling/test/smoke/deploy.ts`. Optional `DEPLOY_SMOKE_TOKEN` and `DEPLOY_SMOKE_ROOM_ID` secrets (mapped to `SMOKE_BEARER_TOKEN`/`SMOKE_ROOM_ID` in `.github/workflows/deploy.yml`) enable a signaling WebSocket check in addition to `GET /health`. diff --git a/docs/guides/VERIFY_LOOP.md b/docs/guides/VERIFY_LOOP.md index 0c80613..ba9ac3f 100644 --- a/docs/guides/VERIFY_LOOP.md +++ b/docs/guides/VERIFY_LOOP.md @@ -95,7 +95,7 @@ export CLI_PUBLIC_SERVER_URL=http://localhost:8787 bun dev login ``` -Open the printed device URL in an authenticated browser and approve the code. The visible device page offers GitHub sign-in. For an email/password test account, use the canonical harness flow described above; it creates the account, establishes the session, claims the device code, and approves it without adding a test-only product path. Then verify and prepare an agent: +Open the printed device URL in an authenticated browser and approve the code. The visible device page offers GitHub sign-in; for an email/password test account, sign up and sign in first via the endpoints described above, then open the device URL in that authenticated session and approve. Then verify and prepare an agent: ```sh bun dev whoami --email