mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
6d907cc86b
A `launch:` on Chromium now starts its own Electron instance, so nested e2e flows and mid-flow relaunches work as they do on iOS/Android. Previously the runner booted one app per run and rejected any second chromium launch. A chromium device id _is_ its CDP port, so a launch that starts an app from scratch has to boot one and move the run onto it: `state.device` became reassignable (steps already read it per call), and `state.owned` tracks what the runner booted, torn down in reverse at run end. An instance the runner did not boot — pinned via `--device`, or auto-detected — is attached to, never killed. Relaunching the same app kills its instance and waits for the process to actually exit, or the replacement loses a race against the app's single-instance lock. The runner needs a device before step 1, so `resolveRunDevice` follows a leading `run:` chain to find the launch the run _begins_ with: a fragment composing a chromium e2e flow now boots instead of failing with "No booted chromium device found". A launch declaring no app id for the run's platform stays an error — the run's platform is fixed start to finish. Also fixes a pre-existing teardown leak: the spawned handle is the npm `electron` node wrapper, not Electron, so signalling it left the app and its helpers running (reparented to launchd, still in the dock). Teardown now sweeps the child's process group. **Behavior change:** a fragment leading with `run:` into a chromium e2e flow now boots a fresh instance where it previously attached to a running one; pass `--device` to keep attaching. ## Tests Cover the boot/attach/settle three-way, teardown ordering and the group sweep, device rebinding after a nested launch, same-app retire ordering, cancellation mid-boot, both "no chromium app declared" guards, and the leading-`run:` chain (multi-hop, cyclic, no-launch). The guard, exit-wait and sweep tests are mutation-checked.
438 lines
16 KiB
TypeScript
438 lines
16 KiB
TypeScript
/**
|
|
* Teardown-path tests for runner-booted Chromium: `killChromiumByPort` must
|
|
* prefer the ChildProcess handle retained at boot — whose exitCode/signalCode
|
|
* guard skips the delayed leader SIGKILL once the child has exited, while the
|
|
* group sweep escalates on a signal-0 liveness probe of the group — and only
|
|
* fall back to group signalling on the raw pid (group SIGTERM, then a
|
|
* probe-gated group SIGKILL) once the child has left the registry.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
|
|
import { EventEmitter } from "node:events";
|
|
import * as fs from "node:fs";
|
|
import * as http from "node:http";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
|
|
const spawnMock = vi.fn();
|
|
|
|
vi.mock("node:child_process", async () => {
|
|
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
|
return {
|
|
...actual,
|
|
spawn: (cmd: string, args: string[], opts: unknown) => spawnMock(cmd, args, opts),
|
|
};
|
|
});
|
|
// Keep trackChromiumPort from persisting booted ports to on-disk state.
|
|
vi.mock("../src/utils/chromium-discovery", () => ({
|
|
trackChromiumPort: vi.fn(),
|
|
untrackChromiumPort: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
bootElectronApp,
|
|
killChromiumByPort,
|
|
killChromiumByPortAndWait,
|
|
} from "../src/tools/devices/boot-electron";
|
|
|
|
const FAKE_PID = 4242;
|
|
|
|
interface FakeChild extends EventEmitter {
|
|
pid: number | undefined;
|
|
stderr: EventEmitter;
|
|
unref: () => void;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
exitCode: number | null;
|
|
signalCode: NodeJS.Signals | null;
|
|
}
|
|
|
|
function makeFakeChild(): FakeChild {
|
|
const ee = new EventEmitter() as FakeChild;
|
|
ee.pid = FAKE_PID;
|
|
ee.stderr = new EventEmitter();
|
|
ee.unref = () => {};
|
|
ee.kill = vi.fn(() => true);
|
|
ee.exitCode = null;
|
|
ee.signalCode = null;
|
|
return ee;
|
|
}
|
|
|
|
let appDir: string;
|
|
beforeAll(() => {
|
|
// resolveLauncher() fs-checks the app path before spawn, so the test needs
|
|
// a real directory on disk. The spawn itself is mocked, so the contents
|
|
// don't matter — only the path's existence.
|
|
appDir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-boot-electron-kill-test-"));
|
|
fs.writeFileSync(
|
|
path.join(appDir, "package.json"),
|
|
JSON.stringify({ name: "fake-electron-app", main: "main.js" })
|
|
);
|
|
fs.writeFileSync(path.join(appDir, "main.js"), "// fake\n");
|
|
});
|
|
afterAll(() => {
|
|
if (appDir) fs.rmSync(appDir, { recursive: true, force: true });
|
|
});
|
|
|
|
beforeEach(() => {
|
|
spawnMock.mockReset();
|
|
// Teardown sweeps the child's process group. The fake children carry a
|
|
// stand-in pid, so real signals must never escape the test; individual tests
|
|
// re-spy when they need to assert on the calls.
|
|
vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
/**
|
|
* Minimal CDP stub: only /json/version, which is all waitForCdpReady probes.
|
|
* Booting against it lands the fake child in the boot-electron handle
|
|
* registry, which is the state under test.
|
|
*/
|
|
async function bootFakeChild(): Promise<{ child: FakeChild; port: number; close: () => void }> {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const srv = http.createServer((req, res) => {
|
|
res.setHeader("content-type", "application/json");
|
|
if (req.url === "/json/version") {
|
|
res.end(JSON.stringify({ "Browser": "Chrome/Test", "Protocol-Version": "1.3" }));
|
|
return;
|
|
}
|
|
res.statusCode = 404;
|
|
res.end();
|
|
});
|
|
await new Promise<void>((resolve) => srv.listen(0, "127.0.0.1", resolve));
|
|
const { port } = srv.address() as { port: number };
|
|
|
|
await bootElectronApp({ appPath: appDir, port, readyTimeoutMs: 5000 });
|
|
return { child, port, close: () => srv.close() };
|
|
}
|
|
|
|
describe("killChromiumByPort — handle path", () => {
|
|
it("kills through the retained handle: SIGTERM, then SIGKILL once the grace period elapses with the child still running", async () => {
|
|
const { child, port, close } = await bootFakeChild();
|
|
try {
|
|
vi.useFakeTimers();
|
|
killChromiumByPort(port, FAKE_PID);
|
|
|
|
// The handle path was taken (child.kill, not process.kill on a raw pid).
|
|
expect(child.kill).toHaveBeenCalledTimes(1);
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
|
|
// Child ignores the SIGTERM (exitCode stays null) → escalate after 2s.
|
|
vi.advanceTimersByTime(2000);
|
|
expect(child.kill).toHaveBeenCalledTimes(2);
|
|
expect(child.kill).toHaveBeenLastCalledWith("SIGKILL");
|
|
} finally {
|
|
close();
|
|
}
|
|
});
|
|
|
|
it("skips the delayed SIGKILL when the child exits within the grace period (the recycled-pid guard)", async () => {
|
|
const { child, port, close } = await bootFakeChild();
|
|
try {
|
|
vi.useFakeTimers();
|
|
killChromiumByPort(port, FAKE_PID);
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
|
|
// The SIGTERM lands: the child exits before the escalation timer fires.
|
|
child.exitCode = 0;
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
expect(child.kill).toHaveBeenCalledTimes(1); // no SIGKILL
|
|
} finally {
|
|
close();
|
|
}
|
|
});
|
|
|
|
it("sends no group SIGTERM while the handle is live — the wrapper forwards it for a clean quit", async () => {
|
|
// A group SIGTERM here would hit the browser and every Chromium helper
|
|
// directly, bypassing Electron's quit sequence (before-quit/will-quit
|
|
// never run). The handle SIGTERM alone must carry the graceful request.
|
|
const { child, port, close } = await bootFakeChild();
|
|
try {
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
vi.useFakeTimers();
|
|
|
|
killChromiumByPort(port, FAKE_PID);
|
|
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
expect(killSpy).not.toHaveBeenCalledWith(-FAKE_PID, "SIGTERM");
|
|
} finally {
|
|
close();
|
|
}
|
|
});
|
|
|
|
it("sends the group SIGTERM when the child already exited, so orphaned survivors get a graceful request", async () => {
|
|
// The boot-failure path's shape: the child died (or was never viable)
|
|
// before the kill, so child.kill reaches nothing and the group SIGTERM
|
|
// must carry the graceful request to any surviving helpers.
|
|
const { child, port, close } = await bootFakeChild();
|
|
try {
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
vi.useFakeTimers();
|
|
child.exitCode = 0;
|
|
|
|
killChromiumByPort(port, FAKE_PID);
|
|
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, "SIGTERM");
|
|
} finally {
|
|
close();
|
|
}
|
|
});
|
|
|
|
it("escalates the group to SIGKILL when anything in it outlives the grace period", async () => {
|
|
const { child, port, close } = await bootFakeChild();
|
|
try {
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
vi.useFakeTimers();
|
|
killChromiumByPort(port, FAKE_PID);
|
|
// The leader exits promptly; a helper does not. The old exit-status guard
|
|
// alone would skip the escalation and strand it.
|
|
child.exitCode = 0;
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
expect(child.kill).toHaveBeenCalledTimes(1); // no SIGKILL on the leader
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, 0); // group liveness probe
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, "SIGKILL");
|
|
} finally {
|
|
close();
|
|
}
|
|
});
|
|
|
|
it("does not escalate a group that already emptied", async () => {
|
|
const { child, port, close } = await bootFakeChild();
|
|
try {
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(((
|
|
_pid: number,
|
|
signal: NodeJS.Signals | 0
|
|
) => {
|
|
if (signal === 0) {
|
|
const err: NodeJS.ErrnoException = new Error("no such process");
|
|
err.code = "ESRCH";
|
|
throw err;
|
|
}
|
|
return true;
|
|
}) as typeof process.kill);
|
|
vi.useFakeTimers();
|
|
killChromiumByPort(port, FAKE_PID);
|
|
child.exitCode = 0;
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
const signals = killSpy.mock.calls.map((c) => c[1]);
|
|
expect(signals).not.toContain("SIGKILL");
|
|
} finally {
|
|
close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("killChromiumByPort — raw-pid fallback", () => {
|
|
it("falls back to group signalling after the child's exit evicted the handle, and the liveness probe suppresses the SIGKILL", async () => {
|
|
const { child, port, close } = await bootFakeChild();
|
|
try {
|
|
// Natural exit (e.g. the user closed the window): the cleanup listener
|
|
// must drop the handle from the registry.
|
|
child.emit("exit", 0, null);
|
|
|
|
const killSpy = vi
|
|
.spyOn(process, "kill")
|
|
.mockImplementation((_pid: number, signal?: string | number) => {
|
|
if (signal === 0) {
|
|
// By probe time the group is empty.
|
|
const err = new Error("kill ESRCH") as NodeJS.ErrnoException;
|
|
err.code = "ESRCH";
|
|
throw err;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
killChromiumByPort(port, FAKE_PID);
|
|
|
|
// Handle gone → raw-pid fallback, never the stale ChildProcess.
|
|
expect(child.kill).not.toHaveBeenCalled();
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, "SIGTERM");
|
|
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
// The 0-probe reported ESRCH → the SIGKILL must be suppressed so it
|
|
// cannot land on a recycled pgid.
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, 0);
|
|
const signals = killSpy.mock.calls.map((c) => c[1]);
|
|
expect(signals).not.toContain("SIGKILL");
|
|
} finally {
|
|
close();
|
|
}
|
|
});
|
|
|
|
it("escalates the fallback to a group SIGKILL when the group is still alive at probe time", () => {
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
vi.useFakeTimers();
|
|
|
|
// Port never registered → straight to the pid fallback.
|
|
killChromiumByPort(59999, FAKE_PID);
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, "SIGTERM");
|
|
|
|
vi.advanceTimersByTime(2000);
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, 0);
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, "SIGKILL");
|
|
});
|
|
|
|
it("still arms the group escalation when the leader pid is already gone but the group is not", async () => {
|
|
// The asymmetric leak: the wrapper died independently (external kill -9,
|
|
// OOM kill), evicting the handle, and its bare pid reports ESRCH — while
|
|
// the browser and helpers keep the group alive. A leader-only fallback
|
|
// would return early here and never signal any of them.
|
|
const { child, port, close } = await bootFakeChild();
|
|
try {
|
|
child.emit("exit", null, "SIGKILL");
|
|
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(((
|
|
pid: number,
|
|
_signal: NodeJS.Signals | 0
|
|
) => {
|
|
if (pid === FAKE_PID) {
|
|
const err: NodeJS.ErrnoException = new Error("no such process");
|
|
err.code = "ESRCH";
|
|
throw err; // the leader is gone; only group members survive
|
|
}
|
|
return true;
|
|
}) as typeof process.kill);
|
|
|
|
vi.useFakeTimers();
|
|
killChromiumByPort(port, FAKE_PID);
|
|
|
|
expect(child.kill).not.toHaveBeenCalled();
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, "SIGTERM");
|
|
|
|
vi.advanceTimersByTime(2000);
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, 0);
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, "SIGKILL");
|
|
} finally {
|
|
close();
|
|
}
|
|
});
|
|
|
|
it("is a no-op when no handle is registered and no pid is provided", () => {
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
killChromiumByPort(58888);
|
|
expect(killSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
/** When the faked process group starts reporting ESRCH, in the raw-pid poll tests. */
|
|
const GONE_AFTER_MS = 40;
|
|
|
|
describe("killChromiumByPortAndWait", () => {
|
|
it("resolves only once the child has actually exited", async () => {
|
|
const { child, port, close } = await bootFakeChild();
|
|
try {
|
|
let settled = false;
|
|
const waited = killChromiumByPortAndWait(port, FAKE_PID).then(() => {
|
|
settled = true;
|
|
});
|
|
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
// The signal is out, but the process is still holding its lock. Flushed
|
|
// through a macrotask so a wait that never awaits the child at all shows
|
|
// up here as already-settled rather than passing on microtask timing.
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(settled).toBe(false);
|
|
|
|
child.exitCode = 0;
|
|
child.emit("exit", 0, null);
|
|
await waited;
|
|
expect(settled).toBe(true);
|
|
} finally {
|
|
close();
|
|
}
|
|
});
|
|
|
|
it("gives up after the timeout so a wedged process can't stall a run", async () => {
|
|
const { child, port, close } = await bootFakeChild();
|
|
try {
|
|
// Child never emits exit — the wait must still resolve.
|
|
await killChromiumByPortAndWait(port, FAKE_PID, 20);
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
} finally {
|
|
close();
|
|
}
|
|
});
|
|
|
|
it("returns immediately when the child already exited", async () => {
|
|
const { child, port, close } = await bootFakeChild();
|
|
try {
|
|
child.exitCode = 0;
|
|
// No exit event is ever emitted here, so a wait that didn't notice the
|
|
// already-exited child would hang past the test timeout.
|
|
await killChromiumByPortAndWait(port, FAKE_PID, 30_000);
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
} finally {
|
|
close();
|
|
}
|
|
});
|
|
|
|
it("polls the group when no handle is held, and stops once it reports ESRCH", async () => {
|
|
let alive = true;
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(((
|
|
_pid: number,
|
|
signal: NodeJS.Signals | 0
|
|
) => {
|
|
if (signal === 0 && !alive) {
|
|
const err: NodeJS.ErrnoException = new Error("no such process");
|
|
err.code = "ESRCH";
|
|
throw err;
|
|
}
|
|
return true;
|
|
}) as typeof process.kill);
|
|
setTimeout(() => {
|
|
alive = false;
|
|
}, GONE_AFTER_MS).unref();
|
|
|
|
// Port never registered → the raw-pid path, which has no exit event to await.
|
|
const started = Date.now();
|
|
await killChromiumByPortAndWait(59998, FAKE_PID, 2000);
|
|
const elapsed = Date.now() - started;
|
|
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, "SIGTERM");
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, 0);
|
|
// Two-sided: it must not return while the group is still alive (the whole
|
|
// point — the replacement would race the lock), and must not burn the whole
|
|
// budget once it is gone (every handle-less retire would stall the flow).
|
|
expect(elapsed).toBeGreaterThanOrEqual(GONE_AFTER_MS);
|
|
expect(elapsed).toBeLessThan(1000);
|
|
});
|
|
|
|
it("does not return while the group is still alive, even though the leader itself is gone", async () => {
|
|
// The single-instance lock the wait guards is held by the browser — a
|
|
// group member — so leader liveness is the wrong signal: here the bare pid
|
|
// reports ESRCH from the start while the group lives on for a while.
|
|
let groupAlive = true;
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(((
|
|
pid: number,
|
|
signal: NodeJS.Signals | 0
|
|
) => {
|
|
const err: NodeJS.ErrnoException = new Error("no such process");
|
|
err.code = "ESRCH";
|
|
if (pid === FAKE_PID) throw err; // the leader is already dead
|
|
if (signal === 0 && !groupAlive) throw err;
|
|
return true;
|
|
}) as typeof process.kill);
|
|
setTimeout(() => {
|
|
groupAlive = false;
|
|
}, GONE_AFTER_MS).unref();
|
|
|
|
const started = Date.now();
|
|
await killChromiumByPortAndWait(59997, FAKE_PID, 2000);
|
|
const elapsed = Date.now() - started;
|
|
|
|
expect(killSpy).toHaveBeenCalledWith(-FAKE_PID, 0);
|
|
expect(elapsed).toBeGreaterThanOrEqual(GONE_AFTER_MS);
|
|
expect(elapsed).toBeLessThan(1000);
|
|
});
|
|
});
|