1108 lines · 35.9 KB · TypeScript
RawDownload
// Packaged desktop supervised-daemon regressions. These run against the real// electron-builder bundle and its bundled executor because the supervised attach// path is production-only (`app.isPackaged`).import { type ChildProcess, execFile, execFileSync, spawn } from "node:child_process";import {  chmodSync,  existsSync,  mkdirSync,  mkdtempSync,  readFileSync,  rmSync,  statSync,  writeFileSync,} from "node:fs";import { createServer, type IncomingMessage } from "node:http";import net from "node:net";import { homedir, tmpdir } from "node:os";import { dirname, join } from "node:path";import { promisify } from "node:util"; import { expect, it } from "@effect/vitest";import { Effect } from "effect";import {  normalizeExecutorServerConnection,  serializeExecutorLocalServerManifest,} from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario";import { RunDir } from "../src/services";import { waitForHttp } from "../setup/boot"; const execFileAsync = promisify(execFile);const SERVICE_LABEL = "sh.executor.daemon"; interface PackagedExecutorBridge {  readonly getSettings: () => Promise<{ readonly port: number }>;  readonly updateSettings: (patch: { readonly port: number }) => Promise<unknown>;  readonly restartServer: () => Promise<unknown>;  readonly getServerConnection: () => Promise<{ readonly origin: string } | null>;} interface PackagedApp {  readonly child: ChildProcess;  cdp: CdpPage;  readonly debugPort: string;  readonly output: () => string;} interface CdpResponse<T> {  readonly id: number;  readonly result?: T;  readonly error?: { readonly message?: string; readonly data?: 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));    }  };   waitForExpression = async (    expression: string,    timeoutMs: number,    description: string,  ): Promise<void> => {    const deadline = Date.now() + timeoutMs;    for (;;) {      if (await this.evaluate<boolean>(`Boolean(${expression})`).catch(() => false)) return;      if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${description}`);      await new Promise((resolve) => setTimeout(resolve, 250));    }  };   textPresent = async (text: string): Promise<boolean> =>    this.evaluate<boolean>(`document.body?.innerText.includes(${JSON.stringify(text)}) ?? false`);   setViewport = async (width: number, height: number): Promise<void> => {    await this.command("Emulation.setDeviceMetricsOverride", {      width,      height,      deviceScaleFactor: 1,      mobile: false,    });  };   wheel = async (x: number, y: number, deltaY: number): Promise<void> => {    await this.command("Input.dispatchMouseEvent", {      type: "mouseWheel",      x,      y,      deltaX: 0,      deltaY,    });  };   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();  };} declare global {  interface Window {    readonly executor: PackagedExecutorBridge;  }} const appExe = process.env.E2E_DESKTOP_APP_EXE;const executorBin = process.env.E2E_DESKTOP_EXECUTOR_BIN; const guiAvailable = (): boolean => {  if (process.platform === "darwin") {    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;}; 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 requireBundle = (): { readonly app: string; readonly executor: string } => {  if (!appExe || !executorBin) {    throw new Error(      "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_EXECUTOR_BIN not set — did desktop-packaged.globalsetup run?",    );  }  return { app: appExe, executor: executorBin };}; const currentUid = (): number => {  const getuid = (process as { readonly getuid?: () => number }).getuid;  return typeof getuid === "function" ? getuid.call(process) : 0;}; const serviceTarget = (): string => `gui/${currentUid()}/${SERVICE_LABEL}`;const launchAgentPath = (): string =>  join(homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);const isolatedDesktopSettingsDir = (home: string): string =>  join(home, ".executor-desktop-settings");const desktopSettingsDirs = (home: string): readonly string[] => {  if (process.platform === "darwin") {    const support = join(home, "Library", "Application Support");    return [      isolatedDesktopSettingsDir(home),      join(support, "@executor-js", "desktop"),      join(support, "Executor"),    ];  }  if (process.platform === "linux") {    return [      isolatedDesktopSettingsDir(home),      join(home, ".config", "@executor-js", "desktop"),      join(home, ".config", "Executor"),    ];  }  const roaming = join(home, "AppData", "Roaming");  return [    isolatedDesktopSettingsDir(home),    join(roaming, "@executor-js", "desktop"),    join(roaming, "Executor"),  ];}; const packagedAppEnv = (home: string): NodeJS.ProcessEnv => {  return {    ...process.env,    HOME: home,    EXECUTOR_DESKTOP_SETTINGS_DIR: isolatedDesktopSettingsDir(home),  };}; interface LaunchdServiceSnapshot {  readonly plist: string | null;  readonly wasLoaded: boolean;} const launchctl = async (args: ReadonlyArray<string>): Promise<boolean> => {  try {    await execFileAsync("launchctl", [...args]);    return true;  } catch {    return false;  }}; const captureLaunchdService = (): LaunchdServiceSnapshot | null => {  if (process.platform !== "darwin") return null;  const path = launchAgentPath();  const plist = existsSync(path) ? readFileSync(path, "utf8") : null;  let wasLoaded = false;  try {    execFileSync("launchctl", ["print", serviceTarget()], { stdio: "ignore" });    wasLoaded = true;  } catch {    wasLoaded = false;  }  return { plist, wasLoaded };}; const restoreLaunchdService = async (snapshot: LaunchdServiceSnapshot | null): Promise<void> => {  if (!snapshot) return;  const target = serviceTarget();  await launchctl(["bootout", target]);  const path = launchAgentPath();  if (snapshot.plist === null) {    rmSync(path, { force: true });    return;  }  mkdirSync(dirname(path), { recursive: true });  writeFileSync(path, snapshot.plist, { mode: 0o600 });  chmodSync(path, 0o600);  await launchctl(["enable", target]);  if (snapshot.wasLoaded) {    const bootstrapped = await launchctl(["bootstrap", `gui/${currentUid()}`, path]);    if (bootstrapped) await launchctl(["kickstart", "-k", target]);  }}; 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 DaemonStart {  readonly child: ChildProcess;  readonly ready: boolean;  readonly stderr: string;} const startSupervisedDaemon = (  env: NodeJS.ProcessEnv,  port: number,  hostname = "127.0.0.1",): Promise<DaemonStart> =>  new Promise((resolve) => {    const { executor } = requireBundle();    const child = spawn(      executor,      ["daemon", "run", "--foreground", "--port", String(port), "--hostname", hostname],      { env, stdio: ["ignore", "pipe", "pipe"] },    );    let stderr = "";    let settled = false;    const settle = (ready: boolean) => {      if (settled) return;      settled = true;      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 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 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> => {  const { app } = requireBundle();  let output = "";  let settled = false;  const child = spawn(app, ["--remote-debugging-port=0"], {    env: packagedAppEnv(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, output: () => output };}; const reconnectPackagedPage = async (app: PackagedApp): Promise<CdpPage> => {  app.cdp.close();  const pageCdpUrl = await waitForPageWebSocket(app.debugPort);  const cdp = await CdpPage.connect(pageCdpUrl);  await cdp.command("Runtime.enable");  await cdp.command("Page.enable");  app.cdp = cdp;  return cdp;}; const closePackaged = async (app: PackagedApp | undefined): Promise<void> => {  app?.cdp.close();  await stopProcess(app?.child);}; const waitForServerConnectionLabel = async (  page: CdpPage,  expectedText: string,  timeoutMs: number,): Promise<string> => {  const deadline = Date.now() + timeoutMs;  let label = "";  for (;;) {    label = await page

Lines 1–500 of 1108.