Ctrl K
ComunidadGuías…

server/browser-processes.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Real processes stand in for agent-browser's: node with Chrome's telltale
// arguments, in a process group of its own, with the environment a daemon
// hands its Chrome.
import { execFile, execFileSync, spawn, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { hostname, tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";

import { browserProcessesRunning, browserProcessTree, orphanedBrowsers, releaseBrowserProfile, stopBrowserProcesses } from "./browser-processes.ts";
import { removeTempDir } from "./testing/cleanup.ts";

// The real tools, except where a test stands in for Windows' tasklist and taskkill.
vi.mock("node:child_process", async (original) => {
  const actual = await original<typeof import("node:child_process")>();
  return { ...actual, execFile: vi.fn(actual.execFile) };
});

const posix = process.platform !== "win32";
const groups: number[] = [];
const homes: string[] = [];
afterEach(async () => {
  for (const group of groups.splice(0)) { try { process.kill(-group, "SIGKILL"); } catch {} }
  for (const home of homes.splice(0)) await removeTempDir(home);
});

const IDLE = "setInterval(() => {}, 1000)";
/** Chrome as agent-browser starts it: its own group, a temporary profile,
 * a DevTools port, and the daemon's environment. */
const chromeArgs = (home: string, profile = join(home, "agent-browser-chrome-0000")) => ["-e", IDLE, "--", "--remote-debugging-port=0", `--user-data-dir=${profile}`];
/** agent-browser's own temporary profile folder, with something in it. */
function profileFolder(home: string, name = `agent-browser-chrome-${randomUUID()}`): string {
  const folder = join(home, name);
  mkdirSync(join(folder, "Default"), { recursive: true });
  writeFileSync(join(folder, "Default", "Cookies"), "fixture");
  return folder;
}
const daemonEnv = (home: string, session: string) => ({ PATH: process.env.PATH, HOME: home, AGENT_BROWSER_DAEMON: "1", AGENT_BROWSER_SESSION: session });
const gone = (pid: number) => {
  try { return execFileSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8" }).trim().startsWith("Z"); } catch { return true; }
};

function start(command: string, args: string[], env: NodeJS.ProcessEnv): ChildProcess {
  const child = spawn(command, args, { detached: true, stdio: "ignore", env });
  child.unref();
  groups.push(child.pid!);
  return child;
}

describe.skipIf(!posix)("browsers whose engine was killed under them", () => {
  it("finds a Chrome of a session whose daemon is gone, by the session its environment names, and stops its group", async () => {
    const home = mkdtempSync(join(tmpdir(), "omb-orphan-"));
    homes.push(home);
    // Its parent is this test, not a daemon: as good as re-parented to init.
    const profile = profileFolder(home);
    const lookalikeProfile = profileFolder(home, "agent-browser-chrome-0000");
    const orphan = start(process.execPath, chromeArgs(home, profile), daemonEnv(home, "bot-orphan"));
    // One still under its daemon (in a group of its own, as agent-browser
    // starts Chrome) is not an orphan, nor is one of another HOME.
    const servedPid = join(home, "served.pid");
    const daemon = start(process.execPath, ["-e", `const served = require('node:child_process').spawn(process.execPath, ${JSON.stringify(chromeArgs(home))}, { stdio: 'ignore', detached: true }); require('node:fs').writeFileSync(${JSON.stringify(servedPid)}, String(served.pid)); ${IDLE}`], daemonEnv(home, "bot-served"));
    const elsewhere = start(process.execPath, chromeArgs(home), daemonEnv(join(home, "other"), "bot-elsewhere"));
    // A process with the arguments and a session's name that no daemon
    // started (no AGENT_BROWSER_DAEMON) is not one either.
    const lookalike = start(process.execPath, chromeArgs(home), { PATH: process.env.PATH, HOME: home, AGENT_BROWSER_SESSION: "bot-lookalike" });
    await vi.waitFor(() => expect(existsSync(servedPid) && readFileSync(servedPid, "utf8")).toBeTruthy());
    const served = Number(readFileSync(servedPid, "utf8"));
    groups.push(served);
    await vi.waitFor(async () => expect((await orphanedBrowsers(home)).map((found) => found.session)).toEqual(["bot-orphan"]), { timeout: 5_000 });
    const [found] = await orphanedBrowsers(home);
    expect(found!.tree.groups).toEqual([orphan.pid]);
    expect(await stopBrowserProcesses(found!.tree)).toBe(true);
    expect(gone(orphan.pid!)).toBe(true);
    expect(await browserProcessesRunning(found!.tree)).toBe(false);
    for (const other of [daemon.pid!, served, elsewhere.pid!, lookalike.pid!]) expect(gone(other)).toBe(false);
    expect(existsSync(profile)).toBe(false);
    expect(existsSync(lookalikeProfile)).toBe(true);
  });

  it("stops a helper the browser started in its group, with the browser", async () => {
    const home = mkdtempSync(join(tmpdir(), "omb-orphan-"));
    homes.push(home);
    const helperPid = join(home, "helper.pid");
    // Not named the way agent-browser names its own profile folders: kept.
    const kept = profileFolder(home, "agent-browser-chrome-1111");
    const orphan = start(process.execPath, ["-e", `const helper = require('node:child_process').spawn('sleep', ['30'], { stdio: 'ignore' }); require('node:fs').writeFileSync(${JSON.stringify(helperPid)}, String(helper.pid)); ${IDLE}`, "--", "--remote-debugging-port=0", `--user-data-dir=${join(home, "agent-browser-chrome-1111")}`], daemonEnv(home, "bot-helper"));
    await vi.waitFor(() => expect(existsSync(helperPid) && readFileSync(helperPid, "utf8")).toBeTruthy());
    const helper = Number(readFileSync(helperPid, "utf8"));
    const [found] = await orphanedBrowsers(home);
    expect(found?.session).toBe("bot-helper");
    expect(await stopBrowserProcesses(found!.tree)).toBe(true);
    expect(gone(orphan.pid!)).toBe(true);
    expect(gone(helper)).toBe(true);
    expect(existsSync(kept)).toBe(true);
  });

  it("never counts or stops a process that took the number of one of the browser's", async () => {
    const stranger = start("sleep", ["30"], { PATH: process.env.PATH });
    const tree = { daemon: stranger.pid!, members: new Map([[stranger.pid!, "an earlier process"]]), groups: [stranger.pid!] };
    expect(await browserProcessesRunning(tree)).toBe(false);
    expect(await stopBrowserProcesses(tree, 100)).toBe(true);
    expect(gone(stranger.pid!)).toBe(false);
  });

  it("stops a helper the browser starts while it exits, once the browser is gone", async () => {
    const home = mkdtempSync(join(tmpdir(), "omb-orphan-"));
    homes.push(home);
    const latePid = join(home, "late.pid");
    const ready = join(home, "ready");
    const orphan = start(process.execPath, ["-e", `process.on("SIGTERM", () => { const late = require("node:child_process").spawn("sleep", ["30"], { stdio: "ignore" }); require("node:fs").writeFileSync(${JSON.stringify(latePid)}, String(late.pid)); process.exit(0); }); require("node:fs").writeFileSync(${JSON.stringify(ready)}, ""); ${IDLE}`, "--", "--remote-debugging-port=0", `--user-data-dir=${profileFolder(home)}`], daemonEnv(home, "bot-late"));
    // Its command line and environment, which find it, exist from the start;
    // a SIGTERM before node has run the script above would simply kill it.
    await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5_000 });
    await vi.waitFor(async () => expect((await orphanedBrowsers(home)).map((found) => found.session)).toEqual(["bot-late"]), { timeout: 5_000 });
    const [found] = await orphanedBrowsers(home);
    expect(await stopBrowserProcesses(found!.tree)).toBe(true);
    const late = Number(readFileSync(latePid, "utf8"));
    expect(gone(orphan.pid!)).toBe(true);
    expect(gone(late)).toBe(true);
  });
});

describe.skipIf(!posix)("a saved session's kept profile", () => {
  /** A kept profile with a sign-in in it, locked the way Chrome locks one: SingletonLock -> "<host>-<pid>". */
  function keptProfile(lockedBy?: string) {
    const root = mkdtempSync(join(tmpdir(), "omb-kept-"));
    homes.push(root);
    const dir = join(root, "agent-browser-profiles", "0123456789abcdef0123456789abcdef");
    mkdirSync(join(dir, "Default"), { recursive: true });
    writeFileSync(join(dir, "Default", "Cookies"), "signed in");
    if (lockedBy) {
      symlinkSync(lockedBy, join(dir, "SingletonLock"));
      symlinkSync(join(root, "socket"), join(dir, "SingletonSocket"));
    }
    return { root, dir, locked: () => { try { return lstatSync(join(dir, "SingletonLock")).isSymbolicLink(); } catch { return false; } } };
  }
  const signedIn = (dir: string) => readFileSync(join(dir, "Default", "Cookies"), "utf8") === "signed in";

  it("frees a lock that names another computer or a process that is gone, and keeps the sign-ins", async () => {
    for (const lock of [`another-host.example-${process.pid}`, `${hostname()}-2147483000`, "not-a-lock"]) {
      const profile = keptProfile(lock);
      await releaseBrowserProfile(profile.dir);
      expect(profile.locked()).toBe(false);
      expect(existsSync(join(profile.dir, "SingletonSocket"))).toBe(false);
      expect(signedIn(profile.dir)).toBe(true);
    }
  });

  it("stops a Chrome whose daemon was killed under it, then frees its lock, and keeps the sign-ins", async () => {
    const profile = keptProfile();
    const orphan = start(process.execPath, ["-e", IDLE, "--", "--remote-debugging-port=0", `--user-data-dir=${profile.dir}`], daemonEnv(profile.root, "bot-kept"));
    symlinkSync(`${hostname()}-${orphan.pid}`, join(profile.dir, "SingletonLock"));
    await vi.waitFor(async () => {
      await releaseBrowserProfile(profile.dir);
      expect(gone(orphan.pid!)).toBe(true);
    }, { timeout: 10_000 });
    expect(profile.locked()).toBe(false);
    expect(signedIn(profile.dir)).toBe(true);
  });

  it("leaves a daemon's own Chrome running with its lock, until that daemon is killed", async () => {
    const profile = keptProfile();
    const servedPid = join(profile.root, "served.pid");
    const chrome = ["-e", IDLE, "--", "--remote-debugging-port=0", `--user-data-dir=${profile.dir}`];
    const daemon = start(process.execPath, ["-e", `const served = require('node:child_process').spawn(process.execPath, ${JSON.stringify(chrome)}, { stdio: 'ignore', detached: true }); require('node:fs').writeFileSync(${JSON.stringify(servedPid)}, String(served.pid)); ${IDLE}`], daemonEnv(profile.root, "bot-kept"));
    await vi.waitFor(() => expect(existsSync(servedPid) && readFileSync(servedPid, "utf8")).toBeTruthy());
    const served = Number(readFileSync(servedPid, "utf8"));
    groups.push(served);
    symlinkSync(`${hostname()}-${served}`, join(profile.dir, "SingletonLock"));
    await releaseBrowserProfile(profile.dir);
    await releaseBrowserProfile(profile.dir);
    expect(profile.locked()).toBe(true);
    for (const pid of [daemon.pid!, served]) expect(gone(pid)).toBe(false);
    // Its daemon killed, the same Chrome is now an orphan that would take the next launch.
    process.kill(daemon.pid!, "SIGKILL");
    await vi.waitFor(() => expect(gone(daemon.pid!)).toBe(true));
    await vi.waitFor(async () => {
      await releaseBrowserProfile(profile.dir);
      expect(gone(served)).toBe(true);
    }, { timeout: 10_000 });
    expect(profile.locked()).toBe(false);
    expect(signedIn(profile.dir)).toBe(true);
  });

  it("finds a kept profile's Chrome whose daemon is gone, and stopping it never deletes the profile", async () => {
    const profile = keptProfile();
    const orphan = start(process.execPath, ["-e", IDLE, "--", "--remote-debugging-port=0", `--user-data-dir=${profile.dir}`], daemonEnv(profile.root, "bot-kept"));
    const profiles = join(profile.root, "agent-browser-profiles");
    expect(await orphanedBrowsers(profile.root)).toEqual([]);
    await vi.waitFor(async () => expect((await orphanedBrowsers(profile.root, profiles)).map((found) => found.session)).toEqual(["bot-kept"]), { timeout: 5_000 });
    const [found] = await orphanedBrowsers(profile.root, profiles);
    expect(found!.tree.profiles).toEqual([]);
    expect(await stopBrowserProcesses(found!.tree)).toBe(true);
    expect(gone(orphan.pid!)).toBe(true);
    expect(signedIn(profile.dir)).toBe(true);
  });
});

// Windows soon gives a freed pid to a new process, and tasklist shows no
// start time to tell the two apart: the daemon is the image it ran when read.
describe("a browser on Windows", () => {
  const platform = process.platform;
  const actualExecFile = vi.mocked(execFile).getMockImplementation()!;
  afterEach(() => {
    Object.defineProperty(process, "platform", { value: platform, configurable: true });
    vi.mocked(execFile).mockImplementation(actualExecFile);
  });

  it("kills the daemon's tree while the daemon runs, and never a program that took its pid or one tasklist cannot vouch for", async () => {
    // A live process answers for the pid, as whatever Windows gave it to would.
    const holder = spawn(process.execPath, ["-e", IDLE], { stdio: "ignore" });
    try {
      let image: string | null = "agent-browser.exe";
      const commands: string[][] = [];
      vi.mocked(execFile).mockImplementation(((file: string, args: string[], _options: unknown, done: (error: Error | null, stdout: string) => void) => {
        commands.push([file, ...args]);
        if (file === "taskkill") image = ""; // the tree is gone
        const row = image ? `"${image}","${holder.pid}","Console","1","12,345 K"\r\n` : "INFO: No tasks are running which match the specified criteria.\r\n";
        queueMicrotask(() => image === null && file === "tasklist" ? done(new Error("tasklist failed"), "") : done(null, file === "tasklist" ? row : ""));
        return {} as ChildProcess;
      }) as unknown as typeof execFile);
      Object.defineProperty(process, "platform", { value: "win32", configurable: true });
      // A pid file naming another program gives no tree to stop.
      image = "notepad.exe";
      expect(await browserProcessTree("/engine/agent-browser.exe", holder.pid!, "bot-a")).toBeNull();
      image = "agent-browser.exe";
      const tree = (await browserProcessTree("/engine/agent-browser.exe", holder.pid!, "bot-a"))!;
      expect(tree).toMatchObject({ daemon: holder.pid, image: "agent-browser.exe" });
      expect(await browserProcessesRunning(tree)).toBe(true);
      const killed = () => commands.filter(([file]) => file === "taskkill");
      // The daemon exited and Windows gave its pid to another program.
      image = "notepad.exe";
      expect(await browserProcessesRunning(tree)).toBe(false);
      expect(await stopBrowserProcesses(tree)).toBe(true);
      // tasklist could not say what runs there: nothing is killed, and the
      // browser is not reported stopped.
      image = null;
      expect(await stopBrowserProcesses(tree)).toBe(false);
      expect(killed()).toEqual([]);
      image = "agent-browser.exe";
      expect(await stopBrowserProcesses(tree)).toBe(true);
      expect(killed()).toEqual([["taskkill", "/PID", String(holder.pid), "/T", "/F"]]);
      expect(await browserProcessesRunning(tree)).toBe(false);
    } finally {
      holder.kill("SIGKILL");
    }
  });

  it("stops an orphaned Chrome that runs under the configured Chrome's own image name, and only that one", async () => {
    const root = mkdtempSync(join(tmpdir(), "omb-win-custom-chrome-"));
    homes.push(root);
    const dir = join(root, "Profile");
    mkdirSync(join(dir, "lockfile"), { recursive: true });
    const started = "2026-11-03T09:00:00.0000000Z";
    const commands: string[][] = [];
    vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: { env?: NodeJS.ProcessEnv }, done: (error: Error | null, stdout: string) => void) => {
      commands.push([file, ...args]);
      let stdout = "";
      if (file === "powershell.exe" && options.env?.OMB_PROCESS_ID) stdout = `${started}\r\n`;
      else if (file === "powershell.exe") {
        stdout = JSON.stringify([{
          ProcessId: 4500, ParentProcessId: 4100, CommandLine: `"C:\\Chrome Dev\\chrome-dev.exe" "--user-data-dir=${dir}"`,
          Name: "chrome-dev.exe", Started: started, ParentName: null, ParentStarted: null,
        }]);
      } else if (file === "taskkill") rmSync(join(dir, "lockfile"), { recursive: true, force: true });
      queueMicrotask(() => done(null, stdout));
      return {} as ChildProcess;
    }) as unknown as typeof execFile);
    Object.defineProperty(process, "platform", { value: "win32", configurable: true });
    const killed = () => commands.filter(([file]) => file === "taskkill");
    // Without the configured Chrome, an image off the list is never stopped.
    await releaseBrowserProfile(dir, "C:\\Tools\\agent-browser.exe");
    expect(killed()).toEqual([]);
    await releaseBrowserProfile(dir, "C:\\Tools\\agent-browser.exe", "C:\\Chrome Dev\\Chrome-Dev.exe");
    expect(killed()).toEqual([["taskkill", "/PID", "4500", "/T", "/F"]]);
    expect(existsSync(join(dir, "lockfile"))).toBe(false);
  });

  it("gives up after a bounded wait when the stopped Chrome's lock stays held", async () => {
    const root = mkdtempSync(join(tmpdir(), "omb-win-held-lock-"));
    homes.push(root);
    const dir = join(root, "Profile");
    mkdirSync(join(dir, "lockfile"), { recursive: true });
    const started = "2026-11-03T09:00:00.0000000Z";
    const commands: string[][] = [];
    vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: { env?: NodeJS.ProcessEnv }, done: (error: Error | null, stdout: string) => void) => {
      commands.push([file, ...args]);
      let stdout = "";
      if (file === "powershell.exe" && options.env?.OMB_PROCESS_ID) stdout = `${started}\r\n`;
      else if (file === "powershell.exe") {
        stdout = JSON.stringify([{
          ProcessId: 4600, ParentProcessId: 4100, CommandLine: `"C:\\Chrome\\chrome.exe" "--user-data-dir=${dir}"`,
          Name: "chrome.exe", Started: started, ParentName: null, ParentStarted: null,
        }]);
      }
      queueMicrotask(() => done(null, stdout));
      return {} as ChildProcess;
    }) as unknown as typeof execFile);
    Object.defineProperty(process, "platform", { value: "win32", configurable: true });
    const begun = Date.now();
    await releaseBrowserProfile(dir);
    expect(Date.now() - begun).toBeGreaterThanOrEqual(1_500);
    expect(commands.filter(([file]) => file === "taskkill")).toEqual([["taskkill", "/PID", "4600", "/T", "/F"]]);
    expect(existsSync(join(dir, "lockfile"))).toBe(true);
  });

  it("stops a Chrome whose daemon was killed under it, which would make every launch exit with code 21, and keeps a daemon's own", async () => {
    const root = mkdtempSync(join(tmpdir(), "omb-win-profile-"));
    homes.push(root);
    const dir = join(root, "Kept Profile");
    mkdirSync(join(dir, "Default"), { recursive: true });
    writeFileSync(join(dir, "Default", "Cookies"), "signed in");
    writeFileSync(join(dir, "lockfile"), "");
    // Windows keeps a running Chrome's lockfile undeletable; a folder by that name, which no system unlinks, stands in for it.
    const hold = () => mkdirSync(join(dir, "lockfile"));
    const release = () => rmSync(join(dir, "lockfile"), { recursive: true, force: true });
    let parentImage = "";
    let parentStarted = "";
    let startedNow = "";
    const started = "2026-11-03T09:00:00.0000000Z";
    // The browser is this test's own live pid, so a Chrome kept under its daemon can be seen still running.
    const browser = process.pid;
    const commands: string[][] = [];
    const chrome = (pid: number, args: string, Name = "chrome.exe") => ({
      ProcessId: pid, ParentProcessId: 4100, CommandLine: `"C:\\Chrome\\${Name}" ${args}`, Name, Started: started,
      ParentName: parentImage || null, ParentStarted: parentStarted || null,
    });
    vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: { env?: NodeJS.ProcessEnv }, done: (error: Error | null, stdout: string) => void) => {
      commands.push([file, ...args]);
      let stdout = "";
      if (file === "powershell.exe" && options.env?.OMB_PROCESS_ID) {
        expect(options.env.OMB_PROCESS_ID).toBe(String(browser));
        stdout = `${startedNow}\r\n`;
      } else if (file === "powershell.exe") {
        expect(options.env?.OMB_BROWSER_PROFILE).toBe(dir);
        stdout = JSON.stringify([
          chrome(browser, `"--user-data-dir=${dir.toUpperCase()}" --remote-debugging-port=0`),
          chrome(4201, `--type=renderer "--user-data-dir=${dir}"`),
          chrome(4300, `"--user-data-dir=${dir} backup" --remote-debugging-port=0`),
          // Another program naming the profile is never stopped.
          chrome(4400, `"--user-data-dir=${dir}"`, "python.exe"),
        ]);
      } else if (file === "tasklist") {
        stdout = parentImage ? `"${parentImage}","4100","Console","1","12,345 K"\r\n` : "INFO: No tasks are running which match the specified criteria.\r\n";
      } else if (file === "taskkill") {
        expect(args).toEqual(["/PID", String(browser), "/T", "/F"]);
        release();
      }
      queueMicrotask(() => done(null, stdout));
      return {} as ChildProcess;
    }) as unknown as typeof execFile);
    Object.defineProperty(process, "platform", { value: "win32", configurable: true });
    const killed = () => commands.filter(([file]) => file === "taskkill");
    const listed = () => commands.filter(([file, ...args]) => file === "powershell.exe" && args.at(-1)!.includes("OMB_BROWSER_PROFILE")).length;
    try {
      // No Chrome holds the profile: nothing is listed.
      await releaseBrowserProfile(dir);
      expect(commands).toEqual([]);
      hold();
      // Its daemon still runs: the Chrome is kept.
      parentImage = "agent-browser-win32-x64.exe";
      parentStarted = "2026-11-03T08:59:59.0000000Z";
      await releaseBrowserProfile(dir);
      expect(killed()).toEqual([]);
      // The next turn finds it under the same daemon without listing every process again.
      await releaseBrowserProfile(dir);
      expect(listed()).toBe(1);
      parentImage = "my-engine.exe";
      await releaseBrowserProfile(dir, "C:\\Tools\\My-Engine.exe");
      expect(killed()).toEqual([]);
      // Its daemon is gone and Windows gave the pid to a newer agent-browser, but by now the browser's pid runs another
      // program too: nothing is stopped.
      parentImage = "agent-browser-win32-x64.exe";
      parentStarted = "2026-11-03T09:00:05.0000000Z";
      startedNow = "2026-11-03T09:00:07.0000000Z";
      await releaseBrowserProfile(dir);
      expect(killed()).toEqual([]);
      // The same Chrome still holds the profile: the browser process and its helpers go.
      startedNow = started;
      await releaseBrowserProfile(dir);
      expect(killed()).toEqual([["taskkill", "/PID", String(browser), "/T", "/F"]]);
      expect(existsSync(join(dir, "lockfile"))).toBe(false);
      expect(readFileSync(join(dir, "Default", "Cookies"), "utf8")).toBe("signed in");
    } finally {
      release();
    }
  });

  it("keeps a daemon's own Chrome that the launcher batch file runs under cmd.exe, and stops it once that shell or daemon is gone", async () => {
    const root = mkdtempSync(join(tmpdir(), "omb-win-launcher-"));
    homes.push(root);
    const dir = join(root, "Kept Profile");
    mkdirSync(dir, { recursive: true });
    mkdirSync(join(dir, "lockfile"));
    const started = "2026-11-03T09:00:00.0000000Z";
    const browser = process.pid;
    let daemonRuns = true;
    let shellRuns = true;
    let daemonStarted = "2026-11-03T08:59:59.0000000Z";
    const commands: string[][] = [];
    vi.mocked(execFile).mockImplementation(((file: string, args: string[], _options: unknown, done: (error: Error | null, stdout: string) => void) => {
      commands.push([file, ...args]);
      let stdout = "";
      if (file === "powershell.exe" && args.at(-1)!.includes("OMB_PROCESS_ID")) stdout = `${started}\r\n`;
      else if (file === "powershell.exe") {
        stdout = JSON.stringify([{
          ProcessId: browser, ParentProcessId: 4100, CommandLine: `"C:\\Chrome\\chrome.exe" "--user-data-dir=${dir}" --remote-debugging-port=0`,
          Name: "chrome.exe", Started: started, ParentName: shellRuns ? "cmd.exe" : null, ParentStarted: shellRuns ? "2026-11-03T08:59:59.5000000Z" : null,
          GrandparentId: daemonRuns ? 4000 : null, GrandparentName: daemonRuns ? "agent-browser.exe" : null,
          GrandparentStarted: daemonRuns ? daemonStarted : null,
        }]);
      } else if (file === "tasklist") {
        const image = daemonRuns && args[1] === "PID eq 4000" ? "agent-browser.exe" : shellRuns && args[1] === "PID eq 4100" ? "cmd.exe" : null;
        stdout = image ? `"${image}","${args[1].slice(7)}","Console","1","12,345 K"\r\n` : "INFO: No tasks are running which match the specified criteria.\r\n";
      } else if (file === "taskkill") rmSync(join(dir, "lockfile"), { recursive: true, force: true });
      queueMicrotask(() => done(null, stdout));
      return {} as ChildProcess;
    }) as unknown as typeof execFile);
    Object.defineProperty(process, "platform", { value: "win32", configurable: true });
    const engine = "C:\\OpenMausBot\\resources\\browser-engine\\agent-browser.exe";
    const launcher = "C:\\Users\\me\\AppData\\Roaming\\openmausbot\\browser\\chrome-launcher.cmd";
    const killed = () => commands.filter(([file]) => file === "taskkill");
    await releaseBrowserProfile(dir, engine, launcher);
    expect(killed(), "a live daemon's Chrome is never stopped").toEqual([]);
    await releaseBrowserProfile(dir, engine, launcher);
    expect(commands.filter(([file]) => file === "powershell.exe"), "the kept Chrome is not listed again each turn").toHaveLength(1);
    // agent-browser stopped only the shell: its Chrome still holds the profile under a daemon that runs.
    shellRuns = false;
    await releaseBrowserProfile(dir, engine, launcher);
    expect(killed()).toEqual([["taskkill", "/PID", String(browser), "/T", "/F"]]);
    expect(existsSync(join(dir, "lockfile"))).toBe(false);
    mkdirSync(join(dir, "lockfile"));
    shellRuns = true;
    daemonRuns = false;
    await releaseBrowserProfile(dir, engine, launcher);
    expect(killed()).toHaveLength(2);
    expect(existsSync(join(dir, "lockfile"))).toBe(false);
    // Windows gave the dead daemon's pid to a newer agent-browser, started after the shell: that one is not its daemon.
    mkdirSync(join(dir, "lockfile"));
    daemonRuns = true;
    daemonStarted = "2026-11-03T09:00:01.0000000Z";
    await releaseBrowserProfile(dir, engine, launcher);
    expect(killed()).toHaveLength(3);
    expect(existsSync(join(dir, "lockfile"))).toBe(false);
  });

  it.runIf(process.platform === "win32")("lists a real browser under cmd.exe with its daemon, keeps it for that daemon and stops it for another", async () => {
    const root = mkdtempSync(join(tmpdir(), "omb-win-cmd-"));
    homes.push(root);
    const dir = join(root, "Kept Profile");
    mkdirSync(join(dir, "lockfile"), { recursive: true });
    const ready = join(root, "ready").replaceAll("\\", "/");
    // This test's node is the daemon; the node under cmd.exe names the profile as the bot's Chrome does.
    const browser = `require('node:fs').writeFileSync('${ready}', ''); ${IDLE}`;
    const shell = spawn("cmd.exe", ["/d", "/s", "/c", `""${process.execPath}" -e "${browser}" -- "--user-data-dir=${dir}""`], { stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true });
    const exited = new Promise((resolve) => shell.once("exit", resolve));
    try {
      await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 15_000 });
      const killed = () => vi.mocked(execFile).mock.calls.filter(([file]) => file === "taskkill");
      vi.mocked(execFile).mockClear();
      const listed = () => vi.mocked(execFile).mock.calls.filter(([file, args]) => file === "powershell.exe" && String((args as string[]).at(-1)).includes("OMB_BROWSER_PROFILE"));
      await releaseBrowserProfile(dir, process.execPath, process.execPath);
      await releaseBrowserProfile(dir, process.execPath, process.execPath);
      expect(killed(), "its daemon's browser is kept").toEqual([]);
      expect(listed(), "the second turn finds the kept browser without listing again").toHaveLength(1);
      rmSync(join(dir, "lockfile"), { recursive: true, force: true });
      await releaseBrowserProfile(dir);
      mkdirSync(join(dir, "lockfile"));
      await releaseBrowserProfile(dir, "C:\\Tools\\agent-browser.exe", process.execPath);
      expect(killed()).toHaveLength(1);
      await Promise.race([exited, new Promise((_, reject) => setTimeout(() => reject(new Error("the stopped browser's shell still runs")), 10_000))]);
    } finally {
      try { execFileSync("taskkill", ["/PID", String(shell.pid), "/T", "/F"], { stdio: "ignore" }); } catch {}
    }
  }, 60_000);
});