test(nodecg): cubrir lifecycle de process-manager con mocks

This commit is contained in:
Pandipipas
2026-02-21 18:37:54 +01:00
parent e3b78cf6ba
commit d3d33324ff
2 changed files with 186 additions and 25 deletions
+62 -25
View File
@@ -1,4 +1,4 @@
import { ChildProcess, spawn } from "node:child_process";
import { ChildProcess, spawn, SpawnOptions } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
@@ -11,6 +11,20 @@ type NodecgProcessManagerConfig = {
baseUrl: string;
runtimeConfig: AppRuntimeConfig;
log: (...args: unknown[]) => void;
deps?: Partial<NodecgProcessManagerDeps>;
};
type NodecgProcessManagerDeps = {
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess;
pathExists: (candidatePath: string) => boolean;
fetchUrl: typeof fetch;
platform: NodeJS.Platform;
execPath: string;
env: NodeJS.ProcessEnv;
killProcess: (pid: number, signal: NodeJS.Signals) => void;
setTimer: (handler: () => void, timeoutMs: number) => unknown;
stdoutWrite: (chunk: string) => void;
stderrWrite: (chunk: string) => void;
};
export type NodecgProcessManager = {
@@ -26,33 +40,36 @@ export function createNodecgProcessManager({
baseUrl,
runtimeConfig,
log,
deps,
}: NodecgProcessManagerConfig): NodecgProcessManager {
const resolvedDeps = resolveDeps(deps);
let nodecgProcess: ChildProcess | null = null;
let stopNodeCGPromise: Promise<void> | null = null;
const startNodeCG = (): ChildProcess => {
validateNodeCGInstall(nodecgPath, runtimeConfig.bundleName);
validateNodeCGInstall(nodecgPath, runtimeConfig.bundleName, resolvedDeps.pathExists);
const indexPath = path.join(nodecgPath, "index.js");
const child = spawn(process.execPath, [indexPath], {
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
cwd: nodecgPath,
env: {
...process.env,
...resolvedDeps.env,
NODE_ENV: isDev ? "development" : "production",
NODECG_PORT: runtimeConfig.nodecgPort,
ELECTRON_RUN_AS_NODE: "1",
},
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
shell: process.platform === "win32",
detached: resolvedDeps.platform !== "win32",
shell: resolvedDeps.platform === "win32",
});
child.stdout?.on("data", (chunk) => {
process.stdout.write(String(chunk));
resolvedDeps.stdoutWrite(String(chunk));
});
child.stderr?.on("data", (chunk) => {
process.stderr.write(String(chunk));
resolvedDeps.stderrWrite(String(chunk));
});
log(`NodeCG started with pid=${child.pid} using ${NODE_RUNTIME_NAME}`);
@@ -73,7 +90,7 @@ export function createNodecgProcessManager({
}
try {
const response = await fetch(baseUrl, { method: "GET" });
const response = await resolvedDeps.fetchUrl(baseUrl, { method: "GET" });
if (response.ok || response.status === 404) {
return;
}
@@ -81,7 +98,7 @@ export function createNodecgProcessManager({
// retry until timeout
}
await sleep(500);
await sleep(500, resolvedDeps.setTimer);
}
throw new Error(`Timeout esperando NodeCG en ${baseUrl} (${runtimeConfig.startupTimeoutMs}ms).`);
@@ -105,7 +122,7 @@ export function createNodecgProcessManager({
}
log(`Stopping NodeCG pid=${pid}`);
killNodeCGProcessTree(pid, "SIGTERM", log);
killNodeCGProcessTree(pid, "SIGTERM", log, resolvedDeps);
stopNodeCGPromise = new Promise((resolve) => {
const complete = () => {
@@ -120,10 +137,10 @@ export function createNodecgProcessManager({
complete();
});
setTimeout(() => {
resolvedDeps.setTimer(() => {
if (processToStop.exitCode === null && processToStop.signalCode === null) {
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
killNodeCGProcessTree(pid, "SIGKILL", log);
killNodeCGProcessTree(pid, "SIGKILL", log, resolvedDeps);
}
}, Math.max(0, runtimeConfig.nodecgKillTimeoutMs));
});
@@ -139,20 +156,35 @@ export function createNodecgProcessManager({
};
}
function validateNodeCGInstall(nodecgPath: string, bundleName: string): void {
function resolveDeps(deps?: Partial<NodecgProcessManagerDeps>): NodecgProcessManagerDeps {
return {
spawnProcess: deps?.spawnProcess ?? spawn,
pathExists: deps?.pathExists ?? fs.existsSync,
fetchUrl: deps?.fetchUrl ?? fetch,
platform: deps?.platform ?? process.platform,
execPath: deps?.execPath ?? process.execPath,
env: deps?.env ?? process.env,
killProcess: deps?.killProcess ?? process.kill,
setTimer: deps?.setTimer ?? setTimeout,
stdoutWrite: deps?.stdoutWrite ?? ((chunk) => process.stdout.write(chunk)),
stderrWrite: deps?.stderrWrite ?? ((chunk) => process.stderr.write(chunk)),
};
}
function validateNodeCGInstall(nodecgPath: string, bundleName: string, pathExists: (candidatePath: string) => boolean): void {
const indexPath = path.join(nodecgPath, "index.js");
const nodecgBootstrapPath = path.join(nodecgPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
const bundlePath = path.join(nodecgPath, "bundles", bundleName);
if (!fs.existsSync(nodecgPath)) {
if (!pathExists(nodecgPath)) {
throw new Error(`No existe la carpeta NodeCG: ${nodecgPath}`);
}
if (!fs.existsSync(indexPath)) {
if (!pathExists(indexPath)) {
throw new Error(`No se encontró ${indexPath}. Copia una instalación completa de NodeCG en lib/nodecg.`);
}
if (!fs.existsSync(nodecgBootstrapPath)) {
if (!pathExists(nodecgBootstrapPath)) {
throw new Error(
[
"NodeCG está presente pero faltan dependencias internas.",
@@ -163,7 +195,7 @@ function validateNodeCGInstall(nodecgPath: string, bundleName: string): void {
);
}
if (!fs.existsSync(bundlePath)) {
if (!pathExists(bundlePath)) {
throw new Error(
[
`No se encontró el bundle '${bundleName}'.`,
@@ -174,10 +206,15 @@ function validateNodeCGInstall(nodecgPath: string, bundleName: string): void {
}
}
function killNodeCGProcessTree(pid: number, signal: NodeJS.Signals, log: (...args: unknown[]) => void): boolean {
if (process.platform === "win32") {
function killNodeCGProcessTree(
pid: number,
signal: NodeJS.Signals,
log: (...args: unknown[]) => void,
deps: Pick<NodecgProcessManagerDeps, "platform" | "spawnProcess" | "killProcess">,
): boolean {
if (deps.platform === "win32") {
const force = signal === "SIGKILL" ? "/F" : "";
const killer = spawn("taskkill", ["/pid", String(pid), "/T", ...(force ? [force] : [])], {
const killer = deps.spawnProcess("taskkill", ["/pid", String(pid), "/T", ...(force ? [force] : [])], {
stdio: "ignore",
shell: true,
});
@@ -190,11 +227,11 @@ function killNodeCGProcessTree(pid: number, signal: NodeJS.Signals, log: (...arg
}
try {
process.kill(-pid, signal);
deps.killProcess(-pid, signal);
return true;
} catch {
try {
process.kill(pid, signal);
deps.killProcess(pid, signal);
return true;
} catch {
return false;
@@ -202,8 +239,8 @@ function killNodeCGProcessTree(pid: number, signal: NodeJS.Signals, log: (...arg
}
}
function sleep(ms: number): Promise<void> {
function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
setTimer(resolve, ms);
});
}