diff --git a/src/main/nodecg/process-manager.ts b/src/main/nodecg/process-manager.ts index 91e2db2..6bc6326 100644 --- a/src/main/nodecg/process-manager.ts +++ b/src/main/nodecg/process-manager.ts @@ -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; +}; + +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 | 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 { + 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, +): 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 { +function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise { return new Promise((resolve) => { - setTimeout(resolve, ms); + setTimer(resolve, ms); }); } diff --git a/src/tests/process-manager.test.ts b/src/tests/process-manager.test.ts new file mode 100644 index 0000000..54f7503 --- /dev/null +++ b/src/tests/process-manager.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; + +import { AppRuntimeConfig } from "../main/config/runtime-config"; +import { createNodecgProcessManager } from "../main/nodecg/process-manager"; + +class MockChildProcess extends EventEmitter { + pid: number | undefined; + killed = false; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + + constructor(pid: number) { + super(); + this.pid = pid; + } +} + +function getBaseConfig(): AppRuntimeConfig { + return { + title: "Scoreko", + userModelId: "com.scoreko.desktop", + nodecgPort: "9090", + bundleName: "scoreko-dev", + dashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true", + loadingRoute: "dashboard/loading/main.html?standalone=true", + loadDelayMs: 10000, + startupTimeoutMs: 100, + nodecgKillTimeoutMs: 10, + }; +} + +test("startNodeCG valida instalación de NodeCG antes de arrancar", () => { + const manager = createNodecgProcessManager({ + isDev: true, + nodecgPath: "/fake/nodecg", + baseUrl: "http://127.0.0.1:9090", + runtimeConfig: getBaseConfig(), + log: () => undefined, + deps: { + pathExists: () => false, + spawnProcess: () => { + throw new Error("no debe intentar arrancar si la validación falla"); + }, + }, + }); + + assert.throws(() => { + manager.startNodeCG(); + }, /No existe la carpeta NodeCG/); +}); + +test("waitForNodeCGReady resuelve cuando el endpoint responde 404", async () => { + const child = new MockChildProcess(4321); + const manager = createNodecgProcessManager({ + isDev: true, + nodecgPath: "/fake/nodecg", + baseUrl: "http://127.0.0.1:9090", + runtimeConfig: getBaseConfig(), + log: () => undefined, + deps: { + pathExists: () => true, + spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, + fetchUrl: async () => ({ ok: false, status: 404 } as Response), + setTimer: ((handler: (...args: unknown[]) => void, _timeoutMs: number) => { + handler(); + return 0 as never; + }), + stdoutWrite: () => undefined, + stderrWrite: () => undefined, + }, + }); + + manager.startNodeCG(); + await assert.doesNotReject(async () => { + await manager.waitForNodeCGReady(Date.now()); + }); +}); + +test("stopNodeCG envía SIGTERM y luego SIGKILL si el proceso no sale", async () => { + const child = new MockChildProcess(9999); + const timers: Array<() => void> = []; + const killSignals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + const manager = createNodecgProcessManager({ + isDev: true, + nodecgPath: "/fake/nodecg", + baseUrl: "http://127.0.0.1:9090", + runtimeConfig: getBaseConfig(), + log: () => undefined, + deps: { + pathExists: () => true, + spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, + fetchUrl: async () => ({ ok: false, status: 404 } as Response), + killProcess: (pid, signal) => { + killSignals.push({ pid, signal }); + }, + setTimer: ((handler: (...args: unknown[]) => void, _timeoutMs: number) => { + timers.push(() => handler()); + return 0 as never; + }), + stdoutWrite: () => undefined, + stderrWrite: () => undefined, + }, + }); + + manager.startNodeCG(); + const stopPromise = manager.stopNodeCG(); + + assert.deepEqual(killSignals, [{ pid: -9999, signal: "SIGTERM" }]); + + timers.forEach((runTimer) => { + runTimer(); + }); + + assert.deepEqual(killSignals, [ + { pid: -9999, signal: "SIGTERM" }, + { pid: -9999, signal: "SIGKILL" }, + ]); + + child.emit("exit", 0, null); + await stopPromise; +});