163 lines · 5.7 KB · TypeScript
RawDownload
/** * The classifier is pure and tested next door; this covers what `startSidecar` * DOES with each verdict — which is where the reported symptom lived. A child * interrupted from outside must leave the crash channel untouched and tell the * window no report was sent, while a genuine post-boot death must still be * reported. Both halves are driven through the real `startSidecar`, with only * the process spawn and Electron's environment replaced. */ import { describe, expect, it } from "@effect/vitest";// oxlint-disable-next-line executor/no-vitest-import -- boundary: vi.mock must come from vitest itself for mock hoisting to resolveimport { vi } from "vitest";import { EventEmitter } from "node:events"; let appQuitting = false;const crashReports: string[] = []; vi.mock("electron", () => ({  app: { isPackaged: true, getVersion: () => "1.6.0", on: () => {} },})); vi.mock("electron-log/main.js", () => {  const scope = () => ({ info: () => {}, error: () => {}, warn: () => {}, debug: () => {} });  return { default: { scope }, scope };}); vi.mock("./local-auth", () => ({ loadOrMintLocalAuthToken: () => "test-token" })); vi.mock("./settings", () => ({ getServerSettings: () => ({ port: 4789 }) })); vi.mock("./diagnostics", () => ({  reportSidecarCrash: (message: string) => {    crashReports.push(message);  },  sidecarCrashReportingEnv: () => ({}),})); vi.mock("./supervised-daemon", () => ({ resolveSupervisedDaemonAttach: () => null })); vi.mock("./app-quit", () => ({ isAppQuitting: () => appQuitting })); class FakeChild extends EventEmitter {  readonly stdout = new EventEmitter();  readonly stderr = new EventEmitter();  readonly pid = 4242;  exitCode: number | null = null;  killed = false;  kill() {    this.killed = true;    return true;  }} // The packaged branch of `resolveSidecarCommand` reads Electron's resources// path; nothing is executed, since the spawn itself is replaced below.(process as { resourcesPath?: string }).resourcesPath ??= "/tmp/executor-test-resources"; let spawned: FakeChild | null = null; vi.mock("node:child_process", () => ({  spawn: () => {    spawned = new FakeChild();    return spawned;  },})); const { startSidecar, onUnexpectedSidecarExit, stopSidecar } = await import("./sidecar"); /** Boot a sidecar to the point `startSidecar` resolves, then hand back the child. */const bootedSidecar = async (): Promise<FakeChild> => {  const started = startSidecar();  // The child announces readiness with the structured stdout sentinel.  await Promise.resolve();  spawned?.stdout.emit("data", Buffer.from("EXECUTOR_READY:4789\n"));  await started;  // oxlint-disable-next-line executor/no-non-null-assertion -- the spawn mock always records a child  return spawned!;}; interface Observed {  readonly notices: { readonly reported: boolean }[];  readonly reports: string[];} /** Boot, drive one post-boot exit, and report what the app did about it. */const exitAfterBoot = async (  exit: { readonly code: number | null; readonly signal: NodeJS.Signals | null },  options: { readonly quitting?: boolean; readonly stopFirst?: boolean } = {},): Promise<Observed> => {  appQuitting = false;  crashReports.length = 0;  const notices: { readonly reported: boolean }[] = [];  onUnexpectedSidecarExit((notice) => notices.push(notice));   const child = await bootedSidecar();  child.stderr.emit("data", Buffer.from("some trailing output\n"));  if (options.stopFirst) {    const stopping = stopSidecar(child as never);    child.emit("exit", exit.code, exit.signal);    await stopping;  } else {    appQuitting = options.quitting ?? false;    child.emit("exit", exit.code, exit.signal);  }  await Promise.resolve();  return { notices, reports: [...crashReports] };}; describe("startSidecar post-boot exit handling", () => {  it("does not report an outside interrupt, and tells the window nothing was sent", async () => {    // A group-wide Ctrl-C reaches the child because it shares Electron's    // process group; Node surfaces it as 128+SIGINT. The server really is gone,    // so the window must still be told — but no crash report exists to claim.    const { notices, reports } = await exitAfterBoot({ code: 130, signal: null });     expect(reports).toEqual([]);    expect(notices).toEqual([{ reported: false }]);  });   it("does not report a SIGTERM the app did not send", async () => {    const { notices, reports } = await exitAfterBoot({ code: null, signal: "SIGTERM" });     expect(reports).toEqual([]);    expect(notices).toEqual([{ reported: false }]);  });   it("still reports a genuine post-boot death and says a report was sent", async () => {    const { notices, reports } = await exitAfterBoot({ code: 1, signal: null });     expect(reports).toHaveLength(1);    expect(reports[0]).toContain("code=1");    expect(notices).toEqual([{ reported: true }]);  });   it("still reports an abnormal death signal", async () => {    const { notices, reports } = await exitAfterBoot({ code: null, signal: "SIGSEGV" });     expect(reports).toHaveLength(1);    expect(notices).toEqual([{ reported: true }]);  });   it("says nothing at all when the app itself stopped the sidecar", async () => {    const { notices, reports } = await exitAfterBoot(      { code: null, signal: "SIGTERM" },      { stopFirst: true },    );     expect(reports).toEqual([]);    expect(notices).toEqual([]);  });   it("says nothing at all when the app is quitting", async () => {    // Quit tears the tree down; the window is going away, so surfacing a    // disconnected screen at that moment would be its own bug.    const { notices, reports } = await exitAfterBoot(      { code: 130, signal: null },      { quitting: true },    );     expect(reports).toEqual([]);    expect(notices).toEqual([]);  });});