Files
Recordly/electron/devProcessLifecycle.test.ts
T
webadderall e5efc06088 Fix Recordly 2.0 part 6 follow-up review findings
Preserve existing PR history and integrate validated second-cycle fixes.
2026-11-08 08:52:14 +11:00

191 lines
6.2 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createElectronDevLifecycle,
type ElectronDevQueue,
} from "../scripts/electronDevProcessLifecycle";
function fixture() {
const children: ChildProcess[] = [];
let current: ChildProcess | undefined;
const makeChild = () => {
const child = Object.assign(new EventEmitter(), {
pid: children.length + 100,
exitCode: null,
signalCode: null,
kill: vi.fn(() => true),
}) as unknown as ChildProcess;
children.push(child);
return child;
};
const start = vi.fn(async () => {
current = makeChild();
});
const stopProcessTree = vi.fn();
const host = {
getProcess: () => current,
clearProcess: (child: ChildProcess) => {
if (child === current) current = undefined;
},
stopProcessTree,
};
const finish = (child: ChildProcess) => {
child.exitCode = 0;
child.emit("exit", 0, null);
};
return {
children,
start,
host,
finish,
setCurrent: (child: ChildProcess) => {
current = child;
},
makeChild,
};
}
afterEach(() => vi.useRealTimers());
describe("Electron development process ownership", () => {
it("adopts the current editor after config reload without stopping or replacing it", async () => {
const f = fixture();
f.setCurrent(f.makeChild());
const lifecycle = createElectronDevLifecycle(f.host);
await lifecycle.mainBuilt(f.start);
await lifecycle.preloadBuilt(f.start);
expect(f.start).not.toHaveBeenCalled();
expect(f.host.stopProcessTree).not.toHaveBeenCalled();
});
it("starts only one process for overlapping main and preload builds", async () => {
const f = fixture();
const lifecycle = createElectronDevLifecycle(f.host);
await Promise.all([lifecycle.mainBuilt(f.start), lifecycle.preloadBuilt(f.start)]);
expect(f.start).toHaveBeenCalledTimes(1);
expect(f.host.stopProcessTree).not.toHaveBeenCalled();
});
it("waits for the old process to exit before starting the replacement", async () => {
const f = fixture();
const lifecycle = createElectronDevLifecycle(f.host);
await lifecycle.mainBuilt(f.start);
const old = f.host.getProcess()!;
const remove = vi.spyOn(old, "removeListener");
const restart = lifecycle.mainBuilt(f.start);
await Promise.resolve();
expect(f.host.stopProcessTree).toHaveBeenCalledWith(old.pid);
expect(remove).toHaveBeenCalledWith("exit", process.exit);
expect(f.start).toHaveBeenCalledTimes(1);
f.finish(old);
await restart;
expect(f.start).toHaveBeenCalledTimes(2);
expect(f.host.getProcess()).not.toBe(old);
});
it("shares its queue across config reloads and never overlaps live processes", async () => {
const f = fixture();
const queue: ElectronDevQueue = { pending: Promise.resolve() };
const first = createElectronDevLifecycle(f.host, queue);
await first.mainBuilt(f.start);
const old = f.host.getProcess()!;
const restart = first.mainBuilt(f.start);
await Promise.resolve();
const reloaded = createElectronDevLifecycle(f.host, queue);
const adopt = reloaded.mainBuilt(f.start);
const preload = reloaded.preloadBuilt(f.start);
expect(f.start).toHaveBeenCalledTimes(1);
f.finish(old);
await Promise.all([restart, adopt, preload]);
expect(f.start).toHaveBeenCalledTimes(2);
expect(f.children.filter((child) => child.exitCode === null)).toHaveLength(1);
});
it("force-stops a stuck owned process before replacing it", async () => {
vi.useFakeTimers();
const f = fixture();
const lifecycle = createElectronDevLifecycle(f.host);
await lifecycle.mainBuilt(f.start);
const old = f.host.getProcess()!;
vi.mocked(old.kill).mockImplementation(() => {
f.finish(old);
return true;
});
const restart = lifecycle.mainBuilt(f.start);
await vi.advanceTimersByTimeAsync(3000);
await restart;
expect(old.kill).toHaveBeenCalledWith("SIGKILL");
expect(f.start).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
});
it("refuses to create another copy when the old process cannot be stopped", async () => {
vi.useFakeTimers();
const f = fixture();
const lifecycle = createElectronDevLifecycle(f.host);
await lifecycle.mainBuilt(f.start);
const failure = expect(lifecycle.mainBuilt(f.start)).rejects.toThrow("restart cancelled");
await vi.advanceTimersByTimeAsync(5000);
await failure;
expect(f.start).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it("allows a later build to retry after a failed startup", async () => {
const f = fixture();
const lifecycle = createElectronDevLifecycle(f.host);
f.start.mockRejectedValueOnce(new Error("spawn failed"));
await expect(lifecycle.mainBuilt(f.start)).rejects.toThrow("spawn failed");
await lifecycle.mainBuilt(f.start);
expect(f.children).toHaveLength(1);
});
it("reaps real child processes through a burst of rebuilds", async () => {
const children: ChildProcess[] = [];
let current: ChildProcess | undefined;
const lifecycle = createElectronDevLifecycle({
getProcess: () => current,
clearProcess: (child) => {
if (current === child) current = undefined;
},
stopProcessTree: (pid) => {
process.kill(pid, "SIGTERM");
},
});
const start = async () => {
expect(
children.filter((child) => child.exitCode === null && child.signalCode === null),
).toHaveLength(0);
current = spawn(
process.execPath,
["-e", 'process.send("ready"); setInterval(() => {}, 1000)'],
{ stdio: ["ignore", "ignore", "ignore", "ipc"] },
);
children.push(current);
await once(current, "message");
};
try {
await Promise.all([lifecycle.mainBuilt(start), lifecycle.preloadBuilt(start)]);
await Promise.all([
lifecycle.mainBuilt(start),
lifecycle.mainBuilt(start),
lifecycle.preloadBuilt(start),
]);
expect(children).toHaveLength(3);
expect(
children.filter((child) => child.exitCode === null && child.signalCode === null),
).toEqual([current]);
} finally {
await Promise.all(
children.map(async (child) => {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = once(child, "exit");
child.kill("SIGKILL");
await exited;
}),
);
}
});
});