History

Make spec-detected auth methods immutable in the add flow (#1188)

* e2e: browse/promote authoring loop and cross-OS packaged-desktop targets Two additions to the e2e setup sharing one idea: the interfaces you develop the product with are the same ones the generated tests drive, and every run is watchable. Authoring loop (src/journey/, scripts/cli.ts): - `bun run cli browse <target> <step>` drives the live web UI one step at a time (real logged-in Chromium), each step replaying the whole flow and printing the page's controls plus a screenshot. Steps span the browser, a terminal (`run`), and HTTP (`request`), interleaved in one session. - `bun run cli promote <target> "<name>"` turns the recorded journey into a committed scenario() and runs it. One Step DSL is the source of truth for both live execution and codegen, so the generated test drives the same surfaces the exploration drove and cannot quietly diverge. Cross-OS packaged desktop (setup/desktop-*, desktop-vm/, src/vm/desktop.ts): - desktop-macos / desktop-linux / desktop-windows run the real electron-builder bundle inside a guest, drive it over a CDP tunnel, and film the console into runs/<target>/ alongside test.ts and step screenshots. One shared scenario and driver; only launch and capture differ per OS: macOS: autologin Aqua session, launchctl asuser, screencapture linux: Xvfb + openbox, xdotool window resize, ffmpeg x11grab windows: dockur (QEMU) interactive session, QEMU screendump - macOS and Linux auto-provision a tart guest and build the bundle locally (the executor binary cross-compiles via BUN_TARGET); Windows attaches to a dockur host configured through E2E_DESKTOP_WIN_* env. The desktop targets are not in the default test chain and skip honestly without a guest. Also: - Force tart SSH to password-only (PubkeyAuthentication=no, IdentitiesOnly=yes) so a loaded SSH agent does not exhaust the guest's MaxAuthTries, an intermittent failure the existing cli-{os} lanes also hit. - build-sidecar keys the executable-bit chmod on the build target, not the host, so a windows-target cross-build no longer fails looking for a unix executor binary. * Make spec-detected auth methods immutable in the add flow A method seeded from spec/probe detection rendered the full None/API key/ OAuth selector and editable fields, so a user could silently switch a detected method (e.g. a Bearer-token API) to OAuth with empty endpoints and end up with a broken integration. Detected methods now render read-only in a disabled state: a lock icon, the auth kind named explicitly (OAuth / API key / No auth, since MCP seeds a detected method as just "Detected"), the declared config shown read-only, and "Pulled from spec. Remove to override." The only action is to remove the row and add a custom one; hand-added methods stay fully editable. This lives in the shared AuthMethodListEditor, so the OpenAPI and MCP add flows both get it. Detection is keyed off an explicit row flag rather than the seed slug, since MCP seeds a detected method without one. Also fix PlacementLine: its inline-flex container trimmed the whitespace at child edges, dropping the space after "Authorization:" and the trailing space in a "Bearer " prefix and rendering "Authorization:Bearer••••••". Render it as plain inline with preserved whitespace.

Rhys Sullivan committed 4 months ago

Commit da78dff1 parent 8109db119 files · +2021 −35Browse files

Unified · Split

19 files
  1. apps/desktop/scripts/build-sidecar.ts +5 −1
  2. e2e/AGENTS.md +74 −0
  3. e2e/desktop-vm/console-renders.test.ts +75 −0
  4. e2e/scripts/cli.ts +326 −0
  5. e2e/selfhost/detected-auth-immutable-ui.test.ts +185 −0
  6. e2e/setup/desktop-linux.globalsetup.ts +125 −0
  7. e2e/setup/desktop-macos.globalsetup.ts +108 −0
  8. e2e/setup/desktop-vm.ts +91 −0
  9. e2e/setup/desktop-windows.globalsetup.ts +106 −0
  10. e2e/src/journey/codegen.ts +78 −0
  11. e2e/src/journey/run.ts +104 −0
  12. e2e/src/journey/steps.ts +236 −0
  13. e2e/src/vm/desktop.ts +327 −0
  14. e2e/src/vm/tart.ts +17 −1
  15. e2e/targets/desktop.ts +4 −1
  16. e2e/targets/registry.ts +6 −0
  17. e2e/vitest.config.ts +20 −0
  18. packages/react/src/components/auth-method-list-editor.tsx +128 −31
  19. packages/react/src/lib/auth-placements.tsx +6 −1
apps/desktop/scripts/build-sidecar.ts+5−1
@@ -45,7 +45,11 @@ await rm(EXECUTOR_OUT_DIR, { recursive: true, force: true }); · 44 unmodified lines
45await mkdir(EXECUTOR_OUT_DIR, { recursive: true });45await mkdir(EXECUTOR_OUT_DIR, { recursive: true });
46await cp(sourceBinDir, EXECUTOR_OUT_DIR, { recursive: true });46await cp(sourceBinDir, EXECUTOR_OUT_DIR, { recursive: true });
47 47
48if (process.platform !== "win32") {48// Restore the unix executable bit — keyed on the TARGET, not the host. A
49// windows-target cross-build (BUN_TARGET=bun-windows-x64 on macOS/linux) stages
50// `executor.exe`, which needs no bit; chmod'ing a non-existent `executor` there
51// would ENOENT.
52if (!targetPackage.includes("windows")) {
49 await chmod(join(EXECUTOR_OUT_DIR, "executor"), 0o755);53 await chmod(join(EXECUTOR_OUT_DIR, "executor"), 0o755);
50}54}
51 55
e2e/AGENTS.md+74−0
@@ -130,6 +130,80 @@ When handing results to the user, follow the evidence contract in the root · 129 unmodified lines
130[AGENTS.md](../AGENTS.md) (direct run links + a live instance + what to try);130[AGENTS.md](../AGENTS.md) (direct run links + a live instance + what to try);
131[RUNNING.md](../RUNNING.md) has the current sharing/demo mechanics.131[RUNNING.md](../RUNNING.md) has the current sharing/demo mechanics.
132 132
133## Authoring from a live browser (`browse` → `promote`)
134
135You don't have to hand-write a browser scenario. Drive a running instance's web
136UI one step at a time, then turn the recorded journey into a committed scenario.
137The generated test drives the same Browser surface the exploration drove, so it
138is the real test, not a transcript of one — develop the flow, then crystallize
139it.
140
141```sh
142cd e2e
143bun run cli up cloud # a live instance to develop against
144bun run cli browse cloud goto / # each step REPLAYS the whole flow from a
145bun run cli browse cloud click link Policies # clean browser and prints the page's controls
146bun run cli browse cloud at-url /policies # (role · name) + a screenshot, so the next
147bun run cli browse cloud see "No policies yet" # step is written against what's actually there
148bun run cli promote cloud "Policies · a fresh workspace has none"
149```
150
151Each `browse` replays every step so far, so what you are building is, at every
152moment, exactly what `promote` emits — a step that doesn't reproduce fails here,
153not in CI. Steps: `goto <path>`, `click <role> <name>`, `click-text <text>`,
154`fill <field> <value>`, `press <key>`, and the assertions `see <text>` /
155`at-url <substring>`. `--label "…"` names a step (it becomes the `step(...)`
156group); `browse <target> show | undo | reset` manages the journey.
157
158`promote` writes `<target>/<slug>.gen.test.ts` and runs it against the live
159instance, producing the usual run artifacts (session.mp4, step screenshots,
160trace). A journey with no assertion is refused — a scenario must prove
161something. From then on the file is an ordinary scenario: edit it, add API/MCP
162checks, drop the `.gen` once it's yours. The journey itself lives in
163`.dev/<target>.journey.json` (gitignored), not the repo.
164
165## Desktop targets (the app on real OSes, filmed)
166
167The packaged desktop app runs as its own targets, each landing in its own
168`runs/<target>/` bucket with a video. One shared scenario (`desktop-vm/`) and the
169shared driver (`src/vm/desktop.ts`) + setup plumbing (`setup/desktop-vm.ts`); one
170project + globalsetup per guest OS.
171
172- **`desktop-packaged`** — the real electron-builder bundle on THIS machine's
173 display (the supervised-daemon attach path). Needs a logged-in GUI session.
174- **`desktop-macos` / `desktop-linux`** — the same bundle inside a guest VM,
175 driven over CDP from the host and filmed. The globalsetup boots the guest
176 (tart), builds + pushes the bundle, brings the app up with
177 `--remote-debugging-port`, forwards it, and the scenario connects + drives +
178 records. Provisioned automatically — or attach to a running guest with
179 `E2E_DESKTOP_VM_IP=<ip>`:
180
181 ```sh
182 vitest run --project desktop-macos # or desktop-linux
183 ```
184
185The guests run tart `--no-graphics` (no host window, never steals focus) but
186still have a usable display:
187
188- **macOS**: the base image's autologin reaches a real Aqua session
189 (WindowServer/Dock/Finder). Launch the app INTO it with `sudo launchctl asuser
190 <uid> …` (a plain SSH spawn lands in a non-GUI session); the unsigned arm64
191 bundle is ad-hoc `codesign`'d in the guest; `screencapture` films it.
192- **linux**: no window server, so the app renders into an `Xvfb` display with a
193 minimal WM (`openbox` — without it the electron window never maps); the window
194 maps tiny (10x10) so the globalsetup `xdotool`-resizes it to fill, and ffmpeg
195 `x11grab` films it. `--no-sandbox` (the chrome-sandbox needs setuid root).
196
197Base images (`admin`/`admin`): `executor-macos-base` (cirruslabs sequoia, autologin)
198and `executor-linux-base` (cirruslabs ubuntu + Xvfb/ffmpeg/openbox/xdotool +
199electron runtime libs). The bundle's `executor` binary is cross-compiled for the
200guest (`BUN_TARGET`), and electron-builder's `dir` target assembles the unpacked
201app on macOS — so both bundles build on this Mac.
202
203Note: `desktop-packaged`'s `guiAvailable()` probe (`launchctl managername`) reads
204"Background" over SSH even when Aqua is up, so it's host-only; the VM targets gate
205on a CDP page target instead.
206
133## Discovering endpoints207## Discovering endpoints
134 208
135- The full OpenAPI spec: `curl http://127.0.0.1:<cloud port>/api/openapi.json`209- The full OpenAPI spec: `curl http://127.0.0.1:<cloud port>/api/openapi.json`
e2e/desktop-vm/console-renders.test.ts+75−0
@@ -0,0 +1,75 @@
1// The PACKAGED desktop app, on camera, inside a GUI guest — driven over CDP from
2// the host. ONE scenario shared by every desktop-<os> project (desktop-macos,
3// desktop-linux): the same bundle and CDP driver, proving it renders on a guest
4// OS and filming the actual console. The desktop-<os> globalsetup boots the
5// guest, launches the app, forwards its --remote-debugging-port (E2E_DESKTOP_CDP_PORT)
6// and publishes the guest IP; this scenario connects, drives, and records. The
7// run lands in runs/<target>/ (its own per-OS bucket). Without a guest it skips
8// honestly, like desktop-packaged without a display.
9import { writeFileSync } from "node:fs";
10import { join } from "node:path";
11
12import { expect, it } from "@effect/vitest";
13import { Effect } from "effect";
14
15import { scenario } from "../src/scenario";
16import { RunDir } from "../src/services";
17import { CdpPage, pageWsUrl, recordGuestScreen } from "../src/vm/desktop";
18
19const NAME = "Desktop (packaged, in a VM) · the bundle renders its console";
20const cdpPort = process.env.E2E_DESKTOP_CDP_PORT;
21const guestIp = process.env.E2E_DESKTOP_VM_IP;
22const recSeconds = Number(process.env.E2E_DESKTOP_REC_SECONDS ?? "12");
23const os: "macos" | "linux" | "windows" =
24 process.env.E2E_TARGET === "desktop-windows"
25 ? "windows"
26 : process.env.E2E_TARGET === "desktop-linux"
27 ? "linux"
28 : "macos";
29
30const run = async (runDir: string) => {
31 const cdp = await CdpPage.connect(await pageWsUrl(Number(cdpPort)));
32 try {
33 await cdp.command("Runtime.enable");
34 await cdp.command("Page.enable");
35
36 // Film the console while we drive it (OS-aware capture lands a playable mp4).
37 const recording = recordGuestScreen(
38 guestIp as string,
39 recSeconds,
40 join(runDir, "session.mp4"),
41 os,
42 );
43
44 // Reaching the nav proves the packaged bundle booted and connected to its
45 // daemon on this OS.
46 await cdp.waitForText("Integrations", 60_000).catch(() => cdp.waitForText("Settings", 60_000));
47 writeFileSync(join(runDir, "01-console-rendered.png"), await cdp.screenshot());
48
49 const body = await cdp.command<{ result?: { value?: string } }>("Runtime.evaluate", {
50 expression: "document.body.innerText",
51 returnByValue: true,
52 });
53 expect(body.result?.value ?? "", "the packaged console rendered its nav").toContain(
54 "Integrations",
55 );
56
57 await recording;
58 } finally {
59 cdp.close();
60 }
61};
62
63if (!cdpPort || !guestIp) {
64 it.skip(`${NAME} (needs a desktop guest — set E2E_DESKTOP_VM_IP or run the desktop-<os> project)`, () => {});
65} else {
66 // Literal name (not NAME) so the run's test.ts review artifact captures it.
67 scenario(
68 "Desktop (packaged, in a VM) · the bundle renders its console",
69 { timeout: 180_000 },
70 Effect.gen(function* () {
71 const runDir = yield* RunDir;
72 yield* Effect.promise(() => run(runDir));
73 }),
74 );
75}
e2e/scripts/cli.ts+326−0
@@ -7,6 +7,8 @@ · 6 unmodified lines
7// bun scripts/cli.ts api <target> <group.endpoint> [json]7// bun scripts/cli.ts api <target> <group.endpoint> [json]
8// bun scripts/cli.ts mcp <target> tools | call <tool> [json]8// bun scripts/cli.ts mcp <target> tools | call <tool> [json]
9// bun scripts/cli.ts ledger <target> [workos|autumn]9// bun scripts/cli.ts ledger <target> [workos|autumn]
10// bun scripts/cli.ts browse <target> <step…> (goto/click/see/…; show|undo|reset)
11// bun scripts/cli.ts promote <target> "<scenario name>"
10// bun scripts/cli.ts logs <target>12// bun scripts/cli.ts logs <target>
11// bun scripts/cli.ts down <target>13// bun scripts/cli.ts down <target>
12//14//
@@ -25,6 +27,9 @@ import { networkInterfaces } from "node:os"; · 12 unmodified lines
25import { join } from "node:path";27import { join } from "node:path";
26import { fileURLToPath } from "node:url";28import { fileURLToPath } from "node:url";
27 29
30import type { JourneyFile } from "../src/journey/codegen";
31import type { Role, Step } from "../src/journey/steps";
32
28const e2eDir = fileURLToPath(new URL("..", import.meta.url));33const e2eDir = fileURLToPath(new URL("..", import.meta.url));
29const devDir = join(e2eDir, ".dev");34const devDir = join(e2eDir, ".dev");
30const cliPath = fileURLToPath(import.meta.url);35const cliPath = fileURLToPath(import.meta.url);
@@ -443,6 +448,308 @@ const ledger = async (targetName: string, service = "workos") => { · 412 unmodified lines
443 console.log(JSON.stringify(entries, null, 2));448 console.log(JSON.stringify(entries, null, 2));
444};449};
445 450
451// --- browser journeys ------------------------------------------------------
452// Drive a live instance's web UI step by step. Each step appends to a journey
453// file and REPLAYS the whole flow from a clean browser, so what you build is, at
454// every moment, exactly what `promote` emits as a scenario — develop the flow,
455// then crystallize it (e2e/AGENTS.md) with no translation gap. The agent can't
456// see the screen, so every step returns the page's interactive controls (the
457// vocabulary the next step is written against) plus a screenshot for a human.
458
459const journeyPaths = (target: string) => ({
460 file: join(devDir, `${target}.journey.json`),
461 shotsDir: join(devDir, `${target}.journey`),
462});
463
464const readJourney = (target: string): JourneyFile => {
465 try {
466 return JSON.parse(readFileSync(journeyPaths(target).file, "utf8")) as JourneyFile;
467 } catch {
468 return { target, org: true, steps: [] };
469 }
470};
471
472const writeJourney = (journey: JourneyFile) => {
473 mkdirSync(devDir, { recursive: true });
474 writeFileSync(journeyPaths(journey.target).file, JSON.stringify(journey, null, 1));
475};
476
477const STEP_ROLES = new Set<Role>([
478 "link",
479 "button",
480 "heading",
481 "textbox",
482 "tab",
483 "menuitem",
484 "checkbox",
485]);
486
487/** One CLI step verb → a Step. The verbs are deliberately plain English so the
488 * journey reads like instructions: goto / click / click-text / fill / press /
489 * see / at-url / run / request. */
490const parseStep = (
491 verb: string,
492 args: ReadonlyArray<string>,
493 opts: { readonly label?: string; readonly contains?: string },
494): Step => {
495 const withLabel = opts.label ? { label: opts.label } : {};
496 const withContains = opts.contains !== undefined ? { contains: opts.contains } : {};
497 switch (verb) {
498 case "goto": {
499 const path = args[0];
500 if (!path) throw new Error("usage: browse <target> goto <path> (e.g. goto /)");
501 return { kind: "goto", path, ...withLabel };
502 }
503 case "click": {
504 const role = args[0];
505 const name = args.slice(1).join(" ");
506 if (!role || !name) {
507 throw new Error(
508 "usage: browse <target> click <role> <name> (role: " + [...STEP_ROLES].join("|") + ")",
509 );
510 }
511 if (!STEP_ROLES.has(role as Role)) throw new Error(`unknown role ${JSON.stringify(role)}`);
512 return { kind: "clickRole", role: role as Role, name, ...withLabel };
513 }
514 case "click-text": {
515 const text = args.join(" ");
516 if (!text) throw new Error("usage: browse <target> click-text <text>");
517 return { kind: "clickText", text, ...withLabel };
518 }
519 case "fill": {
520 const field = args[0];
521 const value = args.slice(1).join(" ");
522 if (!field || args.length < 2) throw new Error("usage: browse <target> fill <field> <value>");
523 return { kind: "fill", field, value, ...withLabel };
524 }
525 case "press": {
526 const key = args[0];
527 if (!key) throw new Error("usage: browse <target> press <key> (e.g. press Enter)");
528 return { kind: "press", key, ...withLabel };
529 }
530 case "see": {
531 const text = args.join(" ");
532 if (!text)
533 throw new Error("usage: browse <target> see <text> (asserts the text is visible)");
534 return { kind: "expectText", text, ...withLabel };
535 }
536 case "at-url": {
537 const contains = args[0];
538 if (!contains) throw new Error("usage: browse <target> at-url <substring>");
539 return { kind: "expectUrl", contains, ...withLabel };
540 }
541 case "run": {
542 const command = args.join(" ");
543 if (!command) {
544 throw new Error(
545 'usage: browse <target> run "<command>" [--contains <text>] ({base} = the instance URL)',
546 );
547 }
548 return { kind: "run", command, ...withContains, ...withLabel };
549 }
550 case "request": {
551 const method = (args[0] ?? "").toUpperCase();
552 const path = args[1];
553 if (!method || !path) {
554 throw new Error("usage: browse <target> request <METHOD> <path> [--contains <text>]");
555 }
556 return { kind: "request", method, path, ...withContains, ...withLabel };
557 }
558 default:
559 throw new Error(
560 `unknown step ${JSON.stringify(verb)} — goto | click | click-text | fill | press | see | at-url | run | request`,
561 );
562 }
563};
564
565const printJourney = (journey: JourneyFile) => {
566 if (journey.steps.length === 0) return console.log(`${journey.target}: empty journey`);
567 console.log(`${journey.target} journey (${journey.steps.length} steps, org=${journey.org}):`);
568 journey.steps.forEach((step, index) => {
569 console.log(` ${String(index + 1).padStart(2)}. ${stepLabelOf(step)}`);
570 });
571};
572
573// A local copy of the label default so printing doesn't pull in the browser
574// module (which imports playwright). Kept trivially in sync with steps.ts.
575const stepLabelOf = (step: Step): string => {
576 if (step.label) return step.label;
577 if (step.kind === "goto") return `goto ${step.path}`;
578 if (step.kind === "clickRole") return `click ${step.role} ${JSON.stringify(step.name)}`;
579 if (step.kind === "clickText") return `click-text ${JSON.stringify(step.text)}`;
580 if (step.kind === "fill") return `fill ${JSON.stringify(step.field)}`;
581 if (step.kind === "press") return `press ${step.key}`;
582 if (step.kind === "expectText") return `see ${JSON.stringify(step.text)}`;
583 if (step.kind === "expectUrl") return `at-url ${JSON.stringify(step.contains)}`;
584 if (step.kind === "run") {
585 return `run ${JSON.stringify(step.command)}${step.contains ? ` → ${JSON.stringify(step.contains)}` : ""}`;
586 }
587 return `request ${step.method} ${step.path}${step.contains ? ` → ${JSON.stringify(step.contains)}` : ""}`;
588};
589
590const browse = async (raw: ReadonlyArray<string>) => {
591 const target = raw[0];
592 if (!target) throw new Error("usage: browse <target> <step…> (or show | undo | reset)");
593
594 // Re-parse tokens here: --label takes a value, which the top-level flag/arg
595 // split would mangle.
596 let label: string | undefined;
597 let contains: string | undefined;
598 const positional: string[] = [];
599 const bools = new Set<string>();
600 const tokens = raw.slice(1);
601 for (let i = 0; i < tokens.length; i++) {
602 const token = tokens[i]!;
603 if (token === "--label") {
604 label = tokens[++i];
605 continue;
606 }
607 if (token.startsWith("--label=")) {
608 label = token.slice("--label=".length);
609 continue;
610 }
611 if (token === "--contains") {
612 contains = tokens[++i];
613 continue;
614 }
615 if (token.startsWith("--contains=")) {
616 contains = token.slice("--contains=".length);
617 continue;
618 }
619 if (token.startsWith("--")) {
620 bools.add(token);
621 continue;
622 }
623 positional.push(token);
624 }
625
626 const verb = positional[0];
627 const { file, shotsDir } = journeyPaths(target);
628 let journey = readJourney(target);
629
630 if (verb === "show") return printJourney(journey);
631 if (verb === "reset") {
632 rmSync(file, { force: true });
633 rmSync(shotsDir, { recursive: true, force: true });
634 return console.log(`${target}: journey reset`);
635 }
636 if (!verb) throw new Error("usage: browse <target> <step…> (or show | undo | reset)");
637
638 // The flow runs as ONE minted identity per replay — same as the generated
639 // test mints one per run, so a stateful journey behaves identically.
640 const { target: resolved } = await loadTarget(target);
641 const identity = await runEffect<import("../src/target").Identity>(
642 resolved.newIdentity(journey.org ? undefined : { org: false }),
643 );
644 const { replayJourney } = await import("../src/journey/run");
645
646 let steps = [...journey.steps];
647 if (verb === "undo") {
648 if (steps.length === 0) throw new Error("nothing to undo");
649 steps = steps.slice(0, -1);
650 } else {
651 if (steps.length === 0 && bools.has("--no-org")) journey = { ...journey, org: false };
652 steps = [...steps, parseStep(verb, positional.slice(1), { label, contains })];
653 }
654
655 mkdirSync(shotsDir, { recursive: true });
656 const shot = join(shotsDir, `${String(steps.length).padStart(2, "0")}.png`);
657 const observation = await replayJourney(resolved, identity, steps, { screenshotPath: shot });
658
659 if (observation.failedStep) {
660 const failed = observation.failedStep;
661 const isNew = verb !== "undo" && failed.index === steps.length - 1;
662 console.log(
663 `✗ ${isNew ? "this step" : `step ${failed.index + 1} (${stepLabelOf(steps[failed.index]!)})`} failed — journey unchanged.`,
664 );
665 console.log(` ${failed.error.split("\n")[0]}`);
666 console.log(` screenshot: ${observation.screenshotPath}`);
667 return;
668 }
669
670 journey = { ...journey, steps };
671 writeJourney(journey);
672
673 console.log(`→ ${observation.url} ${JSON.stringify(observation.title)}`);
674 console.log(` screenshot: ${observation.screenshotPath}`);
675 if (observation.lastOutput) {
676 const lines = observation.lastOutput.split("\n").slice(0, 12);
677 console.log("output:");
678 for (const line of lines) console.log(` ${line}`);
679 }
680 printJourney(journey);
681 if (observation.controls.length > 0) {
682 console.log("controls on the page (role · name):");
683 for (const control of observation.controls.slice(0, 40)) {
684 console.log(` ${control.role.padEnd(9)} ${control.name}`);
685 }
686 if (observation.controls.length > 40) {
687 console.log(` … ${observation.controls.length - 40} more`);
688 }
689 }
690};
691
692const promote = async (raw: ReadonlyArray<string>) => {
693 const target = raw[0];
694 const positional = raw.slice(1).filter((arg) => !arg.startsWith("--"));
695 const name = positional[0];
696 const noRun = raw.includes("--no-run");
697 if (!target || !name) {
698 throw new Error('usage: promote <target> "<scenario name>" [--no-run]');
699 }
700 const journey = readJourney(target);
701 if (journey.steps.length === 0) {
702 throw new Error(`no journey for ${target} — build one with \`browse ${target} …\` first`);
703 }
704 const { codegenScenario, journeyHasAssertion, journeyHasBrowserStep } =
705 await import("../src/journey/codegen");
706 if (!journeyHasBrowserStep(journey)) {
707 throw new Error(
708 "this journey is all terminal/HTTP steps — `promote` generates browser-anchored scenarios. Add a browser step, or write a CLI/API test directly.",
709 );
710 }
711 if (!journeyHasAssertion(journey)) {
712 throw new Error(
713 "this journey has no assertion (a `see`, `at-url`, `request`, or `run --contains` step), so the scenario would prove nothing. Add one, then promote.",
714 );
715 }
716
717 const source = codegenScenario(name, journey);
718 const slug = name
719 .toLowerCase()
720 .replace(/[^a-z0-9]+/g, "-")
721 .replace(/^-+|-+$/g, "")
722 .slice(0, 80);
723 // Scope the file to the target's own dir (cloud/, selfhost/) so it runs only
724 // where its UI selectors apply, not on every cross-target host.
725 const relFile = join(target, `${slug}.gen.test.ts`);
726 writeFileSync(join(e2eDir, relFile), source);
727 console.log(`wrote ${relFile} (${journey.steps.length} steps from the ${target} journey)`);
728
729 const urlKey = `E2E_${target.toUpperCase()}_URL`;
730 const url = readState(target)?.env?.[urlKey];
731 if (noRun || !url) {
732 console.log(
733 `run it against the live instance:\n ${urlKey}=<url> bunx vitest run --project ${target} ${relFile}`,
734 );
735 return;
736 }
737
738 console.log(`running it against the live instance (${url}) …`);
739 const proc = spawn("bunx", ["vitest", "run", "--project", target, relFile], {
740 cwd: e2eDir,
741 stdio: "inherit",
742 env: { ...process.env, [urlKey]: url },
743 });
744 const code: number = await new Promise((resolve) => proc.on("exit", (c) => resolve(c ?? 1)));
745 const slugDir = join("runs", target, slug);
746 console.log(
747 code === 0
748 ? `\n✓ passed. Artifacts (test.ts, result.json, session.mp4/film.mp4): e2e/${slugDir}\n view: cd e2e && bun run serve → #/${target}/${slug}`
749 : `\n✗ the generated test failed (exit ${code}). The flow that passed live did not pass as a committed test — inspect ${relFile} and e2e/${slugDir}.`,
750 );
751};
752
446// --- lifecycle commands ----------------------------------------------------753// --- lifecycle commands ----------------------------------------------------
447 754
448const status = () => {755const status = () => {
@@ -508,9 +815,24 @@ const HELP = `e2e dev CLI — the scenario primitives, interactive (see e2e/AGEN · 59 unmodified lines
508 api <target> <group.endpoint> [json] typed API call as a fresh identity815 api <target> <group.endpoint> [json] typed API call as a fresh identity
509 mcp <target> tools | call <tool> [json] MCP session call816 mcp <target> tools | call <tool> [json] MCP session call
510 ledger <target> [workos|autumn] the emulator's request ledger (cloud)817 ledger <target> [workos|autumn] the emulator's request ledger (cloud)
818 browse <target> <step> drive the live web UI, one step at a time; each step
819 replays the whole flow and prints the page's controls
820 steps: goto <path> | click <role> <name> | click-text <text>
821 | fill <field> <value> | press <key>
822 | see <text> | at-url <substring>
823 | run "<cmd>" | request <METHOD> <path>
824 flags: --label "…", --no-org, --contains <text>
825 ({base} in a run command = the instance URL)
826 browse <target> show | undo | reset
827 promote <target> "<name>" turn the recorded journey into a committed scenario
828 (<target>/<slug>.gen.test.ts) and run it (--no-run)
511 logs <target> dump the instance's dev-server log829 logs <target> dump the instance's dev-server log
512 down <target> tear down (kills servers, removes tailscale serves)830 down <target> tear down (kills servers, removes tailscale serves)
513 831
832A browser journey IS the scenario: develop the flow with \`browse\`, then
833\`promote\` it. The generated test drives the same surface, so a reviewer judges
834it by reading the test and watching its video.
835
514Instances live in e2e/.dev/<target>.json — a state file marks a DELIBERATE836Instances live in e2e/.dev/<target>.json — a state file marks a DELIBERATE
515long-lived instance. Use the booted instance for e2e too:837long-lived instance. Use the booted instance for e2e too:
516 E2E_SELFHOST_URL=<app url> vitest run --project selfhost <file>`;838 E2E_SELFHOST_URL=<app url> vitest run --project selfhost <file>`;
@@ -534,6 +856,10 @@ const main = async () => { · 17 unmodified lines
534 return mcpCall(args[0] ?? "", args[1], args.slice(2));856 return mcpCall(args[0] ?? "", args[1], args.slice(2));
535 case "ledger":857 case "ledger":
536 return ledger(args[0] ?? "", args[1]);858 return ledger(args[0] ?? "", args[1]);
859 case "browse":
860 return browse(rest);
861 case "promote":
862 return promote(rest);
537 case "logs":863 case "logs":
538 return logs(args[0] ?? "");864 return logs(args[0] ?? "");
539 case "down":865 case "down":
e2e/selfhost/detected-auth-immutable-ui.test.ts+185−0
@@ -0,0 +1,185 @@
1// Selfhost-only (browser): a spec/probe-DETECTED auth method is immutable in
2// the add flow. The shared AuthMethodListEditor renders detected methods as a
3// disabled, read-only summary ("Pulled from spec. Remove to override.") with no
4// kind selector, so a user can't silently retype the spec's method into a kind
5// nothing backs. A method the user adds by hand stays fully editable. Both the
6// MCP and OpenAPI add flows compose the same editor, so one behavior, two
7// surfaces. Selfhost runs with EXECUTOR_ALLOW_LOCAL_NETWORK so the probe/analyze
8// can reach the loopback fixtures. Video is the artifact.
9import { randomBytes } from "node:crypto";
10import { createServer } from "node:http";
11
12import { expect } from "@effect/vitest";
13import { Effect } from "effect";
14import { makeGreetingMcpServer, serveMcpServerWithOAuth } from "@executor-js/plugin-mcp/testing";
15import { OAuthTestServer } from "@executor-js/sdk/testing";
16
17import { scenario } from "../src/scenario";
18import { Browser, Target } from "../src/services";
19
20const REMOVE_HINT = "Pulled from spec. Remove to override.";
21
22scenario(
23 "Detected auth · an MCP probe's OAuth method is immutable in the add flow",
24 {},
25 Effect.scoped(
26 Effect.gen(function* () {
27 const target = yield* Target;
28 const browser = yield* Browser;
29 // OAuth-protected server: the probe 401s with resource metadata, so the
30 // method list seeds a single detected OAuth row (discovered metadata).
31 const server = yield* serveMcpServerWithOAuth(
32 () => makeGreetingMcpServer({ name: `oauth-mcp-${randomBytes(3).toString("hex")}` }),
33 { path: "/mcp" },
34 );
35 const identity = yield* target.newIdentity();
36
37 yield* browser.session(identity, async ({ page, step }) => {
38 await step("Open the add-MCP flow pointed at the OAuth server", async () => {
39 await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, {
40 waitUntil: "networkidle",
41 });
42 await page.getByText("How does this server authenticate?").waitFor();
43 await page.getByText("Method 1 · Detected").waitFor();
44 });
45
46 await step("The detected method is locked: read-only, named, no selector", async () => {
47 // The kind is named explicitly ("OAuth"), the discovered-OAuth summary
48 // and override hint sit inside a disabled block, and there is NO
49 // editable kind selector (the FilterTabs render as buttons).
50 await page.getByText("OAuth", { exact: true }).first().waitFor();
51 await page.getByText("OAuth metadata is discovered from this server").waitFor();
52 await page.getByText(REMOVE_HINT).waitFor();
53 expect(
54 await page.locator("[aria-disabled]").count(),
55 "the detected method renders a disabled (non-interactive) block",
56 ).toBeGreaterThan(0);
57 expect(
58 await page.getByRole("button", { name: "API key", exact: true }).count(),
59 "no editable kind selector is shown for the detected method",
60 ).toBe(0);
61 });
62
63 await step("A hand-added method keeps the full editable selector", async () => {
64 await page.getByRole("button", { name: "Add method" }).click();
65 await page.getByText("Method 2").waitFor();
66 // The added row defaults to API key and exposes the None/API key/OAuth
67 // kind tabs (buttons) — the detected row above still shows none.
68 expect(
69 await page.getByRole("button", { name: "API key", exact: true }).count(),
70 "the added method exposes the kind selector",
71 ).toBeGreaterThan(0);
72 await page.getByText(REMOVE_HINT).waitFor();
73 });
74 });
75 }),
76 ).pipe(Effect.provide(OAuthTestServer.layer())),
77);
78
79/** A real 127.0.0.1 server that serves a static OpenAPI spec for the add flow. */
80const serveSpec = (body: string) =>
81 Effect.acquireRelease(
82 Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => {
83 const server = createServer((_request, response) => {
84 response.writeHead(200, { "content-type": "application/json" });
85 response.end(body);
86 });
87 server.listen(0, "127.0.0.1", () => {
88 const address = server.address();
89 const port = typeof address === "object" && address ? address.port : 0;
90 resume(
91 Effect.succeed({
92 url: `http://127.0.0.1:${port}/spec.json`,
93 close: () => {
94 server.close();
95 server.closeAllConnections();
96 },
97 }),
98 );
99 });
100 }),
101 (server) => Effect.sync(server.close),
102 );
103
104const apiKeyAndOAuthSpec = (): string =>
105 JSON.stringify({
106 openapi: "3.0.3",
107 info: { title: "Acme Immutable Auth Fixture", version: "1.0.0" },
108 servers: [{ url: "https://api.acme.test" }],
109 security: [{ bearerAuth: [] }, { acmeOAuth: ["read"] }],
110 components: {
111 securitySchemes: {
112 bearerAuth: { type: "http", scheme: "bearer" },
113 acmeOAuth: {
114 type: "oauth2",
115 flows: {
116 authorizationCode: {
117 authorizationUrl: "https://api.acme.test/oauth/authorize",
118 tokenUrl: "https://api.acme.test/oauth/token",
119 scopes: { read: "Read access" },
120 },
121 },
122 },
123 },
124 },
125 paths: {
126 "/widgets": {
127 get: {
128 operationId: "listWidgets",
129 summary: "List widgets",
130 responses: { "200": { description: "ok" } },
131 },
132 },
133 },
134 });
135
136scenario(
137 "Detected auth · OpenAPI spec-detected methods are immutable in the add flow",
138 {},
139 Effect.scoped(
140 Effect.gen(function* () {
141 const target = yield* Target;
142 const browser = yield* Browser;
143 const spec = yield* serveSpec(apiKeyAndOAuthSpec());
144 const identity = yield* target.newIdentity();
145
146 yield* browser.session(identity, async ({ page, step }) => {
147 await step("Analyze a spec that declares both API key and OAuth", async () => {
148 await page.goto(`/integrations/add/openapi`, { waitUntil: "networkidle" });
149 await page
150 .getByPlaceholder(/openapi\.json/i)
151 .first()
152 .fill(spec.url);
153 await page.getByText("How does this API authenticate?").waitFor();
154 await page.getByText("Method 2").waitFor();
155 });
156
157 await step("Both detected methods are locked, named, read-only", async () => {
158 // Two detected methods, each with the override hint and its kind named
159 // ("API key" / "OAuth"); the OAuth one shows the spec's real endpoints
160 // read-only. No editable kind selector (FilterTabs render as buttons).
161 expect(
162 await page.getByText(REMOVE_HINT).count(),
163 "both detected methods show the remove-to-override hint",
164 ).toBe(2);
165 await page.getByText("https://api.acme.test/oauth/authorize").waitFor();
166 await page.getByText("API key", { exact: true }).first().waitFor();
167 await page.getByText("OAuth", { exact: true }).first().waitFor();
168 expect(
169 await page.getByRole("button", { name: "OAuth", exact: true }).count(),
170 "no editable kind selector is shown for the detected methods",
171 ).toBe(0);
172 });
173
174 await step("A hand-added method keeps the full editable selector", async () => {
175 await page.getByRole("button", { name: "Add method" }).click();
176 await page.getByText("Method 3").waitFor();
177 expect(
178 await page.getByRole("button", { name: "API key", exact: true }).count(),
179 "the added method exposes the kind selector",
180 ).toBeGreaterThan(0);
181 });
182 });
183 }),
184 ),
185);
e2e/setup/desktop-linux.globalsetup.ts+125−0
@@ -0,0 +1,125 @@
1// desktop-linux: bring the PACKAGED app up inside a Linux guest and forward its
2// CDP port (the shared attach/forward lives in ./desktop-vm). No window server,
3// so the app renders into an Xvfb virtual display; ffmpeg x11grab (in the
4// scenario's recorder) films that display. Simpler than macOS: no Aqua, no
5// codesign, no launchctl — just background processes with DISPLAY set and
6// --no-sandbox (the chrome-sandbox needs setuid root, pointless on a throwaway
7// guest). The base image (executor-linux-base) carries Xvfb + ffmpeg + the
8// electron runtime libs.
9import { execFileSync } from "node:child_process";
10import { existsSync } from "node:fs";
11import { fileURLToPath } from "node:url";
12import { basename, join } from "node:path";
13
14import { pushDirAsTar } from "../src/vm/desktop";
15import { tartVm } from "../src/vm/tart";
16import {
17 attachOrProvision,
18 CDP_GUEST_PORT,
19 waitGuestHttp,
20 waitGuestPageTarget,
21 type ProvisionedGuest,
22} from "./desktop-vm";
23
24const DAEMON_PORT = 4789;
25const GUEST_DIR = "/home/admin/exe";
26const GUEST_HOME = "/home/admin/exe-home";
27const DISPLAY = ":99";
28
29const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url));
30const hostBundle = () => {
31 // electron-builder names the dir `linux-<arch>-unpacked` for non-x64.
32 const dir = join(appDir, "dist", "linux-arm64-unpacked");
33 return {
34 dir,
35 exe: join(dir, "executor-desktop"),
36 executor: join(dir, "resources/executor/executor"),
37 };
38};
39
40/** Build the packaged linux-arm64 bundle if it isn't on disk. The `executor`
41 * binary is cross-compiled here via BUN_TARGET (same as the cli-linux lane);
42 * electron-builder's `dir` target assembles the unpacked app on macOS without
43 * Docker. */
44const ensureBundle = (): void => {
45 if (existsSync(hostBundle().dir)) return;
46 const run = (cmd: string, args: string[], env: Record<string, string> = {}) =>
47 execFileSync(cmd, args, { cwd: appDir, stdio: "inherit", env: { ...process.env, ...env } });
48 run("bun", ["./scripts/build-sidecar.ts"], { BUN_TARGET: "bun-linux-arm64" });
49 run("bunx", ["--bun", "electron-vite", "build"]);
50 run(
51 "bunx",
52 [
53 "--bun",
54 "electron-builder",
55 "--config",
56 "electron-builder.e2e.config.ts",
57 "--linux",
58 "--arm64",
59 ],
60 { CSC_IDENTITY_AUTO_DISCOVERY: "false" },
61 );
62};
63
64const provisionLinux = async (): Promise<ProvisionedGuest> => {
65 ensureBundle();
66 const { dir } = hostBundle();
67 const vm = await tartVm("linux", "arm64").provision();
68 try {
69 await vm.ssh(`rm -rf ${GUEST_DIR} ${GUEST_HOME}; mkdir -p ${GUEST_HOME}/.executor`);
70 await pushDirAsTar(vm.host, dir, GUEST_DIR);
71
72 const guestApp = `${GUEST_DIR}/${basename(dir)}`;
73 const guestExe = `${guestApp}/executor-desktop`;
74 const guestExecutor = `${guestApp}/resources/executor/executor`;
75 await vm.ssh(`chmod +x '${guestExe}' '${guestExecutor}' 2>/dev/null || true`);
76 const env = `HOME=${GUEST_HOME} EXECUTOR_DATA_DIR=${GUEST_HOME}/.executor`;
77
78 // A virtual display + a minimal WM (openbox) — without a window manager the
79 // electron window doesn't map onto the framebuffer that x11grab records.
80 await vm.ssh(
81 `pkill Xvfb 2>/dev/null; pkill openbox 2>/dev/null; ` +
82 `nohup Xvfb ${DISPLAY} -screen 0 1280x800x24 >/tmp/xvfb.log 2>&1 & sleep 2; ` +
83 `DISPLAY=${DISPLAY} nohup openbox >/tmp/openbox.log 2>&1 & sleep 1; echo up`,
84 );
85
86 // 1) the bundled daemon, supervised — the app attaches to this.
87 await vm.ssh(
88 `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-linux-e2e EXECUTOR_CLIENT=desktop ` +
89 `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +
90 `>/tmp/executor-daemon.log 2>&1 &`,
91 );
92 if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) {
93 throw new Error(
94 "supervised daemon never came up in the guest (see /tmp/executor-daemon.log)",
95 );
96 }
97
98 // 2) the packaged app on the virtual display, with CDP enabled.
99 await vm.ssh(
100 `nohup env ${env} DISPLAY=${DISPLAY} '${guestExe}' --no-sandbox ` +
101 `--remote-debugging-port=${CDP_GUEST_PORT} --remote-allow-origins='*' ` +
102 `>/tmp/executor-app.log 2>&1 &`,
103 );
104 if (!(await waitGuestPageTarget(vm, CDP_GUEST_PORT))) {
105 const log = (await vm.ssh("tail -40 /tmp/executor-app.log 2>/dev/null").catch(() => null))
106 ?.stdout;
107 throw new Error(`the app's CDP page target never appeared:\n${log ?? "(no app log)"}`);
108 }
109
110 // The electron window maps tiny (10x10) under Xvfb; size it to the screen so
111 // the x11grab recording captures the full console (CDP screenshots the
112 // renderer surface regardless, but the film grabs the X framebuffer).
113 await vm.ssh(
114 `WID=$(DISPLAY=${DISPLAY} xdotool search --name executor-desktop | head -1); ` +
115 `[ -n "$WID" ] && DISPLAY=${DISPLAY} xdotool windowsize "$WID" 1280 800 windowmove "$WID" 0 0 || true`,
116 );
117
118 return { ip: vm.host, teardown: async () => void (await vm.discard()) };
119 } catch (error) {
120 await vm.discard();
121 throw error;
122 }
123};
124
125export default (): Promise<(() => Promise<void>) | void> => attachOrProvision(provisionLinux);
e2e/setup/desktop-macos.globalsetup.ts+108−0
@@ -0,0 +1,108 @@
1// desktop-macos: bring the PACKAGED app up inside a macOS GUI guest and forward
2// its CDP port (the shared attach/forward lives in ./desktop-vm). The guest runs
3// tart `--no-graphics` (no host window) but the base image's autologin still
4// reaches a real Aqua session, so the GUI renders and `screencapture` films it.
5// We come up the SAME way desktop-packaged does — start the bundled daemon, then
6// launch the app so it ATTACHES (no sidecar spawn → no first-run consent modal).
7// The app must be launched INTO the Aqua session (`launchctl asuser`); a plain
8// SSH spawn lands in a non-GUI session.
9import { execFileSync } from "node:child_process";
10import { existsSync } from "node:fs";
11import { fileURLToPath } from "node:url";
12import { join } from "node:path";
13
14import { pushDirAsTar } from "../src/vm/desktop";
15import { tartVm } from "../src/vm/tart";
16import {
17 attachOrProvision,
18 CDP_GUEST_PORT,
19 waitGuestHttp,
20 waitGuestPageTarget,
21 type ProvisionedGuest,
22} from "./desktop-vm";
23
24const DAEMON_PORT = 4789;
25const GUEST_DIR = "/Users/admin/exe";
26const GUEST_HOME = "/Users/admin/exe-home";
27
28const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url));
29const hostBundle = () => {
30 const app = join(appDir, "dist", "mac-arm64", "Executor.app");
31 return {
32 app,
33 exe: join(app, "Contents/MacOS/Executor"),
34 executor: join(app, "Contents/Resources/executor/executor"),
35 };
36};
37
38/** Build the packaged mac bundle if it isn't on disk (slow; reuse an existing
39 * dist/ while iterating). Mirrors desktop-packaged.globalsetup. */
40const ensureBundle = (): void => {
41 if (existsSync(hostBundle().app)) return;
42 const run = (cmd: string, args: string[]) =>
43 execFileSync(cmd, args, { cwd: appDir, stdio: "inherit", env: { ...process.env } });
44 run("bun", ["./scripts/build-sidecar.ts"]);
45 run("bunx", ["--bun", "electron-vite", "build"]);
46 execFileSync(
47 "bunx",
48 ["--bun", "electron-builder", "--config", "electron-builder.e2e.config.ts", "--mac"],
49 {
50 cwd: appDir,
51 stdio: "inherit",
52 env: { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: "false" },
53 },
54 );
55};
56
57const provisionMac = async (): Promise<ProvisionedGuest> => {
58 ensureBundle();
59 const { exe, executor } = hostBundle();
60 const vm = await tartVm("macos", "arm64").provision();
61 try {
62 // Push the bundle (tar-stream, robust over the just-booted link) and clear
63 // the scp quarantine so it can run.
64 await vm.ssh(`rm -rf ${GUEST_DIR} ${GUEST_HOME} && mkdir -p ${GUEST_HOME}/.executor`);
65 await pushDirAsTar(vm.host, hostBundle().app, GUEST_DIR);
66 await vm.ssh(`xattr -dr com.apple.quarantine ${GUEST_DIR} 2>/dev/null || true`);
67 // The e2e build is unsigned; an arm64 app needs at least an ad-hoc signature
68 // to execute, and the host build's signature isn't trusted on another Mac.
69 await vm.ssh(
70 `codesign --force --deep --sign - ${GUEST_DIR}/Executor.app 2>&1 | tail -2 || true`,
71 );
72
73 const guestExe = `${GUEST_DIR}/Executor.app/${exe.split("/Executor.app/")[1]}`;
74 const guestExecutor = `${GUEST_DIR}/Executor.app/${executor.split("/Executor.app/")[1]}`;
75 const env = `HOME=${GUEST_HOME} EXECUTOR_DATA_DIR=${GUEST_HOME}/.executor`;
76
77 // 1) the bundled daemon, supervised — the app attaches to this.
78 await vm.ssh(
79 `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-macos-e2e EXECUTOR_CLIENT=desktop ` +
80 `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +
81 `>/tmp/executor-daemon.log 2>&1 &`,
82 );
83 if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) {
84 throw new Error(
85 "supervised daemon never came up in the guest (see /tmp/executor-daemon.log)",
86 );
87 }
88
89 // 2) the packaged app, launched INTO the Aqua session with CDP enabled.
90 await vm.ssh(
91 `U=$(id -u); sudo launchctl asuser $U bash -lc ` +
92 `'nohup env HOME=${GUEST_HOME} "${guestExe}" --remote-debugging-port=${CDP_GUEST_PORT} --remote-allow-origins="*" ` +
93 `>/tmp/executor-app.log 2>&1 &'`,
94 );
95 if (!(await waitGuestPageTarget(vm, CDP_GUEST_PORT))) {
96 const log = (await vm.ssh("tail -40 /tmp/executor-app.log 2>/dev/null").catch(() => null))
97 ?.stdout;
98 throw new Error(`the app's CDP page target never appeared:\n${log ?? "(no app log)"}`);
99 }
100
101 return { ip: vm.host, teardown: async () => void (await vm.discard()) };
102 } catch (error) {
103 await vm.discard();
104 throw error;
105 }
106};
107
108export default (): Promise<(() => Promise<void>) | void> => attachOrProvision(provisionMac);
e2e/setup/desktop-vm.ts+91−0
@@ -0,0 +1,91 @@
1// Shared plumbing for the desktop-<os> globalsetups. Each OS setup supplies a
2// `provision` that boots its guest and brings the packaged app up with
3// --remote-debugging-port; this module handles the rest the same everywhere:
4// attach to an already-running guest (E2E_DESKTOP_VM_IP) or provision a fresh
5// one, then forward the guest's CDP port and publish it for the scenario.
6import { guestTunnel } from "../src/vm/desktop";
7import type { VmHandle } from "../src/vm/types";
8
9export const CDP_GUEST_PORT = 9222;
10
11const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
12
13/** Poll until an HTTP endpoint inside the guest answers (any status — a 401 from
14 * the bearer-gated daemon still means "up"). HTTP, not lsof: the app may be
15 * owned by root (launchctl asuser), whose listening socket an unprivileged lsof
16 * can't see — a loopback HTTP probe works regardless of owner. */
17export const waitGuestHttp = async (vm: VmHandle, url: string, attempts = 60): Promise<boolean> => {
18 for (let i = 0; i < attempts; i++) {
19 const r = await vm.ssh(
20 `curl -s -o /dev/null -w '%{http_code}' --max-time 5 ${url} 2>/dev/null || echo 000`,
21 );
22 const code = r.stdout.trim().slice(-3);
23 if (code !== "000" && code !== "") return true;
24 await sleep(2000);
25 }
26 return false;
27};
28
29/** Poll until CDP advertises a real PAGE target — i.e. the app's window/renderer
30 * is up, not just the browser endpoint. On a cold guest the page appears a good
31 * bit after the port opens, so gating on this makes the scenario deterministic. */
32export const waitGuestPageTarget = async (
33 vm: VmHandle,
34 port: number,
35 attempts = 60,
36): Promise<boolean> => {
37 for (let i = 0; i < attempts; i++) {
38 const r = await vm.ssh(
39 `curl -s --max-time 5 http://127.0.0.1:${port}/json/list 2>/dev/null | grep -c '"type": "page"' || echo 0`,
40 );
41 if (Number(r.stdout.trim() || "0") > 0) return true;
42 await sleep(2000);
43 }
44 return false;
45};
46
47export interface ProvisionedGuest {
48 readonly ip: string;
49 readonly teardown: () => Promise<void>;
50}
51
52/**
53 * The body every desktop-<os>.globalsetup returns: attach to E2E_DESKTOP_VM_IP
54 * if set, else provision a fresh guest; then forward the guest's CDP port and
55 * publish it (+ the guest IP, for filming) for the worker. A provision/forward
56 * failure never fails the run — the scenario skips honestly, like
57 * desktop-packaged without a display.
58 */
59export const attachOrProvision = async (
60 provision: () => Promise<ProvisionedGuest>,
61): Promise<(() => Promise<void>) | void> => {
62 let ip = process.env.E2E_DESKTOP_VM_IP;
63 let teardownVm: (() => Promise<void>) | undefined;
64
65 if (!ip) {
66 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: VM/host setup may fail; degrade to a skip
67 try {
68 const result = await provision();
69 ip = result.ip;
70 teardownVm = result.teardown;
71 } catch (error) {
72 console.warn(`[desktop] provision failed, scenario will skip: ${String(error)}`);
73 return;
74 }
75 }
76
77 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: forwarding may fail; degrade to a skip
78 try {
79 const forward = await guestTunnel(ip, CDP_GUEST_PORT);
80 process.env.E2E_DESKTOP_CDP_PORT = String(forward.localPort);
81 process.env.E2E_DESKTOP_VM_IP = ip;
82 return async () => {
83 forward.close();
84 await teardownVm?.();
85 };
86 } catch (error) {
87 console.warn(`[desktop] could not forward CDP from ${ip}: ${String(error)}`);
88 await teardownVm?.();
89 return;
90 }
91};
e2e/setup/desktop-windows.globalsetup.ts+106−0
@@ -0,0 +1,106 @@
1// desktop-windows: drive the PACKAGED app running in a Windows guest over CDP.
2// Windows-in-a-VM works best with dockur (QEMU on a Linux/KVM host): autologin
3// gives a real interactive session the app renders into, and QEMU `screendump`
4// films the framebuffer directly — sidestepping the session-0 problem that
5// defeats SSH-driven screenshots (the prior proof of this path).
6//
7// Unlike the tart targets this ATTACHES to a long-lived Windows host (the dockur
8// guest stays up between runs, like a shared selfhost): it forwards the guest's
9// --remote-debugging-port to the host over an SSH jump and publishes it. The
10// shared scenario drives; the windows recorder (src/vm/desktop.ts) films via
11// screendump. Without a reachable app it skips honestly. All connection details
12// come from env (no baked-in host):
13// E2E_DESKTOP_WIN_HOST (ssh alias of the docker/KVM host to jump through),
14// _SSH_PORT (the guest's mapped OpenSSH port), _KEY, _USER; the recorder also
15// reads _CONTAINER and _STORAGE.
16import { spawn } from "node:child_process";
17import net from "node:net";
18
19const SSH_PORT = process.env.E2E_DESKTOP_WIN_SSH_PORT ?? "2222";
20const KEY = process.env.E2E_DESKTOP_WIN_KEY ?? "/tmp/winkey";
21const USER = process.env.E2E_DESKTOP_WIN_USER ?? "Administrator";
22const CDP_GUEST_PORT = 9222;
23
24const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
25
26const freePort = (): Promise<number> =>
27 new Promise((resolve, reject) => {
28 const srv = net.createServer();
29 srv.on("error", reject);
30 srv.listen(0, "127.0.0.1", () => {
31 const port = (srv.address() as net.AddressInfo).port;
32 srv.close(() => resolve(port));
33 });
34 });
35
36interface CdpTarget {
37 readonly type: string;
38 readonly webSocketDebuggerUrl?: string;
39}
40
41/** Poll the forwarded port until the app advertises a CDP page target. */
42const pageReady = async (port: number, attempts = 30): Promise<boolean> => {
43 for (let i = 0; i < attempts; i++) {
44 const targets = (await fetch(`http://127.0.0.1:${port}/json/list`)
45 .then((r) => (r.ok ? r.json() : []))
46 .catch(() => [])) as ReadonlyArray<CdpTarget>;
47 if (targets.some((t) => t.type === "page" && t.webSocketDebuggerUrl)) return true;
48 await sleep(2000);
49 }
50 return false;
51};
52
53export default async function setup(): Promise<(() => Promise<void>) | void> {
54 const host = process.env.E2E_DESKTOP_WIN_HOST;
55 if (!host) {
56 console.warn(
57 "[desktop-windows] E2E_DESKTOP_WIN_HOST not set; scenario will skip. Point it at the ssh " +
58 "alias of a dockur/KVM Windows host running the packaged app with --remote-debugging-port.",
59 );
60 return;
61 }
62 const localPort = await freePort();
63 // mac:localPort → (jump host) → guest:9222. -p is the guest's mapped OpenSSH
64 // port on the host; the final hop into Windows carries the -L forward.
65 const tunnel = spawn(
66 "ssh",
67 [
68 "-o",
69 "StrictHostKeyChecking=no",
70 "-o",
71 "UserKnownHostsFile=/dev/null",
72 "-o",
73 "ConnectTimeout=12",
74 "-o",
75 "ServerAliveInterval=15",
76 "-J",
77 host,
78 "-p",
79 SSH_PORT,
80 "-i",
81 KEY,
82 "-L",
83 `${localPort}:127.0.0.1:${CDP_GUEST_PORT}`,
84 "-N",
85 `${USER}@127.0.0.1`,
86 ],
87 { stdio: "ignore" },
88 );
89
90 if (!(await pageReady(localPort))) {
91 tunnel.kill();
92 console.warn(
93 `[desktop-windows] no app/CDP reachable on the Windows host (${host}); scenario will skip. ` +
94 `Bring up the packaged app with --remote-debugging-port=${CDP_GUEST_PORT} in the dockur guest.`,
95 );
96 return;
97 }
98
99 process.env.E2E_DESKTOP_CDP_PORT = String(localPort);
100 // Non-empty so the scenario runs; the windows recorder uses E2E_DESKTOP_WIN_*.
101 process.env.E2E_DESKTOP_VM_IP = host;
102
103 return async () => {
104 tunnel.kill();
105 };
106}
e2e/src/journey/codegen.ts+78−0
@@ -0,0 +1,78 @@
1// Generate a committed scenario from a recorded journey. The output is a normal
2// scenario file (the same shape a human writes, see e2e/AGENTS.md): an Effect
3// body that yields Target + Browser, mints a fresh identity, and drives one
4// browser session of labelled steps. Terminal (`run`) and HTTP (`request`) steps
5// run INSIDE that session, interleaved with the UI, so page state is never lost
6// between them. It is meant to be read and edited after generation — promotion
7// is the START of a scenario's life, not a frozen artifact.
8import { codegenStep, isAssertion, isBrowserStep, stepLabel, type Step } from "./steps";
9
10export interface JourneyFile {
11 readonly target: string;
12 readonly org: boolean;
13 readonly steps: ReadonlyArray<Step>;
14}
15
16const INDENT = " ";
17
18const stepBlock = (step: Step): string => {
19 const label = JSON.stringify(stepLabel(step));
20 const body = codegenStep(step)
21 .split("\n")
22 .map((line) => `${INDENT} ${line}`)
23 .join("\n");
24 return `${INDENT}await step(${label}, async () => {\n${body}\n${INDENT}});`;
25};
26
27/** A journey with no assertion proves nothing; one with no browser step isn't
28 * what this tool generates (write a CLI/API test directly). `promote` checks
29 * both. */
30export const journeyHasAssertion = (journey: JourneyFile): boolean =>
31 journey.steps.some(isAssertion);
32
33export const journeyHasBrowserStep = (journey: JourneyFile): boolean =>
34 journey.steps.some(isBrowserStep);
35
36export const codegenScenario = (name: string, journey: JourneyFile): string => {
37 const identityArg = journey.org ? "" : "{ org: false }";
38 const body = journey.steps.map(stepBlock).join("\n");
39
40 const needsExec = journey.steps.some((step) => step.kind === "run");
41 const needsExpect = journey.steps.some(
42 (step) => step.kind === "request" || (step.kind === "run" && step.contains !== undefined),
43 );
44
45 const imports: string[] = [];
46 if (needsExec) {
47 imports.push(`import { execFile } from "node:child_process";`);
48 imports.push(`import { promisify } from "node:util";`);
49 imports.push("");
50 }
51 if (needsExpect) imports.push(`import { expect } from "@effect/vitest";`);
52 imports.push(`import { Effect } from "effect";`);
53 imports.push("");
54 imports.push(`import { scenario } from "../src/scenario";`);
55 imports.push(`import { Browser, Target } from "../src/services";`);
56
57 const execHelper = needsExec ? "\nconst execFileAsync = promisify(execFile);\n" : "";
58
59 return `// Generated from an interactive browser journey: \`bun scripts/cli.ts promote ${journey.target} "<name>"\`.
60// This is now an ordinary scenario — edit it freely. It drives the same Browser
61// surface the exploration used, so a reviewer can judge the guarantee by
62// reading it. Re-run with: E2E_${journey.target.toUpperCase()}_URL=<url> vitest run --project ${journey.target} <this file>
63${imports.join("\n")}
64${execHelper}
65scenario(
66 ${JSON.stringify(name)},
67 { timeout: 120_000 },
68 Effect.gen(function* () {
69 const target = yield* Target;
70 const browser = yield* Browser;
71 const identity = yield* target.newIdentity(${identityArg});
72 yield* browser.session(identity, async ({ page, step }) => {
73${body}
74 });
75 }),
76);
77`;
78};
e2e/src/journey/run.ts+104−0
@@ -0,0 +1,104 @@
1// Replay a journey live, from a clean browser, and report what the page looks
2// like afterward. This is the development loop: each `browse` command appends a
3// step and replays the WHOLE journey from scratch, so the flow the agent is
4// building is, at every moment, exactly what the generated test will run — a
5// step that doesn't reproduce fails here, not later. The returned observation
6// (url, title, the page's interactive controls) is how the agent, which can't
7// see the screen, decides the next step; the screenshot is for a human.
8import { chromium, type Page } from "playwright";
9
10import type { Identity, Target } from "../target";
11import { executeStep, type Step } from "./steps";
12
13export interface Control {
14 readonly role: string;
15 readonly name: string;
16}
17
18export interface Observation {
19 readonly url: string;
20 readonly title: string;
21 /** The interactive elements on the page, as (role, accessible name) — the
22 * vocabulary the next clickRole/fill step is written against. */
23 readonly controls: ReadonlyArray<Control>;
24 readonly screenshotPath: string;
25 /** Textual output of the last terminal/HTTP step, if any — so the agent sees
26 * what a `run`/`request` produced, not just the page. */
27 readonly lastOutput?: string;
28 /** Index of the step that threw, with its message — undefined on success. */
29 readonly failedStep?: { readonly index: number; readonly error: string };
30}
31
32const OBSERVED_ROLES = ["link", "button", "textbox", "tab", "menuitem", "checkbox"] as const;
33const PER_ROLE_CAP = 30;
34
35/** A compact, deduped list of the page's interactive controls. Names come from
36 * the accessible name (text / aria-label / placeholder), trimmed. */
37const snapshotControls = async (page: Page): Promise<Control[]> => {
38 const seen = new Set<string>();
39 const controls: Control[] = [];
40 for (const role of OBSERVED_ROLES) {
41 const elements = await page.getByRole(role).all();
42 for (const element of elements.slice(0, PER_ROLE_CAP)) {
43 const raw =
44 (await element.textContent().catch(() => null))?.trim() ||
45 (await element.getAttribute("aria-label").catch(() => null)) ||
46 (await element.getAttribute("placeholder").catch(() => null)) ||
47 "";
48 const name = raw.replace(/\s+/g, " ").trim().slice(0, 70);
49 if (!name) continue;
50 const key = `${role}:${name}`;
51 if (seen.has(key)) continue;
52 seen.add(key);
53 controls.push({ role, name });
54 }
55 }
56 return controls;
57};
58
59export const replayJourney = async (
60 target: Target,
61 identity: Identity,
62 steps: ReadonlyArray<Step>,
63 options: { readonly screenshotPath: string },
64): Promise<Observation> => {
65 const browser = await chromium.launch();
66 let failedStep: Observation["failedStep"];
67 try {
68 const context = await browser.newContext({
69 colorScheme: "dark",
70 viewport: { width: 1280, height: 800 },
71 baseURL: target.baseUrl,
72 });
73 // Same identity injection the Browser surface does, so the live page is the
74 // logged-in page the generated test will drive.
75 if (identity.cookies?.length) {
76 await context.addCookies(
77 identity.cookies.map((cookie) => ({ ...cookie, url: target.baseUrl })),
78 );
79 }
80 const page = await context.newPage();
81 let lastOutput: string | undefined;
82 for (let index = 0; index < steps.length; index++) {
83 try {
84 const output = await executeStep({ page, baseUrl: target.baseUrl }, steps[index]!);
85 if (output !== undefined) lastOutput = output;
86 } catch (error) {
87 failedStep = { index, error: error instanceof Error ? error.message : String(error) };
88 break;
89 }
90 }
91 await page.waitForLoadState("networkidle").catch(() => {});
92 await page.screenshot({ path: options.screenshotPath }).catch(() => {});
93 return {
94 url: page.url(),
95 title: await page.title().catch(() => ""),
96 controls: await snapshotControls(page).catch(() => []),
97 screenshotPath: options.screenshotPath,
98 lastOutput,
99 failedStep,
100 };
101 } finally {
102 await browser.close();
103 }
104};
e2e/src/journey/steps.ts+236−0
@@ -0,0 +1,236 @@
1// The journey Step DSL: the single source of truth shared by interactive
2// exploration and the generated scenario. ONE step description is both
3// (a) executed live against the real product while the agent develops a flow
4// (`executeStep`), and (b) emitted as the matching Playwright line inside a
5// committed scenario (`codegenStep`). Because both sides read the same record,
6// "turn what I just did into a test" is a translation, not a reimplementation:
7// the generated test drives the exact surface the exploration drove.
8//
9// Steps are plain JSON (they persist to .dev/<target>.journey.json between CLI
10// invocations), so they carry no closures — every action is a named primitive.
11import { execFile } from "node:child_process";
12import { promisify } from "node:util";
13
14import type { Page } from "playwright";
15
16const execFileAsync = promisify(execFile);
17
18/** ARIA roles the journey can target. A closed set keeps codegen honest and
19 * the live `getByRole` calls type-safe (Playwright's role arg is a union). */
20export type Role = "link" | "button" | "heading" | "textbox" | "tab" | "menuitem" | "checkbox";
21
22export type Step =
23 | { readonly kind: "goto"; readonly path: string; readonly label?: string }
24 | {
25 readonly kind: "clickRole";
26 readonly role: Role;
27 readonly name: string;
28 readonly label?: string;
29 }
30 | { readonly kind: "clickText"; readonly text: string; readonly label?: string }
31 | {
32 readonly kind: "fill";
33 readonly field: string;
34 readonly value: string;
35 readonly label?: string;
36 }
37 | { readonly kind: "press"; readonly key: string; readonly label?: string }
38 | { readonly kind: "expectText"; readonly text: string; readonly label?: string }
39 | { readonly kind: "expectUrl"; readonly contains: string; readonly label?: string }
40 // A terminal command. `{base}` expands to the target's base URL, so a journey
41 // can hit the same instance the UI is driving (curl, npx add-mcp, executor …).
42 // `contains` (when set) asserts on the combined stdout+stderr.
43 | {
44 readonly kind: "run";
45 readonly command: string;
46 readonly contains?: string;
47 readonly label?: string;
48 }
49 // An HTTP call through the page's own authenticated session (relative paths
50 // resolve against the base URL). `contains` asserts on the response body;
51 // without it, the assertion is a 2xx.
52 | {
53 readonly kind: "request";
54 readonly method: string;
55 readonly path: string;
56 readonly contains?: string;
57 readonly label?: string;
58 };
59
60/** Assertions are the steps a reviewer reads as the guarantee — a journey with
61 * none asserts nothing, so `promote` refuses it. A `run`/`request` is an
62 * assertion when it carries an expectation (`contains`, or `request`'s 2xx). */
63export const isAssertion = (step: Step): boolean =>
64 step.kind === "expectText" ||
65 step.kind === "expectUrl" ||
66 step.kind === "request" ||
67 (step.kind === "run" && step.contains !== undefined);
68
69export const isBrowserStep = (step: Step): boolean =>
70 step.kind !== "run" && step.kind !== "request";
71
72/** The human-readable step name (the `step(label, …)` group + screenshot
73 * caption). The agent can override per step; this is the sensible default so a
74 * generated test reads as a journey even when labels were left implicit. */
75export const stepLabel = (step: Step): string => {
76 if (step.label) return step.label;
77 switch (step.kind) {
78 case "goto":
79 return `Open ${step.path}`;
80 case "clickRole":
81 return `Click the ${JSON.stringify(step.name)} ${step.role}`;
82 case "clickText":
83 return `Click ${JSON.stringify(step.text)}`;
84 case "fill":
85 return `Fill ${JSON.stringify(step.field)}`;
86 case "press":
87 return `Press ${step.key}`;
88 case "expectText":
89 return `See ${JSON.stringify(step.text)}`;
90 case "expectUrl":
91 return `Land on a URL containing ${JSON.stringify(step.contains)}`;
92 case "run":
93 return step.contains
94 ? `Run ${JSON.stringify(step.command)} and see ${JSON.stringify(step.contains)}`
95 : `Run ${JSON.stringify(step.command)}`;
96 case "request":
97 return step.contains
98 ? `${step.method} ${step.path} returns ${JSON.stringify(step.contains)}`
99 : `${step.method} ${step.path} succeeds`;
100 }
101};
102
103const ASSERT_TIMEOUT = 15_000;
104
105export interface StepContext {
106 readonly page: Page;
107 /** The target's base URL — `{base}` in a `run` command expands to this. */
108 readonly baseUrl: string;
109}
110
111/** Expand `{base}` so a terminal command can reach the instance under test. */
112const withBase = (command: string, baseUrl: string): string =>
113 command.replaceAll("{base}", baseUrl);
114
115/** Drive one step against a live page. Assertions throw on failure (a missing
116 * text, a wrong URL, a non-matching command output) so it surfaces immediately
117 * while exploring, the same way it would fail the generated test. Returns any
118 * textual output (terminal / HTTP) so the caller can show it. */
119export const executeStep = async (ctx: StepContext, step: Step): Promise<string | undefined> => {
120 const { page } = ctx;
121 switch (step.kind) {
122 case "goto":
123 await page.goto(step.path, { waitUntil: "networkidle" });
124 return;
125 case "clickRole":
126 await page.getByRole(step.role, { name: step.name }).first().click();
127 return;
128 case "clickText":
129 await page.getByText(step.text).first().click();
130 return;
131 case "fill":
132 await page.getByLabel(step.field).first().fill(step.value);
133 return;
134 case "press":
135 await page.keyboard.press(step.key);
136 return;
137 case "expectText":
138 await page
139 .getByText(step.text)
140 .first()
141 .waitFor({ state: "visible", timeout: ASSERT_TIMEOUT });
142 return;
143 case "expectUrl":
144 await page.waitForURL((url) => url.toString().includes(step.contains), {
145 timeout: ASSERT_TIMEOUT,
146 });
147 return;
148 case "run": {
149 const result = await execFileAsync("sh", ["-c", withBase(step.command, ctx.baseUrl)]).catch(
150 (error: { stdout?: string; stderr?: string }) => ({
151 stdout: error.stdout ?? "",
152 stderr: error.stderr ?? String(error),
153 }),
154 );
155 const output = `${result.stdout}${result.stderr}`;
156 if (step.contains !== undefined && !output.includes(step.contains)) {
157 throw new Error(
158 `\`run\` output did not contain ${JSON.stringify(step.contains)}\n${output.slice(0, 1000)}`,
159 );
160 }
161 return output.trim().slice(0, 2000);
162 }
163 case "request": {
164 const response = await page.request.fetch(step.path, { method: step.method });
165 const body = await response.text();
166 if (step.contains !== undefined) {
167 if (!body.includes(step.contains)) {
168 throw new Error(
169 `${step.method} ${step.path} body did not contain ${JSON.stringify(step.contains)} (status ${response.status()})`,
170 );
171 }
172 } else if (!response.ok()) {
173 throw new Error(`${step.method} ${step.path} returned ${response.status()}`);
174 }
175 return `${response.status()} ${body.slice(0, 800)}`;
176 }
177 }
178};
179
180/** The Playwright line(s) for this step, as they appear inside the generated
181 * scenario's `step(label, async () => { … })` body. Mirrors `executeStep`
182 * exactly — same locator, same call — so live behavior and the test match. */
183export const codegenStep = (step: Step): string => {
184 const s = (value: string): string => JSON.stringify(value);
185 switch (step.kind) {
186 case "goto":
187 return `await page.goto(${s(step.path)}, { waitUntil: "networkidle" });`;
188 case "clickRole":
189 return `await page.getByRole(${s(step.role)}, { name: ${s(step.name)} }).first().click();`;
190 case "clickText":
191 return `await page.getByText(${s(step.text)}).first().click();`;
192 case "fill":
193 return `await page.getByLabel(${s(step.field)}).first().fill(${s(step.value)});`;
194 case "press":
195 return `await page.keyboard.press(${s(step.key)});`;
196 case "expectText":
197 // The repo's browser-assertion idiom: waiting for the element IS the
198 // assertion (a timeout fails the step with the locator in the message).
199 return `await page.getByText(${s(step.text)}).first().waitFor();`;
200 case "expectUrl":
201 return `await page.waitForURL((url) => url.toString().includes(${s(step.contains)}));`;
202 case "run": {
203 const lines = [
204 `const { stdout } = await execFileAsync("sh", ["-c", ${backtick(step.command)}]);`,
205 ];
206 if (step.contains !== undefined) {
207 lines.push(
208 `expect(stdout, "the command output is as expected").toContain(${s(step.contains)});`,
209 );
210 }
211 return lines.join("\n");
212 }
213 case "request": {
214 const lines = [
215 `const response = await page.request.fetch(${s(step.path)}, { method: ${s(step.method)} });`,
216 ];
217 lines.push(
218 step.contains !== undefined
219 ? `expect(await response.text(), "the response is as expected").toContain(${s(step.contains)});`
220 : `expect(response.ok(), "the request succeeded").toBe(true);`,
221 );
222 return lines.join("\n");
223 }
224 }
225};
226
227/** A terminal command as a template literal so `{base}` becomes `target.baseUrl`
228 * (which is in scope in the generated body). Backticks in the command are
229 * escaped so the literal stays valid. */
230const backtick = (command: string): string =>
231 "`" +
232 command
233 .replaceAll("\\", "\\\\")
234 .replaceAll("`", "\\`")
235 .replaceAll("{base}", "${target.baseUrl}") +
236 "`";
e2e/src/vm/desktop.ts+327−0
@@ -0,0 +1,327 @@
1// Driving the PACKAGED desktop app inside a GUI guest, from the host. This is
2// the shared substrate for the cross-OS desktop targets (Gap A): SSH plumbing,
3// an SSH local-forward, a minimal CDP page client, and screen recording — the
4// pieces proven against a tart macOS guest. The desktop-<os> globalsetup boots
5// the guest and launches the app; a scenario connects over CDP and records.
6//
7// Why these mechanics (macOS): a tart `--no-graphics` guest opens no host window
8// (no focus stealing) yet, with the base image's autologin, still reaches a real
9// Aqua session (WindowServer/Dock/Finder) the app can render into. A GUI app must
10// be launched INTO that session (`sudo launchctl asuser <uid> …`); a plain SSH
11// spawn lands in a non-GUI session. The app's --remote-debugging-port is then
12// reachable over an SSH forward, and `screencapture` films the console.
13import { execFile, spawn } from "node:child_process";
14import net from "node:net";
15import { basename, dirname } from "node:path";
16import { promisify } from "node:util";
17
18const execFileP = promisify(execFile);
19
20const SSHPASS = process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass";
21const GUEST_PASS = process.env.E2E_DESKTOP_VM_PASS ?? "admin";
22const GUEST_USER = process.env.E2E_DESKTOP_VM_USER ?? "admin";
23const SSH_OPTS = [
24 "-o",
25 "StrictHostKeyChecking=no",
26 "-o",
27 "UserKnownHostsFile=/dev/null",
28 "-o",
29 "ConnectTimeout=8",
30 "-o",
31 "LogLevel=ERROR",
32 // Password auth only (sshpass): a loaded SSH agent's keys would otherwise
33 // exhaust the guest's MaxAuthTries before the password is tried.
34 "-o",
35 "PubkeyAuthentication=no",
36 "-o",
37 "IdentitiesOnly=yes",
38];
39
40export const sleep = (ms: number): Promise<void> =>
41 new Promise((resolve) => setTimeout(resolve, ms));
42
43export const guestSsh = (
44 ip: string,
45 command: string,
46): Promise<{ stdout: string; stderr: string }> =>
47 execFileP(SSHPASS, ["-p", GUEST_PASS, "ssh", ...SSH_OPTS, `${GUEST_USER}@${ip}`, command], {
48 maxBuffer: 64 * 1024 * 1024,
49 });
50
51export const guestScpFrom = (ip: string, remote: string, local: string): Promise<unknown> =>
52 execFileP(SSHPASS, [
53 "-p",
54 GUEST_PASS,
55 "scp",
56 ...SSH_OPTS,
57 `${GUEST_USER}@${ip}:${remote}`,
58 local,
59 ]);
60
61/**
62 * Push a directory into the guest by streaming a tar over ssh: one connection,
63 * no per-file round-trips, and the flowing data keeps the link alive — far more
64 * robust than `scp -r` of a big app bundle (thousands of files + symlinks),
65 * which drops mid-transfer on a freshly-booted guest. Retries once. The dir
66 * lands at `${remoteParent}/${basename(localDir)}`.
67 */
68export const pushDirAsTar = async (
69 ip: string,
70 localDir: string,
71 remoteParent: string,
72): Promise<void> => {
73 const parent = dirname(localDir);
74 const base = basename(localDir);
75 const remote = `${SSHPASS} -p ${GUEST_PASS} ssh ${SSH_OPTS.join(" ")} ${GUEST_USER}@${ip} ${JSON.stringify(
76 `mkdir -p ${remoteParent} && tar xf - -C ${remoteParent}`,
77 )}`;
78 const pipeline = `tar cf - -C ${JSON.stringify(parent)} ${JSON.stringify(base)} | ${remote}`;
79 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: one retry over a flaky just-booted guest link
80 try {
81 await execFileP("sh", ["-c", pipeline], { maxBuffer: 16 * 1024 * 1024 });
82 } catch {
83 await sleep(3000);
84 await execFileP("sh", ["-c", pipeline], { maxBuffer: 16 * 1024 * 1024 });
85 }
86};
87
88const freePort = (): Promise<number> =>
89 new Promise((resolve, reject) => {
90 const srv = net.createServer();
91 srv.on("error", reject);
92 srv.listen(0, "127.0.0.1", () => {
93 const port = (srv.address() as net.AddressInfo).port;
94 srv.close(() => resolve(port));
95 });
96 });
97
98export interface Forward {
99 readonly localPort: number;
100 close(): void;
101}
102
103/** SSH local-forward host:localPort → guest:guestPort; resolves once it binds. */
104export const guestTunnel = async (ip: string, guestPort: number): Promise<Forward> => {
105 const localPort = await freePort();
106 const child = spawn(
107 SSHPASS,
108 [
109 "-p",
110 GUEST_PASS,
111 "ssh",
112 ...SSH_OPTS,
113 "-N",
114 "-L",
115 `${localPort}:127.0.0.1:${guestPort}`,
116 `${GUEST_USER}@${ip}`,
117 ],
118 { stdio: "ignore" },
119 );
120 for (let i = 0; i < 40; i++) {
121 const ok = await new Promise<boolean>((resolve) => {
122 const sock = net.connect({ host: "127.0.0.1", port: localPort }, () => {
123 sock.destroy();
124 resolve(true);
125 });
126 sock.on("error", () => resolve(false));
127 sock.setTimeout(1000, () => {
128 sock.destroy();
129 resolve(false);
130 });
131 });
132 if (ok) break;
133 await sleep(500);
134 }
135 return { localPort, close: () => child.kill() };
136};
137
138const guestFileSize = (ip: string, remote: string): Promise<number> =>
139 guestSsh(ip, `stat -f%z ${remote} 2>/dev/null || stat -c%s ${remote} 2>/dev/null || echo 0`)
140 .then((r) => Number(r.stdout.trim() || "0"))
141 .catch(() => 0);
142
143/**
144 * Film the guest's screen for `seconds` and land it on the host as `localMp4`
145 * (mp4, plays everywhere). OS-aware capture:
146 * • macOS — `screencapture -V` to a .mov, then host-side ffmpeg to mp4. The
147 * first capture after a cold display can silently no-op, so warm it with a
148 * throwaway still and verify+retry.
149 * • linux — ffmpeg `x11grab` of the Xvfb display straight to mp4.
150 * Best-effort: failures never throw — "every run is watchable" wants the video,
151 * but a missing one shouldn't fail the run. Run it concurrently with the drive.
152 */
153export const recordGuestScreen = async (
154 ip: string,
155 seconds: number,
156 localMp4: string,
157 os: "macos" | "linux" | "windows",
158): Promise<void> => {
159 if (os === "windows") {
160 // Windows can't screenshot the interactive desktop from an SSH session, so
161 // we film the VM framebuffer directly via QEMU's `screendump` (the dockur
162 // host runs the loop + ffmpeg; we pull the mp4). Host/container/storage come
163 // from env (no baked-in host); best-effort, so skip filming if unconfigured.
164 const host = process.env.E2E_DESKTOP_WIN_HOST;
165 const storage = process.env.E2E_DESKTOP_WIN_STORAGE;
166 if (!host || !storage) return;
167 const container = process.env.E2E_DESKTOP_WIN_CONTAINER ?? "exec-win";
168 const frames = Math.max(8, seconds * 4);
169 const py = `import socket,time
170s=socket.socket(socket.AF_UNIX); s.connect("/run/shm/monitor.sock"); time.sleep(0.2); s.recv(65536)
171for i in range(${frames}):
172 s.sendall(("screendump /storage/frames/f%03d.ppm\\n"%i).encode()); time.sleep(0.2)
173 try: s.recv(65536)
174 except Exception: pass`;
175 const b64 = Buffer.from(py).toString("base64");
176 const remote =
177 `S=${storage}; rm -rf "$S/frames"; mkdir -p "$S/frames"; ` +
178 `docker exec ${container} python3 -c "import base64;exec(base64.b64decode('${b64}'))"; ` +
179 `ffmpeg -y -framerate 4 -i "$S/frames/f%03d.ppm" -pix_fmt yuv420p -movflags +faststart "$S/win.mp4" >/dev/null 2>&1`;
180 await execFileP("ssh", ["-o", "ConnectTimeout=10", host, remote], {
181 maxBuffer: 16 * 1024 * 1024,
182 }).catch(() => undefined);
183 await execFileP("scp", [
184 "-o",
185 "ConnectTimeout=10",
186 `${host}:${storage}/win.mp4`,
187 localMp4,
188 ]).catch(() => undefined);
189 return;
190 }
191
192 if (os === "linux") {
193 const remote = "/tmp/executor-desktop-vm.mp4";
194 await guestSsh(
195 ip,
196 `rm -f ${remote}; DISPLAY=:99 ffmpeg -y -f x11grab -video_size 1280x800 -framerate 15 ` +
197 `-i :99 -t ${seconds} -pix_fmt yuv420p ${remote} >/tmp/ffmpeg.log 2>&1`,
198 ).catch(() => undefined);
199 // The mostly-flat console compresses small under x264 — a real capture is
200 // ~30-60KB, a blank/failed one only a few KB.
201 if ((await guestFileSize(ip, remote)) > 12_000) {
202 await guestScpFrom(ip, remote, localMp4).catch(() => undefined);
203 }
204 return;
205 }
206
207 const remoteMov = "/tmp/executor-desktop-vm.mov";
208 // Warm the capture subsystem — the first screencapture after the display comes
209 // up can produce nothing.
210 await guestSsh(ip, "screencapture -x /tmp/.warm.png 2>/dev/null; rm -f /tmp/.warm.png").catch(
211 () => undefined,
212 );
213 for (let attempt = 0; attempt < 2; attempt++) {
214 await guestSsh(ip, `rm -f ${remoteMov}; screencapture -V ${seconds} -x ${remoteMov}`).catch(
215 () => undefined,
216 );
217 if ((await guestFileSize(ip, remoteMov)) > 100_000) {
218 const localMov = `${localMp4}.mov`;
219 await guestScpFrom(ip, remoteMov, localMov).catch(() => undefined);
220 await execFileP("ffmpeg", [
221 "-y",
222 "-i",
223 localMov,
224 "-c:v",
225 "libx264",
226 "-pix_fmt",
227 "yuv420p",
228 "-movflags",
229 "+faststart",
230 localMp4,
231 ])
232 .then(() => execFileP("rm", ["-f", localMov]))
233 .catch(() => undefined);
234 return;
235 }
236 }
237};
238
239// --- a minimal CDP page client (same protocol as desktop-packaged's driver) --
240
241interface CdpTarget {
242 readonly type: string;
243 readonly webSocketDebuggerUrl?: string;
244}
245
246export class CdpPage {
247 private nextId = 1;
248 private readonly pending = new Map<number, (value: unknown) => void>();
249
250 private constructor(private readonly socket: WebSocket) {
251 socket.addEventListener("message", (event) => {
252 if (typeof event.data !== "string") return;
253 const message = JSON.parse(event.data) as { id?: number; result?: unknown };
254 if (message.id && this.pending.has(message.id)) {
255 this.pending.get(message.id)!(message.result);
256 this.pending.delete(message.id);
257 }
258 });
259 }
260
261 static connect = (url: string): Promise<CdpPage> =>
262 new Promise((resolve, reject) => {
263 const socket = new WebSocket(url);
264 const timer = setTimeout(
265 // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter
266 () => reject(new Error(`CDP connect timeout: ${url}`)),
267 30_000,
268 );
269 socket.addEventListener("open", () => {
270 clearTimeout(timer);
271 resolve(new CdpPage(socket));
272 });
273 socket.addEventListener("error", () => {
274 clearTimeout(timer);
275 // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter
276 reject(new Error(`CDP connect failed: ${url}`));
277 });
278 });
279
280 command = <T>(method: string, params: Record<string, unknown> = {}): Promise<T> => {
281 const id = this.nextId++;
282 const result = new Promise<T>((resolve) =>
283 this.pending.set(id, (value) => resolve(value as T)),
284 );
285 this.socket.send(JSON.stringify({ id, method, params }));
286 return result;
287 };
288
289 waitForText = async (text: string, timeoutMs: number): Promise<void> => {
290 const deadline = Date.now() + timeoutMs;
291 const expression = `document.body?.innerText.includes(${JSON.stringify(text)}) ?? false`;
292 for (;;) {
293 const r = await this.command<{ result?: { value?: boolean } }>("Runtime.evaluate", {
294 expression,
295 returnByValue: true,
296 });
297 if (r.result?.value) return;
298 // oxlint-disable-next-line executor/no-error-constructor -- boundary: a wait timeout is a plain failure here
299 if (Date.now() >= deadline) throw new Error(`timed out waiting for text: ${text}`);
300 await sleep(250);
301 }
302 };
303
304 screenshot = async (): Promise<Buffer> => {
305 const r = await this.command<{ data: string }>("Page.captureScreenshot", { format: "png" });
306 return Buffer.from(r.data, "base64");
307 };
308
309 close = (): void => this.socket.close();
310}
311
312/** The first drivable page target's WebSocket URL, fetched through the forward
313 * (so the returned ws URL already points at the local port). */
314export const pageWsUrl = async (localPort: number): Promise<string> => {
315 const deadline = Date.now() + 60_000;
316 for (;;) {
317 const targets = (await fetch(`http://127.0.0.1:${localPort}/json/list`)
318 .then((r) => (r.ok ? r.json() : []))
319 .catch(() => [])) as ReadonlyArray<CdpTarget>;
320 const page = targets.find((t) => t.type === "page" && t.webSocketDebuggerUrl);
321 if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl;
322 // oxlint-disable-next-line executor/no-error-constructor -- boundary: setup failure surfaced to the caller
323 if (Date.now() >= deadline)
324 throw new Error("no CDP page target (app not running with --remote-debugging-port?)");
325 await sleep(500);
326 }
327};
e2e/src/vm/tart.ts+17−1
@@ -30,6 +30,14 @@ const SSH_OPTS = [ · 29 unmodified lines
30 "ServerAliveInterval=5",30 "ServerAliveInterval=5",
31 "-o",31 "-o",
32 "LogLevel=ERROR",32 "LogLevel=ERROR",
33 // We authenticate with sshpass (password). A loaded SSH agent would otherwise
34 // offer its keys first and exhaust the guest's MaxAuthTries ("Too many
35 // authentication failures") before the password is tried — intermittently,
36 // depending on how many keys the agent holds. Force password-only.
37 "-o",
38 "PubkeyAuthentication=no",
39 "-o",
40 "IdentitiesOnly=yes",
33];41];
34const GUEST_USER = "admin";42const GUEST_USER = "admin";
35const GUEST_PASS = "admin";43const GUEST_PASS = "admin";
@@ -92,7 +100,15 @@ export const tartVm = (os: "macos" | "linux", arch: VmArch = "arm64"): VmProvide · 56 unmodified lines
92 provision: async () => {100 provision: async () => {
93 const name = `executor-e2e-${os}-${process.pid}-${Math.floor(performance.now())}`;101 const name = `executor-e2e-${os}-${process.pid}-${Math.floor(performance.now())}`;
94 await execFileP(TART, ["clone", baseImage(os), name]);102 await execFileP(TART, ["clone", baseImage(os), name]);
95 const runProc = spawn(TART, ["run", name, "--no-graphics"], { stdio: "ignore" });103 // `--no-graphics` opens NO host window (never steals focus) yet the guest
104 // still has a virtual display: with the base image's autologin it reaches a
105 // real Aqua session (WindowServer/Dock/Finder), so even the packaged GUI app
106 // renders and `screencapture` records it. No windowed/VNC mode is needed.
107 const runProc = spawn(TART, ["run", name, "--no-graphics"], {
108 stdio: "ignore",
109 detached: true,
110 });
111 runProc.unref();
96 112
97 const tunnelClosers: Array<() => void> = [];113 const tunnelClosers: Array<() => void> = [];
98 let ip = "";114 let ip = "";
e2e/targets/desktop.ts+4−1
@@ -9,7 +9,10 @@ import { Effect } from "effect"; · 8 unmodified lines
9import type { Target } from "../src/target";9import type { Target } from "../src/target";
10 10
11export const desktopTarget = (): Target => ({11export const desktopTarget = (): Target => ({
12 name: "desktop",12 // The project name (desktop / desktop-packaged / desktop-macos) so each lands
13 // in its own runs/<target>/ bucket and viewer column — they're the same app
14 // in different harnesses (dev electron / packaged / packaged-in-a-VM).
15 name: process.env.E2E_TARGET ?? "desktop",
13 baseUrl: "",16 baseUrl: "",
14 mcpUrl: "",17 mcpUrl: "",
15 capabilities: new Set(),18 capabilities: new Set(),
e2e/targets/registry.ts+6−0
@@ -19,6 +19,12 @@ const factories: Record<string, () => Target> = { · 18 unmodified lines
19 // The packaged desktop bundle launches its own app per scenario, same as19 // The packaged desktop bundle launches its own app per scenario, same as
20 // `desktop` — no standard surfaces to carry. See desktop-packaged.globalsetup.20 // `desktop` — no standard surfaces to carry. See desktop-packaged.globalsetup.
21 "desktop-packaged": desktopTarget,21 "desktop-packaged": desktopTarget,
22 // The packaged bundle inside a GUI guest (one per OS), driven over CDP from
23 // the host. Carries no surfaces (the scenario drives CDP itself). See
24 // desktop-<os>.globalsetup.
25 "desktop-macos": desktopTarget,
26 "desktop-linux": desktopTarget,
27 "desktop-windows": desktopTarget,
22 local: localTarget,28 local: localTarget,
23 // The supervised CLI daemon inside a VM, one project per guest OS — restart()29 // The supervised CLI daemon inside a VM, one project per guest OS — restart()
24 // is a real reboot. See setup/cli.globalsetup.ts.30 // is a real reboot. See setup/cli.globalsetup.ts.
e2e/vitest.config.ts+20−0
@@ -77,6 +77,26 @@ export default defineConfig({ · 76 unmodified lines
77 testTimeout: 360_000,77 testTimeout: 360_000,
78 hookTimeout: 600_000,78 hookTimeout: 600_000,
79 }),79 }),
80 // The packaged desktop app inside a GUI guest, driven over CDP from the
81 // host and filmed (the cross-OS counterpart of desktop-packaged) — one
82 // shared scenario (desktop-vm/), one project per guest OS. The globalsetup
83 // provisions the guest, launches the bundle with --remote-debugging-port,
84 // and forwards it; the scenario connects, drives, and records the console.
85 // Each lands in runs/<target>/. Not in the default `npm run test` chain —
86 // run with `vitest run --project desktop-macos` (or desktop-linux). The VM
87 // is provisioned automatically; set E2E_DESKTOP_VM_IP to attach to an
88 // already-running guest instead.
89 // macos/linux provision a tart guest and build+push the ~450MB bundle;
90 // windows ATTACHES to a long-lived dockur guest over an SSH jump (no
91 // provision), so it needs no build but the same generous hooks.
92 ...(["macos", "linux", "windows"] as const).map((os) =>
93 project(`desktop-${os}`, {
94 include: ["desktop-vm/**/*.test.ts"],
95 fileParallelism: false,
96 testTimeout: 300_000,
97 hookTimeout: 900_000,
98 }),
99 ),
80 // The single-user local app. Each scenario launches its OWN `executor100 // The single-user local app. Each scenario launches its OWN `executor
81 // web` via the CLI on a throwaway data dir + an OS-assigned port, so101 // web` via the CLI on a throwaway data dir + an OS-assigned port, so
82 // there is no shared instance and scenarios are independent. Files run102 // there is no shared instance and scenarios are independent. Files run
packages/react/src/components/auth-method-list-editor.tsx+128−31
@@ -12,8 +12,9 @@ · 11 unmodified lines
12// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
13 13
14import { useCallback, useEffect, useRef, useState } from "react";14import { useCallback, useEffect, useRef, useState } from "react";
15import { PlusIcon, XIcon } from "lucide-react";15import { LockIcon, PlusIcon, XIcon } from "lucide-react";
16 16
17import { PlacementLine } from "../lib/auth-placements";
17import { Button } from "./button";18import { Button } from "./button";
18import { FieldLabel } from "./field";19import { FieldLabel } from "./field";
19import {20import {
@@ -35,6 +36,11 @@ export interface AuthMethodSeed { · 15 unmodified lines
35 36
36export interface AuthMethodRow {37export interface AuthMethodRow {
37 readonly value: AuthTemplateEditorValue;38 readonly value: AuthTemplateEditorValue;
39 /** True when this row came from detection (a seed), false when the user added
40 * it. Detected rows are immutable — the spec/probe declared them — so the
41 * editor renders them read-only. Not inferred from `seedSlug`: some plugins
42 * (MCP) seed a detected method with a label but no slug. */
43 readonly seeded: boolean;
38 readonly seedSlug?: string;44 readonly seedSlug?: string;
39 readonly seedLabel?: string;45 readonly seedLabel?: string;
40}46}
@@ -59,6 +65,7 @@ export function useAuthMethodList(seeds: readonly AuthMethodSeed[]): AuthMethodL · 18 unmodified lines
59 seeds.map(65 seeds.map(
60 (seed: AuthMethodSeed): AuthMethodRow => ({66 (seed: AuthMethodSeed): AuthMethodRow => ({
61 value: seed.value,67 value: seed.value,
68 seeded: true,
62 ...(seed.slug !== undefined ? { seedSlug: seed.slug } : {}),69 ...(seed.slug !== undefined ? { seedSlug: seed.slug } : {}),
63 ...(seed.label !== undefined ? { seedLabel: seed.label } : {}),70 ...(seed.label !== undefined ? { seedLabel: seed.label } : {}),
64 }),71 }),
@@ -79,7 +86,10 @@ export function useAuthMethodList(seeds: readonly AuthMethodSeed[]): AuthMethodL · 14 unmodified lines
79 }, []);86 }, []);
80 87
81 const addRow = useCallback(() => {88 const addRow = useCallback(() => {
82 setRows((current: readonly AuthMethodRow[]) => [...current, { value: emptyApiKeyValue() }]);89 setRows((current: readonly AuthMethodRow[]) => [
90 ...current,
91 { value: emptyApiKeyValue(), seeded: false },
92 ]);
83 }, []);93 }, []);
84 94
85 return { rows, setRowAt, removeRowAt, addRow };95 return { rows, setRowAt, removeRowAt, addRow };
@@ -115,36 +125,53 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) { · 29 unmodified lines
115 ) : null125 ) : null
116 ) : (126 ) : (
117 <div className="flex flex-col gap-3">127 <div className="flex flex-col gap-3">
118 {list.rows.map((row: AuthMethodRow, index: number) => (128 {list.rows.map((row: AuthMethodRow, index: number) => {
119 <div129 // A row seeded from detection is the spec's own auth declaration:
120 key={index}130 // it's IMMUTABLE here. We render it read-only (no kind selector, no
121 className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"131 // editable fields) so a user can't silently retype the spec's
122 >132 // method into something nothing backs (e.g. flipping a Bearer-token
123 <div className="flex items-center justify-between">133 // API to OAuth with empty endpoints). The escape hatch is to remove
124 <span className="text-xs font-medium text-muted-foreground">134 // the row and add a custom one. Manually added rows (no seed) get
125 Method {index + 1}135 // the full editor.
126 {row.seedLabel ? ` · ${row.seedLabel}` : ""}136 const detected = row.seeded;
127 </span>137 return (
128 <Button138 <div
129 type="button"139 key={index}
130 variant="ghost"140 className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"
131 size="icon-sm"141 >
132 aria-label="Remove method"142 <div className="flex items-center justify-between">
133 className="text-muted-foreground hover:text-foreground"143 <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
134 onClick={() => list.removeRowAt(index)}144 {detected ? <LockIcon className="size-3 shrink-0" aria-hidden /> : null}
135 >145 <span>
136 <XIcon />146 Method {index + 1}
137 </Button>147 {row.seedLabel ? ` · ${row.seedLabel}` : ""}
148 </span>
149 </span>
150 <Button
151 type="button"
152 variant="ghost"
153 size="icon-sm"
154 aria-label="Remove method"
155 className="text-muted-foreground hover:text-foreground"
156 onClick={() => list.removeRowAt(index)}
157 >
158 <XIcon />
159 </Button>
160 </div>
161 {detected ? (
162 <DetectedMethodSummary value={row.value} oauthMetadata={oauthMetadata} />
163 ) : (
164 <AuthTemplateEditor
165 value={row.value}
166 onChange={(next: AuthTemplateEditorValue) => list.setRowAt(index, next)}
167 {...(allowedKinds ? { allowedKinds } : {})}
168 {...(presets ? { presets } : {})}
169 {...(oauthMetadata ? { oauthMetadata } : {})}
170 />
171 )}
138 </div>172 </div>
139 <AuthTemplateEditor173 );
140 value={row.value}174 })}
141 onChange={(next: AuthTemplateEditorValue) => list.setRowAt(index, next)}
142 {...(allowedKinds ? { allowedKinds } : {})}
143 {...(presets ? { presets } : {})}
144 {...(oauthMetadata ? { oauthMetadata } : {})}
145 />
146 </div>
147 ))}
148 </div>175 </div>
149 )}176 )}
150 {list.rows.length > 0 && props.footerHint ? (177 {list.rows.length > 0 && props.footerHint ? (
@@ -153,3 +180,73 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) { · 2 unmodified lines
153 </section>180 </section>
154 );181 );
155}182}
183
184/** One read-only `label value` line, mono value, for the detected summary. */
185function SpecField(props: { readonly label: string; readonly value: string }) {
186 return (
187 <div className="flex gap-2 text-xs">
188 <span className="w-20 shrink-0 text-muted-foreground">{props.label}</span>
189 <span className="break-all font-mono text-foreground/80">{props.value}</span>
190 </div>
191 );
192}
193
194/** Read-only view of a spec-detected method: shows what the spec declared
195 * (placements / OAuth endpoints) as a DISABLED, non-interactive block. The
196 * detected method is immutable here, so the summary is styled like a disabled
197 * field (muted, not-allowed cursor, text not selectable) to communicate that
198 * plainly. The only action is to remove the row (the header's X) and add a
199 * custom method to override. */
200function DetectedMethodSummary(props: {
201 readonly value: AuthTemplateEditorValue;
202 readonly oauthMetadata?: "editable" | "discovered";
203}) {
204 const { value, oauthMetadata } = props;
205 // Name the auth kind explicitly: a detection label like MCP's "Detected"
206 // doesn't say whether it's OAuth or an API key, so surface it here.
207 const kindLabel =
208 value.kind === "oauth" ? "OAuth" : value.kind === "apikey" ? "API key" : "No auth";
209 return (
210 <div className="space-y-2">
211 <p className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">
212 {kindLabel}
213 </p>
214 <div
215 aria-disabled
216 className="cursor-not-allowed select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground"
217 >
218 {value.kind === "none" && (
219 <p className="text-xs">No credential — tools are callable without an account.</p>
220 )}
221
222 {value.kind === "apikey" &&
223 (value.placements.length > 0 ? (
224 <div className="flex flex-wrap gap-x-4 gap-y-1">
225 {value.placements.map((placement, i: number) => (
226 <PlacementLine key={i} placement={placement} />
227 ))}
228 </div>
229 ) : null)}
230
231 {value.kind === "oauth" &&
232 (oauthMetadata === "discovered" ? (
233 <p className="text-xs">
234 OAuth metadata is discovered from this server when you connect an account.
235 </p>
236 ) : (
237 <div className="space-y-1">
238 {value.authorizationUrl ? (
239 <SpecField label="Authorize" value={value.authorizationUrl} />
240 ) : null}
241 {value.tokenUrl ? <SpecField label="Token" value={value.tokenUrl} /> : null}
242 {value.scopes.length > 0 ? (
243 <SpecField label="Scopes" value={value.scopes.join(", ")} />
244 ) : null}
245 </div>
246 ))}
247 </div>
248
249 <p className="text-[11px] text-muted-foreground">Pulled from spec. Remove to override.</p>
250 </div>
251 );
252}
packages/react/src/lib/auth-placements.tsx+6−1
@@ -112,8 +112,13 @@ export function PlacementLine(props: { readonly placement: Placement; readonly m · 111 unmodified lines
112 : placement.carrier === "env"112 : placement.carrier === "env"
113 ? `${placement.name || "TOKEN"}=`113 ? `${placement.name || "TOKEN"}=`
114 : `?${placement.name || "api_key"}=`;114 : `?${placement.name || "api_key"}=`;
115 // Plain inline (not inline-flex): flex trims the whitespace at the edges of
116 // each child, which would drop the space after "Authorization:" and the
117 // trailing space carried by a prefix like "Bearer ", rendering
118 // "Authorization:Bearer••••••". whitespace-pre-wrap keeps those spaces while
119 // still allowing the line to wrap.
115 return (120 return (
116 <span className="inline-flex items-center font-mono text-xs text-muted-foreground">121 <span className="whitespace-pre-wrap font-mono text-xs text-muted-foreground">
117 {lead}122 {lead}
118 {placement.prefix ? (123 {placement.prefix ? (
119 <span className="text-muted-foreground/60">{placement.prefix}</span>124 <span className="text-muted-foreground/60">{placement.prefix}</span>

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t