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.
9import { type ChildProcess, execFile, spawn } from "node:child_process";
10import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
11import { createRequire } from "node:module";
12import net from "node:net";
13import { tmpdir } from "node:os";
14import { join } from "node:path";
15import { fileURLToPath } from "node:url";
16import { promisify } from "node:util";
17
18import { expect } from "@effect/vitest";
19import { Effect } from "effect";
20import { _electron } from "playwright";
21
22import { scenario } from "../src/scenario";
23import { RunDir } from "../src/services";
24import { waitForHttp } from "../setup/boot";
25
26const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url));
27const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
28const sidecarServer = join(appDir, "src/sidecar/server.ts");
29const clientDir = join(repoRoot, "apps/local/dist");
30const electronBinary = createRequire(join(appDir, "package.json"))("electron") as string;
31
32const 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
42interface Manifest {
43 readonly kind: string;
44 readonly pid: number;
45}
46
47interface 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. */
56const 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
81scenario(
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
90const 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};