mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-05 21:22:07 +00:00
test(nodecg): cubrir lifecycle de process-manager con mocks
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user