mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 07:23:14 +00:00
188 lines
6.2 KiB
TypeScript
188 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;
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
});
|