Films the desktop ATTACHING to an already-running OS-supervised daemon instead of spawning its own sidecar: starts a real cli-daemon (the desktop sidecar server in EXECUTOR_SUPERVISED mode → kind "cli-daemon") against a throwaway HOME, launches Electron pointed at the same HOME, and proves the attach — the server manifest still names OUR daemon's pid (a spawned sidecar would be a new pid + kind "desktop-sidecar"). session.mp4 is the artifact. The daemon half is verified (EXECUTOR_READY + kind cli-daemon + 401). Like the existing desktop scenario it drives Electron via Playwright, so it needs a GUI display session to run (the desktop project is already display-gated, not in the default test chain) — run on a real desktop / CI-with-display to capture the video.
Unified · Split
| @@ -0,0 +1,182 @@ | |||
| 1 | // Desktop-only, on camera: the app ATTACHES to an already-running OS-supervised | ||
| 2 | // daemon instead of spawning its own sidecar. We start a real supervised gateway | ||
| 3 | // (the desktop sidecar server in EXECUTOR_SUPERVISED mode → it self-publishes a | ||
| 4 | // manifest of kind "cli-daemon"), launch the Electron app pointed at the same | ||
| 5 | // HOME, and prove it attached: the manifest still names OUR daemon's pid (a | ||
| 6 | // spawned sidecar would be a fresh pid + kind "desktop-sidecar"). The recording | ||
| 7 | // (session.mp4 + screenshots) is the artifact; the waits are the assertions. No | ||
| 8 | // launchd — only a throwaway home and one short-lived daemon process. | ||
| 9 | import { type ChildProcess, execFile, spawn } from "node:child_process"; | ||
| 10 | import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; | ||
| 11 | import { createRequire } from "node:module"; | ||
| 12 | import net from "node:net"; | ||
| 13 | import { tmpdir } from "node:os"; | ||
| 14 | import { join } from "node:path"; | ||
| 15 | import { fileURLToPath } from "node:url"; | ||
| 16 | import { promisify } from "node:util"; | ||
| 17 | |||
| 18 | import { expect } from "@effect/vitest"; | ||
| 19 | import { Effect } from "effect"; | ||
| 20 | import { _electron } from "playwright"; | ||
| 21 | |||
| 22 | import { scenario } from "../src/scenario"; | ||
| 23 | import { RunDir } from "../src/services"; | ||
| 24 | import { waitForHttp } from "../setup/boot"; | ||
| 25 | |||
| 26 | const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); | ||
| 27 | const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); | ||
| 28 | const sidecarServer = join(appDir, "src/sidecar/server.ts"); | ||
| 29 | const clientDir = join(repoRoot, "apps/local/dist"); | ||
| 30 | const electronBinary = createRequire(join(appDir, "package.json"))("electron") as string; | ||
| 31 | |||
| 32 | const freePort = (): Promise<number> => | ||
| 33 | new Promise((resolve, reject) => { | ||
| 34 | const srv = net.createServer(); | ||
| 35 | srv.on("error", reject); | ||
| 36 | srv.listen(0, "127.0.0.1", () => { | ||
| 37 | const port = (srv.address() as net.AddressInfo).port; | ||
| 38 | srv.close(() => resolve(port)); | ||
| 39 | }); | ||
| 40 | }); | ||
| 41 | |||
| 42 | interface Manifest { | ||
| 43 | readonly kind: string; | ||
| 44 | readonly pid: number; | ||
| 45 | } | ||
| 46 | |||
| 47 | interface DaemonStart { | ||
| 48 | readonly child: ChildProcess; | ||
| 49 | readonly ready: boolean; | ||
| 50 | readonly stderr: string; | ||
| 51 | } | ||
| 52 | |||
| 53 | /** Spawn the supervised gateway; resolves once it announces EXECUTOR_READY (or | ||
| 54 | * times out / exits early, with `ready: false`). The caller asserts readiness, | ||
| 55 | * so the executor only ever resolves. */ | ||
| 56 | const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise<DaemonStart> => | ||
| 57 | new Promise((resolve) => { | ||
| 58 | const child = spawn("bun", ["run", sidecarServer], { | ||
| 59 | cwd: repoRoot, | ||
| 60 | env, | ||
| 61 | stdio: ["ignore", "pipe", "pipe"], | ||
| 62 | }); | ||
| 63 | let stderr = ""; | ||
| 64 | const settle = (ready: boolean) => resolve({ child, ready, stderr }); | ||
| 65 | const timer = setTimeout(() => settle(false), 60_000); | ||
| 66 | child.stdout.on("data", (chunk: Buffer) => { | ||
| 67 | if (chunk.toString().includes("EXECUTOR_READY:")) { | ||
| 68 | clearTimeout(timer); | ||
| 69 | settle(true); | ||
| 70 | } | ||
| 71 | }); | ||
| 72 | child.stderr.on("data", (chunk: Buffer) => { | ||
| 73 | stderr += chunk.toString(); | ||
| 74 | }); | ||
| 75 | child.on("exit", () => { | ||
| 76 | clearTimeout(timer); | ||
| 77 | settle(false); | ||
| 78 | }); | ||
| 79 | }); | ||
| 80 | |||
| 81 | scenario( | ||
| 82 | "Desktop · attaches to the OS-supervised daemon instead of spawning a sidecar", | ||
| 83 | { timeout: 240_000 }, | ||
| 84 | Effect.gen(function* () { | ||
| 85 | const runDir = yield* RunDir; | ||
| 86 | yield* Effect.promise(() => run(runDir)); | ||
| 87 | }), | ||
| 88 | ); | ||
| 89 | |||
| 90 | const run = async (runDir: string) => { | ||
| 91 | const home = mkdtempSync(join(tmpdir(), "executor-attach-e2e-")); | ||
| 92 | const dataDir = join(home, ".executor"); | ||
| 93 | const manifestPath = join(dataDir, "server-control", "server.json"); | ||
| 94 | const videoTmp = join(runDir, ".video-tmp"); | ||
| 95 | const port = await freePort(); | ||
| 96 | |||
| 97 | let daemon: ChildProcess | undefined; | ||
| 98 | let app: Awaited<ReturnType<typeof _electron.launch>> | undefined; | ||
| 99 | let stepIndex = 0; | ||
| 100 | |||
| 101 | try { | ||
| 102 | const started = await startSupervisedDaemon({ | ||
| 103 | ...process.env, | ||
| 104 | HOME: home, | ||
| 105 | EXECUTOR_SUPERVISED: "1", | ||
| 106 | EXECUTOR_DATA_DIR: dataDir, | ||
| 107 | EXECUTOR_PORT: String(port), | ||
| 108 | EXECUTOR_HOST: "127.0.0.1", | ||
| 109 | EXECUTOR_AUTH_PASSWORD: "supervised-attach-film", | ||
| 110 | EXECUTOR_CLIENT_DIR: clientDir, | ||
| 111 | }); | ||
| 112 | daemon = started.child; | ||
| 113 | expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); | ||
| 114 | await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 }); | ||
| 115 | |||
| 116 | const daemonManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest; | ||
| 117 | expect(daemonManifest.kind, "the running daemon advertises itself as cli-daemon").toBe( | ||
| 118 | "cli-daemon", | ||
| 119 | ); | ||
| 120 | const daemonPid = daemonManifest.pid; | ||
| 121 | |||
| 122 | app = await _electron.launch({ | ||
| 123 | executablePath: electronBinary, | ||
| 124 | args: [appDir], | ||
| 125 | cwd: appDir, | ||
| 126 | env: { ...process.env, HOME: home }, | ||
| 127 | recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, | ||
| 128 | timeout: 120_000, | ||
| 129 | }); | ||
| 130 | |||
| 131 | const page = await app.firstWindow({ timeout: 120_000 }); | ||
| 132 | const step = async (label: string, body: () => Promise<void>) => { | ||
| 133 | await body(); | ||
| 134 | stepIndex += 1; | ||
| 135 | const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); | ||
| 136 | await page.screenshot({ | ||
| 137 | path: join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`), | ||
| 138 | }); | ||
| 139 | }; | ||
| 140 | |||
| 141 | // The window only loads the console once the app has a connection — and it | ||
| 142 | // attaches to the supervised daemon before it would ever spawn a sidecar. | ||
| 143 | await step("desktop boots into the console", async () => { | ||
| 144 | await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); | ||
| 145 | }); | ||
| 146 | |||
| 147 | // The proof it ATTACHED rather than spawned: the manifest is untouched — | ||
| 148 | // same pid, still cli-daemon. A managed sidecar would have rewritten it to | ||
| 149 | // kind "desktop-sidecar" with a fresh child pid. | ||
| 150 | await step("server manifest still names the supervised daemon", async () => { | ||
| 151 | const after = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest; | ||
| 152 | expect(after.kind, "still the supervised daemon (not a desktop sidecar)").toBe("cli-daemon"); | ||
| 153 | expect(after.pid, "the desktop attached to our daemon, not a new sidecar").toBe(daemonPid); | ||
| 154 | }); | ||
| 155 | } finally { | ||
| 156 | const page = app?.windows()[0]; | ||
| 157 | const video = page?.video(); | ||
| 158 | await app?.close().catch(() => {}); | ||
| 159 | const recordedPath = await video?.path().catch(() => undefined); | ||
| 160 | if (recordedPath && existsSync(recordedPath)) { | ||
| 161 | await promisify(execFile)("ffmpeg", [ | ||
| 162 | "-y", | ||
| 163 | "-i", | ||
| 164 | recordedPath, | ||
| 165 | "-c:v", | ||
| 166 | "libx264", | ||
| 167 | "-preset", | ||
| 168 | "veryfast", | ||
| 169 | "-crf", | ||
| 170 | "26", | ||
| 171 | "-pix_fmt", | ||
| 172 | "yuv420p", | ||
| 173 | "-movflags", | ||
| 174 | "+faststart", | ||
| 175 | join(runDir, "session.mp4"), | ||
| 176 | ]).catch(() => {}); | ||
| 177 | } | ||
| 178 | daemon?.kill("SIGTERM"); | ||
| 179 | rmSync(videoTmp, { recursive: true, force: true }); | ||
| 180 | rmSync(home, { recursive: true, force: true }); | ||
| 181 | } | ||
| 182 | }; | ||