415 lines · 14.6 KB · TypeScript
RawDownload
// Packaged desktop, on camera: the REAL electron-builder bundle (app.isPackaged// === true) attaches to an already-running OS-supervised daemon instead of// spawning its own sidecar. This is the production-only path — dev electron skips// ensureSupervisedConnection entirely and always spawns a desktop-sidecar, so the// attach behavior can ONLY be proven against the packaged artifact.//// We start the daemon as the bundle's OWN compiled `executor` binary (the exact// binary a supervised install runs) in EXECUTOR_SUPERVISED mode. It publishes a// manifest of kind "cli-daemon". Then we launch the packaged app// pointed at the same HOME and prove it attached: the manifest still names the// daemon's pid (a spawned sidecar would rewrite it to "desktop-sidecar" with a// fresh pid), and the console — served by the bearer-gated daemon — renders,// which only happens if the app injected the bearer it read from the manifest.// The recording (session.mp4 + screenshots) is the artifact; the waits assert.import { type ChildProcess, execFileSync, spawn } from "node:child_process";import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";import net from "node:net";import { tmpdir } from "node:os";import { join } from "node:path"; import { expect, it } from "@effect/vitest";import { Effect } from "effect"; import { scenario } from "../src/scenario";import { RunDir } from "../src/services";import { waitForHttp } from "../setup/boot"; // Driving the packaged Electron app needs a real window-server session: Aqua on// macOS, an X/Wayland display on Linux. An SSH/CI shell runs in the background// (non-GUI) session where Electron can't open a window — so this scenario runs// only where a display is reachable (a logged-in console, or a guest under// autologin/Xvfb) and skips honestly elsewhere rather than hanging on launch.const guiAvailable = (): boolean => {  if (process.platform === "darwin") {    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing the session manager; absence = no GUI    try {      return execFileSync("launchctl", ["managername"], { encoding: "utf8" }).trim() === "Aqua";    } catch {      return false;    }  }  if (process.platform === "linux") {    return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);  }  return true; // windows: the runner places this in an interactive session}; const SCENARIO_NAME = "Desktop (packaged) · the real bundle attaches to the OS-supervised daemon"; const appExe = process.env.E2E_DESKTOP_APP_EXE;const executorBin = process.env.E2E_DESKTOP_EXECUTOR_BIN; interface PackagedApp {  readonly child: ChildProcess;  readonly debugPort: string;  cdp: CdpPage;} interface CdpResponse<T> {  readonly id: number;  readonly result?: T;  readonly error?: { readonly message?: string };} interface CdpEvaluateResult {  readonly result: { readonly value?: unknown };  readonly exceptionDetails?: unknown;} interface CdpTarget {  readonly type: string;  readonly url: string;  readonly webSocketDebuggerUrl?: string;} class CdpPage {  private nextId = 1;  private readonly pending = new Map<    number,    {      readonly resolve: (value: unknown) => void;      readonly reject: (error: Error) => void;    }  >();   private constructor(private readonly socket: WebSocket) {    socket.addEventListener("message", (event) => {      const data = event.data;      if (typeof data !== "string") return;      const message = JSON.parse(data) as CdpResponse<unknown>;      if (!message.id) return;      const pending = this.pending.get(message.id);      if (!pending) return;      this.pending.delete(message.id);      if (message.error) {        pending.reject(new Error(message.error.message ?? "CDP command failed"));        return;      }      pending.resolve(message.result);    });    socket.addEventListener("close", () => {      for (const [, pending] of this.pending) {        pending.reject(new Error("CDP socket closed"));      }      this.pending.clear();    });  }   static connect = (url: string): Promise<CdpPage> =>    new Promise((resolve, reject) => {      const socket = new WebSocket(url);      const timer = setTimeout(() => {        socket.close();        // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter        reject(new Error(`Timed out connecting to page CDP target ${url}`));      }, 30_000);      socket.addEventListener(        "open",        () => {          clearTimeout(timer);          resolve(new CdpPage(socket));        },        { once: true },      );      socket.addEventListener(        "error",        () => {          clearTimeout(timer);          // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter          reject(new Error(`Failed to connect to page CDP target ${url}`));        },        { once: true },      );    });   command = async <T>(method: string, params: Record<string, unknown> = {}): Promise<T> => {    const id = this.nextId;    this.nextId += 1;    const result = new Promise<T>((resolve, reject) => {      this.pending.set(id, {        resolve: (value) => resolve(value as T),        reject,      });    });    this.socket.send(JSON.stringify({ id, method, params }));    return result;  };   evaluate = async <T>(expression: string): Promise<T> => {    const result = await this.command<CdpEvaluateResult>("Runtime.evaluate", {      expression,      awaitPromise: true,      returnByValue: true,    });    if (result.exceptionDetails) {      throw new Error(`CDP evaluation failed: ${JSON.stringify(result.exceptionDetails)}`);    }    return result.result.value as T;  };   waitForText = async (text: string, timeoutMs: number): Promise<void> => {    const deadline = Date.now() + timeoutMs;    const expression = `document.body?.innerText.includes(${JSON.stringify(text)}) ?? false`;    for (;;) {      if (await this.evaluate<boolean>(expression).catch(() => false)) return;      if (Date.now() >= deadline) throw new Error(`Timed out waiting for text: ${text}`);      await new Promise((resolve) => setTimeout(resolve, 250));    }  };   screenshot = async (path: string): Promise<void> => {    const result = await this.command<{ readonly data: string }>("Page.captureScreenshot", {      format: "png",      fromSurface: true,    });    writeFileSync(path, Buffer.from(result.data, "base64"));  };   close = (): void => {    this.socket.close();  };} const freePort = (): Promise<number> =>  new Promise((resolve, reject) => {    const srv = net.createServer();    srv.on("error", reject);    srv.listen(0, "127.0.0.1", () => {      const port = (srv.address() as net.AddressInfo).port;      srv.close(() => resolve(port));    });  }); interface Manifest {  readonly kind: string;  readonly pid: number;} interface DaemonStart {  readonly child: ChildProcess;  readonly ready: boolean;  readonly stderr: string;} /** Spawn the bundle's compiled executor as a supervised daemon; resolves once it *  announces readiness (or times out / exits early, ready:false). */const startSupervisedDaemon = (env: NodeJS.ProcessEnv, port: number): Promise<DaemonStart> =>  new Promise((resolve) => {    const child = spawn(      executorBin as string,      ["daemon", "run", "--foreground", "--port", String(port), "--hostname", "127.0.0.1"],      { env, stdio: ["ignore", "pipe", "pipe"] },    );    let stderr = "";    const settle = (ready: boolean) => resolve({ child, ready, stderr });    const timer = setTimeout(() => settle(false), 60_000);    child.stdout.on("data", (chunk: Buffer) => {      if (/Daemon ready on http:\/\//.test(chunk.toString())) {        clearTimeout(timer);        settle(true);      }    });    child.stderr.on("data", (chunk: Buffer) => {      stderr += chunk.toString();    });    child.on("exit", () => {      clearTimeout(timer);      settle(false);    });  }); const packagedSingleInstanceAvailable = (): boolean => {  if (process.platform !== "darwin" || !appExe) return true;  try {    const lines = execFileSync("pgrep", ["-fl", "Executor.app/Contents/MacOS/Executor"], {      encoding: "utf8",    })      .split("\n")      .filter(Boolean);    return !lines.some((line) => !line.includes(appExe));  } catch {    return true;  }}; const waitForPageWebSocket = async (debugPort: string): Promise<string> => {  const deadline = Date.now() + 120_000;  for (;;) {    const targets = (await fetch(`http://127.0.0.1:${debugPort}/json/list`)      .then((response) => (response.ok ? response.json() : []))      .catch(() => [])) as ReadonlyArray<CdpTarget>;    const page = targets.find(      (target) =>        target.type === "page" &&        target.webSocketDebuggerUrl &&        !target.url.startsWith("devtools://"),    );    if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl;    if (Date.now() >= deadline) {      throw new Error("Timed out waiting for packaged app page CDP target");    }    await new Promise((resolve) => setTimeout(resolve, 100));  }}; const launchPackaged = async (home: string): Promise<PackagedApp> => {  let output = "";  let settled = false;  const child = spawn(appExe as string, ["--remote-debugging-port=0"], {    env: { ...process.env, HOME: home },    stdio: ["ignore", "pipe", "pipe"],  });   const browserCdpUrl = await new Promise<string>((resolve, reject) => {    const timer = setTimeout(() => {      // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: packaged-app launch promise adapter      reject(new Error(`Timed out waiting for packaged app CDP URL\n${output}`));    }, 120_000);    const settle = (fn: () => void) => {      if (settled) return;      settled = true;      clearTimeout(timer);      fn();    };    const collectOutput = (chunk: Buffer) => {      const text = chunk.toString();      output = (output + text).slice(-16_384);      const match = output.match(/DevTools listening on (ws:\/\/[^\s]+)/);      if (match) settle(() => resolve(match[1]!));    };    child.stdout?.on("data", collectOutput);    child.stderr?.on("data", collectOutput);    // oxlint-disable-next-line executor/no-promise-reject -- boundary: packaged-app launch promise adapter    child.once("error", (error) => settle(() => reject(error)));    child.once("exit", (code, signal) =>      settle(() =>        // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: packaged-app launch promise adapter        reject(          new Error(`Packaged app exited before CDP (code=${code} signal=${signal})\n${output}`),        ),      ),    );  });   const debugPort = new URL(browserCdpUrl).port;  const pageCdpUrl = await waitForPageWebSocket(debugPort);  const cdp = await CdpPage.connect(pageCdpUrl);  await cdp.command("Runtime.enable");  await cdp.command("Page.enable");  return { child, cdp, debugPort };}; const stopProcess = async (child: ChildProcess | undefined): Promise<void> => {  if (!child || child.exitCode !== null || child.signalCode !== null) return;  await new Promise<void>((resolve) => {    const timeout = setTimeout(() => {      child.kill("SIGKILL");      resolve();    }, 5_000);    child.once("exit", () => {      clearTimeout(timeout);      resolve();    });    child.kill("SIGTERM");  });}; const closePackaged = async (app: PackagedApp | undefined): Promise<void> => {  app?.cdp.close();  await stopProcess(app?.child);}; if (!guiAvailable() || !packagedSingleInstanceAvailable()) {  it.skip(`${SCENARIO_NAME} (needs a GUI display and no already-running Executor.app)`, () => {});} else {  scenario(    SCENARIO_NAME,    { timeout: 240_000 },    Effect.gen(function* () {      if (!appExe || !executorBin) {        return yield* Effect.die(          "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_EXECUTOR_BIN not set — did desktop-packaged.globalsetup run?",        );      }      const runDir = yield* RunDir;      yield* Effect.promise(() => run(runDir));    }),  );} const run = async (runDir: string) => {  const home = mkdtempSync(join(tmpdir(), "executor-pkg-attach-"));  const dataDir = join(home, ".executor");  const manifestPath = join(dataDir, "server-control", "server.json");  const port = await freePort();   let daemon: ChildProcess | undefined;  let app: PackagedApp | undefined;  let stepIndex = 0;   try {    const started = await startSupervisedDaemon(      {        ...process.env,        HOME: home,        EXECUTOR_SUPERVISED: "1",        EXECUTOR_DATA_DIR: dataDir,        EXECUTOR_AUTH_TOKEN: "packaged-attach-film",        EXECUTOR_CLIENT: "desktop",      },      port,    );    daemon = started.child;    expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true);    await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 });     const daemonManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest;    expect(daemonManifest.kind, "the bundled executor advertises itself as cli-daemon").toBe(      "cli-daemon",    );    const daemonPid = daemonManifest.pid;     // Launch the PACKAGED bundle directly. `app.isPackaged` is true, so boot()    // runs the supervised attach path; CDP drives the real renderer.    app = await launchPackaged(home);    const page = app.cdp;    const step = async (label: string, body: () => Promise<void>) => {      await body();      stepIndex += 1;      const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-");      await page.screenshot(join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`));    };     // The console only renders once the app has a live connection AND the bearer    // it injects is accepted by the gated daemon — so reaching it proves both the    // attach and the bearer wiring through the packaged session layer.    await step("packaged app boots into the bearer-gated console", async () => {      await page.waitForText("Settings", 120_000);    });     // Proof it ATTACHED, not spawned: the manifest is untouched — same pid, still    // cli-daemon. A managed sidecar would have rewritten it to "desktop-sidecar".    await step("server manifest still names the supervised daemon", async () => {      const after = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest;      expect(after.kind, "still the supervised daemon (not a desktop sidecar)").toBe("cli-daemon");      expect(after.pid, "the packaged app attached to our daemon, not a new sidecar").toBe(        daemonPid,      );    });  } finally {    await closePackaged(app);    await stopProcess(daemon);    rmSync(home, { recursive: true, force: true });  }};