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
7474
7575 - run: bun run test
7676
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+
77122 e2e-local:
78123 name: E2E (stdio MCP)
79124 # Skipped on pull_request: the local scenario boots a real `executor web`
80125 # plus a browser and is currently flaky on PRs. Still runs on push to main.
81126 if: github.event_name != 'pull_request'
82127 runs-on: ubuntu-latest
128+ timeout-minutes: 20
83129 steps:
84130 - uses: actions/checkout@v4
85131
e2e/AGENTS.md+65−17
@@ -14,9 +14,18 @@ produce a Playwright trace, video, and step screenshots for debugging. · 13 unmodified lines
1414
1515 ## File placement
1616
17−- `scenarios/*.test.ts` — runs on every target (cloud + selfhost)
18−- `cloud/*.test.ts` — cloud-only (e.g. billing, WorkOS-session UI)
19−- `selfhost/*.test.ts` — selfhost-only
17+Scenario directories map to vitest projects (`vitest.config.ts` is the
18+authoritative list of targets and what each one includes):
19+
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
2029
2130 ## Anatomy
2231
@@ -25,32 +34,47 @@ import { expect } from "@effect/vitest"; · 2 unmodified lines
2534 import { Effect } from "effect";
2635 import { composePluginApi } from "@executor-js/api/server";
2736 import { scenario } from "../src/scenario";
37+import { Api, Target } from "../src/services";
2838
2939 const coreApi = composePluginApi([] as const); // tools/integrations/connections/providers/executions/oauth/policies
3040
31−scenario("Tools · a fresh workspace advertises the built-in tools", { needs: ["api"] }, (ctx) =>
41+scenario(
42+ "Tools · a fresh workspace advertises the built-in tools",
43+ {}, // options: { timeout?: number }
3244 Effect.gen(function* () {
33− const identity = yield* ctx.target.newIdentity(); // fresh isolated user+org
34− const client = yield* ctx.api.client(coreApi, identity); // typed HttpApiClient
35− const tools = yield* client.tools.list();
45+ const target = yield* Target;
46+ const { client } = yield* Api;
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: {} });
3650 expect(tools.length, "at least one tool is exposed").toBeGreaterThan(0);
3751 }),
3852 );
3953 ```
4054
41−- Capabilities (`needs`): `api`, `browser` (cloud only today), `mcp-oauth`
42− (selfhost only today), `billing` (cloud only).
55+- A scenario declares what it needs by **yielding services** from
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).
4365 - Resources created in a test must be cleaned up with `Effect.ensuring` (a
4466 finalizer), not trailing statements — a mid-test failure must not leak state
4567 into the shared instance.
4668
47−## Browser scenarios (cloud)
69+## Browser scenarios
4870
4971 ```ts
50−const identity = yield * ctx.target.newIdentity(); // logged in, has an org
72+const target = yield * Target;
73+const browser = yield * Browser;
74+const identity = yield * target.newIdentity(); // logged in, has an org
5175 // or newIdentity({ org: false }) for the onboarding flow
5276 yield *
53− ctx.browser.session(identity, async ({ page, step }) => {
77+ browser.session(identity, async ({ page, step }) => {
5478 await step("A fresh user lands on the integrations page", async () => {
5579 await page.goto("/", { waitUntil: "networkidle" });
5680 await page.getByText("Integrations").first().waitFor();
@@ -68,10 +92,11 @@ yield * · 11 unmodified lines
6892 opening menus: `await page.waitForLoadState("networkidle")`.
6993 - The stub user renders as "Test User" / `test@example.com`.
7094
71−## MCP scenarios (selfhost)
95+## MCP scenarios
7296
7397 ```ts
74−const session = ctx.mcp.session(identity);
98+const mcp = yield * Mcp;
99+const session = mcp.session(identity);
75100 const tools = yield * session.listTools(); // OAuth happens headlessly here
76101 const r = yield * session.call("execute", { code: "return 1 + 1;" });
77102 // 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
97122
98123 - `expectSpan` polls (~20s): exporters batch, so arrival is
99124 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).
100127 - Spec gotcha for fixtures: give operations explicit `tags` — tool addresses
101128 are `group.leaf`, and an untagged op derives its group from the URL path,
102129 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
106133
107134 ```sh
108135 cd e2e
109−bun run test # boots both dev servers, runs everything
110−bun run test:cloud # one target
136+bun run test # boots both dev servers, runs cloud + selfhost
137+bun run test:cloud # one target (also: test:selfhost, test:selfhost-docker,
138+ # test:cloudflare, test:local, test:desktop, test:watch)
111139 bun run ports # print THIS checkout's derived ports
140+bun run summary # pass/fail digest per target from runs/
112141 # attach to an already-running server while iterating (use `bun run ports` URLs):
113142 E2E_CLOUD_URL=http://127.0.0.1:<port> ../node_modules/.bin/vitest run --project cloud <file>
114143 E2E_SELFHOST_URL=http://localhost:<port> ../node_modules/.bin/vitest run --project selfhost <file>
115144 ```
116145
146+For interactive work against a live instance (boot, mint identities, typed API
147+calls, MCP calls, emulator ledger) use the dev CLI: `bun run cli` — full
148+command list in [RUNNING.md](../RUNNING.md).
149+
117150 Ports are claimed at boot (see `src/ports.ts`): each checkout hashes its repo
118151 root to a preferred block, atomically locks it (a held lock port makes races
119152 impossible), 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
124157
125158 Each run writes `runs/<target>/<slug>/result.json` plus any browser artifacts
126159 (trace.zip / session.mp4 / screenshots). `bun run serve` hosts the scenario ×
127−target matrix; a run page links the trace into Playwright's trace viewer.
160+target matrix; a run page links the trace into Playwright's trace viewer. The
161+viewer 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>`
163+turns a run's recording into PR-ready markdown.
128164
129165 When handing results to the user, follow the evidence contract in the root
130166 [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
149185 ```sh
150186 vitest run --project desktop-macos # or desktop-linux
151187 ```
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+
191+There are also **`cli-macos` / `cli-linux` / `cli-windows`** projects (the
192+supervised CLI daemon inside a guest VM, `cli/*.test.ts` +
193+`scenarios/restart-persistence.test.ts`): the globalsetup provisions the VM
194+and `executor service install`s the daemon; `restart()` reboots the guest for
195+real, proving the boot-time auto-start path. tart for macOS/Linux, EC2 for
196+Windows.
197+
198+macOS-guest gotchas (VNC login first, single-instance lock vs a host
199+Executor.app, guest log paths): see [notes/testing-on-mac.md](notes/testing-on-mac.md).
152200
153201 The guests run tart `--no-graphics` (no host window, never steals focus) but
154202 still have a usable display:
e2e/scripts/cli.ts+52−9
@@ -67,6 +67,25 @@ const alive = (pid: number): boolean => { · 66 unmodified lines
6767 }
6868 };
6969
70+const 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+
80+const 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+
7089 // --- tailnet helpers -------------------------------------------------------
7190
7291 const TAILSCALE_CANDIDATES = [
@@ -445,18 +464,42 @@ const ledger = async (targetName: string, service = "workos") => { · 372 unmodified lines
445464
446465 // --- lifecycle commands ----------------------------------------------------
447466
448−const status = () => {
467+const status = async () => {
449468 if (!existsSync(devDir)) return console.log("no instances");
450− const states = readdirSync(devDir)
451− .filter((f) => f.endsWith(".json"))
452− .map((f) => JSON.parse(readFileSync(join(devDir, f), "utf8")) as InstanceState);
469+ const states: InstanceState[] = [];
470+ for (const f of readdirSync(devDir)) {
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+ }
453479 if (states.length === 0) return console.log("no instances");
454480 for (const state of states) {
455481 const live = alive(state.runnerPid);
456− console.log(
457− `${state.target}: ${live ? state.status : "DEAD (stale state file)"} — runner ${state.runnerPid}, since ${state.startedAt}`,
458− );
459− if (live && state.status === "ready") printInstance(state);
482+ let label: string;
483+ if (!live) {
484+ label = "DEAD (stale state file)";
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+ }
460503 }
461504 };
462505
@@ -525,7 +568,7 @@ const main = async () => { · 62 unmodified lines
525568 case "__run":
526569 return run(args[0] as "selfhost" | "cloud", flags);
527570 case "status":
528− return status();
571+ return await status();
529572 case "identity":
530573 return identity(args[0] ?? "", flags);
531574 case "api":