History

e2e: desktop attach film — app connects to the supervised daemon (not a sidecar)

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.

Rhys Sullivan committed 4 months ago

Commit cfd22841 parent 5bacd3c1 file · +182 −0Browse files

Unified · Split

e2e/desktop/supervised-attach.test.ts+182−0
@@ -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+};