History

e2e: fix stale docs, harden dev-CLI status, add cloud+selfhost CI jobs

- e2e/AGENTS.md: the anatomy example predated the service-yielding scenario() signature (no more needs/ctx); capability notes said browser was cloud-only and mcp-oauth selfhost-only, both wrong per targets/*.ts; file placement now lists cloudflare/, local/, cli/; document summary, motel, test:* scripts, the viewer/ SPA, pr-media, and the Windows desktop/cli VM targets. - e2e dev CLI status: probe the app URL before reporting ready (a zombie runner with a dead server used to read as healthy), and only parse real state files in .dev/ (cloud.journey.json rendered as a garbage DEAD line). - CI: run the cloud and selfhost e2e projects on every PR/push with failure artifacts (trace.zip, session.mp4, step screenshots) uploaded per target.

Rhys Sullivan committed 4 months ago

Commit cf6b7e41 parent be3f2083 files · +163 −26Browse files

Unified · Split

3 files
  1. .github/workflows/ci.yml +46 −0
  2. e2e/AGENTS.md +65 −17
  3. e2e/scripts/cli.ts +52 −9
.github/workflows/ci.yml+46−0
@@ -74,12 +74,58 @@ jobs: · 73 unmodified lines
74 74
75 - run: bun run test75 - run: bun run test
76 76
77 e2e:
78 name: E2E (${{ matrix.target }})
79 strategy:
80 fail-fast: false
81 matrix:
82 target: [cloud, selfhost]
83 runs-on: ubuntu-latest
84 timeout-minutes: 30
85 steps:
86 - uses: actions/checkout@v4
87
88 - uses: oven-sh/setup-bun@v2
89 with:
90 bun-version: 1.3.11
91
92 # The dev stacks spawn Node sidecars (vite/workerd tooling); pin the
93 # same known-good runtime the unit-test job uses.
94 - uses: actions/setup-node@v4
95 with:
96 node-version: 22
97
98 - run: bun install --frozen-lockfile
99
100 # Install from e2e so bunx resolves ITS pinned playwright (the version
101 # the tests run against) rather than floating to the latest.
102 - name: Install Playwright Chromium
103 run: bunx playwright install --with-deps chromium chromium-headless-shell
104 working-directory: e2e
105
106 # The globalsetup boots the target's own dev server (ports are claimed
107 # per checkout, so this is hermetic) and tears it down after the run.
108 - name: Run ${{ matrix.target }} scenarios
109 run: bunx vitest run --project ${{ matrix.target }}
110 working-directory: e2e
111
112 # Failed runs keep their trace.zip / session.mp4 / step screenshots in
113 # runs/<target>/<slug>/ — surface them instead of a bare red X.
114 - name: Upload run artifacts
115 if: failure()
116 uses: actions/upload-artifact@v4
117 with:
118 name: e2e-runs-${{ matrix.target }}
119 path: e2e/runs/
120 retention-days: 7
121
77 e2e-local:122 e2e-local:
78 name: E2E (stdio MCP)123 name: E2E (stdio MCP)
79 # Skipped on pull_request: the local scenario boots a real `executor web`124 # Skipped on pull_request: the local scenario boots a real `executor web`
80 # plus a browser and is currently flaky on PRs. Still runs on push to main.125 # plus a browser and is currently flaky on PRs. Still runs on push to main.
81 if: github.event_name != 'pull_request'126 if: github.event_name != 'pull_request'
82 runs-on: ubuntu-latest127 runs-on: ubuntu-latest
128 timeout-minutes: 20
83 steps:129 steps:
84 - uses: actions/checkout@v4130 - uses: actions/checkout@v4
85 131
e2e/AGENTS.md+65−17
@@ -14,9 +14,18 @@ produce a Playwright trace, video, and step screenshots for debugging. · 13 unmodified lines
14 14
15## File placement15## File placement
16 16
17- `scenarios/*.test.ts` — runs on every target (cloud + selfhost)17Scenario directories map to vitest projects (`vitest.config.ts` is the
18- `cloud/*.test.ts` — cloud-only (e.g. billing, WorkOS-session UI)18authoritative list of targets and what each one includes):
19- `selfhost/*.test.ts` — selfhost-only19
20- `scenarios/*.test.ts` — cross-target; runs on cloud + selfhost by default,
21 and selected files also run on selfhost-docker and cloudflare
22- `cloud/*.test.ts` — cloud-only (e.g. billing, WorkOS-session UI, telemetry)
23- `selfhost/*.test.ts` — selfhost-only (also runs on selfhost-docker)
24- `cloudflare/*.test.ts` — the Cloudflare self-host worker
25- `local/*.test.ts` — the single-user local app; each scenario boots its own
26 `executor web`
27- `cli/*.test.ts` — the supervised CLI daemon inside guest VMs
28- `desktop/`, `desktop-packaged/`, `desktop-vm/` — see Desktop targets below
20 29
21## Anatomy30## Anatomy
22 31
@@ -25,32 +34,47 @@ import { expect } from "@effect/vitest"; · 2 unmodified lines
25import { Effect } from "effect";34import { Effect } from "effect";
26import { composePluginApi } from "@executor-js/api/server";35import { composePluginApi } from "@executor-js/api/server";
27import { scenario } from "../src/scenario";36import { scenario } from "../src/scenario";
37import { Api, Target } from "../src/services";
28 38
29const coreApi = composePluginApi([] as const); // tools/integrations/connections/providers/executions/oauth/policies39const coreApi = composePluginApi([] as const); // tools/integrations/connections/providers/executions/oauth/policies
30 40
31scenario("Tools · a fresh workspace advertises the built-in tools", { needs: ["api"] }, (ctx) =>41scenario(
42 "Tools · a fresh workspace advertises the built-in tools",
43 {}, // options: { timeout?: number }
32 Effect.gen(function* () {44 Effect.gen(function* () {
33 const identity = yield* ctx.target.newIdentity(); // fresh isolated user+org45 const target = yield* Target;
34 const client = yield* ctx.api.client(coreApi, identity); // typed HttpApiClient46 const { client } = yield* Api;
35 const tools = yield* client.tools.list();47 const identity = yield* target.newIdentity(); // fresh isolated user+org
48 const api = yield* client(coreApi, identity); // typed HttpApiClient
49 const tools = yield* api.tools.list({ query: {} });
36 expect(tools.length, "at least one tool is exposed").toBeGreaterThan(0);50 expect(tools.length, "at least one tool is exposed").toBeGreaterThan(0);
37 }),51 }),
38);52);
39```53```
40 54
41- Capabilities (`needs`): `api`, `browser` (cloud only today), `mcp-oauth`55- A scenario declares what it needs by **yielding services** from
42 (selfhost only today), `billing` (cloud only).56 `src/services.ts` (`Target`, `Api`, `Browser`, `Mcp`, `Billing`, `Cli`,
57 `Telemetry`, …). There is no `needs` list: yielding a service the current
58 target can't provide skips the test and records why in `skipped.json`.
59- Which target provides what (from `targets/*.ts`): `api` — everything except
60 local and the desktop targets; `browser` — cloud, selfhost, selfhost-docker,
61 cloudflare, local; `mcp-oauth` — cloud, selfhost, selfhost-docker,
62 cloudflare (dev-auth on cloudflare, so no real consent hop); `billing` —
63 cloud only. `Telemetry` and `Autumn` appear when the suite booted motel /
64 the Autumn emulator (cloud).
43- Resources created in a test must be cleaned up with `Effect.ensuring` (a65- Resources created in a test must be cleaned up with `Effect.ensuring` (a
44 finalizer), not trailing statements — a mid-test failure must not leak state66 finalizer), not trailing statements — a mid-test failure must not leak state
45 into the shared instance.67 into the shared instance.
46 68
47## Browser scenarios (cloud)69## Browser scenarios
48 70
49```ts71```ts
50const identity = yield * ctx.target.newIdentity(); // logged in, has an org72const target = yield * Target;
73const browser = yield * Browser;
74const identity = yield * target.newIdentity(); // logged in, has an org
51// or newIdentity({ org: false }) for the onboarding flow75// or newIdentity({ org: false }) for the onboarding flow
52yield *76yield *
53 ctx.browser.session(identity, async ({ page, step }) => {77 browser.session(identity, async ({ page, step }) => {
54 await step("A fresh user lands on the integrations page", async () => {78 await step("A fresh user lands on the integrations page", async () => {
55 await page.goto("/", { waitUntil: "networkidle" });79 await page.goto("/", { waitUntil: "networkidle" });
56 await page.getByText("Integrations").first().waitFor();80 await page.getByText("Integrations").first().waitFor();
@@ -68,10 +92,11 @@ yield * · 11 unmodified lines
68 opening menus: `await page.waitForLoadState("networkidle")`.92 opening menus: `await page.waitForLoadState("networkidle")`.
69- The stub user renders as "Test User" / `test@example.com`.93- The stub user renders as "Test User" / `test@example.com`.
70 94
71## MCP scenarios (selfhost)95## MCP scenarios
72 96
73```ts97```ts
74const session = ctx.mcp.session(identity);98const mcp = yield * Mcp;
99const session = mcp.session(identity);
75const tools = yield * session.listTools(); // OAuth happens headlessly here100const tools = yield * session.listTools(); // OAuth happens headlessly here
76const r = yield * session.call("execute", { code: "return 1 + 1;" });101const r = yield * session.call("execute", { code: "return 1 + 1;" });
77// human-in-the-loop: session.approvePaused(r.text) resumes a paused execution102// human-in-the-loop: session.approvePaused(r.text) resumes a paused execution
@@ -97,6 +122,8 @@ expect(span.span.tags["executor.tool.outcome"]).toBe("fail"); · 19 unmodified lines
97 122
98- `expectSpan` polls (~20s): exporters batch, so arrival is123- `expectSpan` polls (~20s): exporters batch, so arrival is
99 eventually-consistent — "the span reaches the store, soon" IS the contract.124 eventually-consistent — "the span reaches the store, soon" IS the contract.
125- The cloud globalsetup boots motel automatically; `bun run motel` runs the
126 same store standalone (browse it, or point a dev server's exporter at it).
100- Spec gotcha for fixtures: give operations explicit `tags` — tool addresses127- Spec gotcha for fixtures: give operations explicit `tags` — tool addresses
101 are `group.leaf`, and an untagged op derives its group from the URL path,128 are `group.leaf`, and an untagged op derives its group from the URL path,
102 so `/fail` does NOT produce a `.fail`-suffixed address.129 so `/fail` does NOT produce a `.fail`-suffixed address.
@@ -106,14 +133,20 @@ expect(span.span.tags["executor.tool.outcome"]).toBe("fail"); · 3 unmodified lines
106 133
107```sh134```sh
108cd e2e135cd e2e
109bun run test # boots both dev servers, runs everything136bun run test # boots both dev servers, runs cloud + selfhost
110bun run test:cloud # one target137bun run test:cloud # one target (also: test:selfhost, test:selfhost-docker,
138 # test:cloudflare, test:local, test:desktop, test:watch)
111bun run ports # print THIS checkout's derived ports139bun run ports # print THIS checkout's derived ports
140bun run summary # pass/fail digest per target from runs/
112# attach to an already-running server while iterating (use `bun run ports` URLs):141# attach to an already-running server while iterating (use `bun run ports` URLs):
113E2E_CLOUD_URL=http://127.0.0.1:<port> ../node_modules/.bin/vitest run --project cloud <file>142E2E_CLOUD_URL=http://127.0.0.1:<port> ../node_modules/.bin/vitest run --project cloud <file>
114E2E_SELFHOST_URL=http://localhost:<port> ../node_modules/.bin/vitest run --project selfhost <file>143E2E_SELFHOST_URL=http://localhost:<port> ../node_modules/.bin/vitest run --project selfhost <file>
115```144```
116 145
146For interactive work against a live instance (boot, mint identities, typed API
147calls, MCP calls, emulator ledger) use the dev CLI: `bun run cli` — full
148command list in [RUNNING.md](../RUNNING.md).
149
117Ports are claimed at boot (see `src/ports.ts`): each checkout hashes its repo150Ports are claimed at boot (see `src/ports.ts`): each checkout hashes its repo
118root to a preferred block, atomically locks it (a held lock port makes races151root to a preferred block, atomically locks it (a held lock port makes races
119impossible), and walks to the next free block if it's locked or squatted — so152impossible), and walks to the next free block if it's locked or squatted — so
@@ -124,7 +157,10 @@ if a suite moved. `E2E_*_PORT` env vars pin ports explicitly (no probing) and · 4 unmodified lines
124 157
125Each run writes `runs/<target>/<slug>/result.json` plus any browser artifacts158Each run writes `runs/<target>/<slug>/result.json` plus any browser artifacts
126(trace.zip / session.mp4 / screenshots). `bun run serve` hosts the scenario ×159(trace.zip / session.mp4 / screenshots). `bun run serve` hosts the scenario ×
127target matrix; a run page links the trace into Playwright's trace viewer.160target matrix; a run page links the trace into Playwright's trace viewer. The
161viewer itself is a Vite/React SPA in `viewer/` (rebuilt into `runs/` by
162`bun run viewer:build`); `bun e2e/scripts/pr-media.ts runs/<target>/<slug>`
163turns a run's recording into PR-ready markdown.
128 164
129When handing results to the user, follow the evidence contract in the root165When handing results to the user, follow the evidence contract in the root
130[AGENTS.md](../AGENTS.md) (direct run links + a live instance + what to try);166[AGENTS.md](../AGENTS.md) (direct run links + a live instance + what to try);
@@ -149,6 +185,18 @@ project + globalsetup per guest OS. · 18 unmodified lines
149 ```sh185 ```sh
150 vitest run --project desktop-macos # or desktop-linux186 vitest run --project desktop-macos # or desktop-linux
151 ```187 ```
188- **`desktop-windows`** — same scenario, but ATTACHES to a long-lived dockur
189 Windows guest over an SSH jump instead of provisioning one (no bundle build).
190
191There are also **`cli-macos` / `cli-linux` / `cli-windows`** projects (the
192supervised CLI daemon inside a guest VM, `cli/*.test.ts` +
193`scenarios/restart-persistence.test.ts`): the globalsetup provisions the VM
194and `executor service install`s the daemon; `restart()` reboots the guest for
195real, proving the boot-time auto-start path. tart for macOS/Linux, EC2 for
196Windows.
197
198macOS-guest gotchas (VNC login first, single-instance lock vs a host
199Executor.app, guest log paths): see [notes/testing-on-mac.md](notes/testing-on-mac.md).
152 200
153The guests run tart `--no-graphics` (no host window, never steals focus) but201The guests run tart `--no-graphics` (no host window, never steals focus) but
154still have a usable display:202still have a usable display:
e2e/scripts/cli.ts+52−9
@@ -67,6 +67,25 @@ const alive = (pid: number): boolean => { · 66 unmodified lines
67 }67 }
68};68};
69 69
70const isInstanceState = (value: unknown): value is InstanceState => {
71 if (!value || typeof value !== "object") return false;
72 const v = value as Record<string, unknown>;
73 return (
74 typeof v.target === "string" &&
75 typeof v.runnerPid === "number" &&
76 typeof v.startedAt === "string"
77 );
78};
79
80const appResponds = async (url: string): Promise<boolean> => {
81 try {
82 await fetch(url, { signal: AbortSignal.timeout(3000) });
83 return true;
84 } catch {
85 return false;
86 }
87};
88
70// --- tailnet helpers -------------------------------------------------------89// --- tailnet helpers -------------------------------------------------------
71 90
72const TAILSCALE_CANDIDATES = [91const TAILSCALE_CANDIDATES = [
@@ -445,18 +464,42 @@ const ledger = async (targetName: string, service = "workos") => { · 372 unmodified lines
445 464
446// --- lifecycle commands ----------------------------------------------------465// --- lifecycle commands ----------------------------------------------------
447 466
448const status = () => {467const status = async () => {
449 if (!existsSync(devDir)) return console.log("no instances");468 if (!existsSync(devDir)) return console.log("no instances");
450 const states = readdirSync(devDir)469 const states: InstanceState[] = [];
451 .filter((f) => f.endsWith(".json"))470 for (const f of readdirSync(devDir)) {
452 .map((f) => JSON.parse(readFileSync(join(devDir, f), "utf8")) as InstanceState);471 if (!f.endsWith(".json")) continue;
472 try {
473 const parsed: unknown = JSON.parse(readFileSync(join(devDir, f), "utf8"));
474 if (isInstanceState(parsed)) states.push(parsed);
475 } catch {
476 // skip unparseable debris
477 }
478 }
453 if (states.length === 0) return console.log("no instances");479 if (states.length === 0) return console.log("no instances");
454 for (const state of states) {480 for (const state of states) {
455 const live = alive(state.runnerPid);481 const live = alive(state.runnerPid);
456 console.log(482 let label: string;
457 `${state.target}: ${live ? state.status : "DEAD (stale state file)"} — runner ${state.runnerPid}, since ${state.startedAt}`,483 if (!live) {
458 );484 label = "DEAD (stale state file)";
459 if (live && state.status === "ready") printInstance(state);485 } else if (state.status === "ready") {
486 const appUrl = state.urls?.app;
487 if (appUrl && !(await appResponds(appUrl))) {
488 label = "UNRESPONSIVE (runner alive but app not answering)";
489 } else {
490 label = state.status;
491 }
492 } else {
493 label = state.status;
494 }
495 console.log(`${state.target}: ${label} — runner ${state.runnerPid}, since ${state.startedAt}`);
496 if (live && state.status === "ready") {
497 if (label === "UNRESPONSIVE (runner alive but app not answering)") {
498 console.log(` log ${state.logFile}`);
499 } else {
500 printInstance(state);
501 }
502 }
460 }503 }
461};504};
462 505
@@ -525,7 +568,7 @@ const main = async () => { · 62 unmodified lines
525 case "__run":568 case "__run":
526 return run(args[0] as "selfhost" | "cloud", flags);569 return run(args[0] as "selfhost" | "cloud", flags);
527 case "status":570 case "status":
528 return status();571 return await status();
529 case "identity":572 case "identity":
530 return identity(args[0] ?? "", flags);573 return identity(args[0] ?? "", flags);
531 case "api":574 case "api":