mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-06 05:32:06 +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 fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -11,6 +11,20 @@ type NodecgProcessManagerConfig = {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
runtimeConfig: AppRuntimeConfig;
|
runtimeConfig: AppRuntimeConfig;
|
||||||
log: (...args: unknown[]) => void;
|
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 = {
|
export type NodecgProcessManager = {
|
||||||
@@ -26,33 +40,36 @@ export function createNodecgProcessManager({
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
runtimeConfig,
|
runtimeConfig,
|
||||||
log,
|
log,
|
||||||
|
deps,
|
||||||
}: NodecgProcessManagerConfig): NodecgProcessManager {
|
}: NodecgProcessManagerConfig): NodecgProcessManager {
|
||||||
|
const resolvedDeps = resolveDeps(deps);
|
||||||
|
|
||||||
let nodecgProcess: ChildProcess | null = null;
|
let nodecgProcess: ChildProcess | null = null;
|
||||||
let stopNodeCGPromise: Promise<void> | null = null;
|
let stopNodeCGPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
const startNodeCG = (): ChildProcess => {
|
const startNodeCG = (): ChildProcess => {
|
||||||
validateNodeCGInstall(nodecgPath, runtimeConfig.bundleName);
|
validateNodeCGInstall(nodecgPath, runtimeConfig.bundleName, resolvedDeps.pathExists);
|
||||||
|
|
||||||
const indexPath = path.join(nodecgPath, "index.js");
|
const indexPath = path.join(nodecgPath, "index.js");
|
||||||
const child = spawn(process.execPath, [indexPath], {
|
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
|
||||||
cwd: nodecgPath,
|
cwd: nodecgPath,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...resolvedDeps.env,
|
||||||
NODE_ENV: isDev ? "development" : "production",
|
NODE_ENV: isDev ? "development" : "production",
|
||||||
NODECG_PORT: runtimeConfig.nodecgPort,
|
NODECG_PORT: runtimeConfig.nodecgPort,
|
||||||
ELECTRON_RUN_AS_NODE: "1",
|
ELECTRON_RUN_AS_NODE: "1",
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: process.platform !== "win32",
|
detached: resolvedDeps.platform !== "win32",
|
||||||
shell: process.platform === "win32",
|
shell: resolvedDeps.platform === "win32",
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stdout?.on("data", (chunk) => {
|
child.stdout?.on("data", (chunk) => {
|
||||||
process.stdout.write(String(chunk));
|
resolvedDeps.stdoutWrite(String(chunk));
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stderr?.on("data", (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}`);
|
log(`NodeCG started with pid=${child.pid} using ${NODE_RUNTIME_NAME}`);
|
||||||
@@ -73,7 +90,7 @@ export function createNodecgProcessManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(baseUrl, { method: "GET" });
|
const response = await resolvedDeps.fetchUrl(baseUrl, { method: "GET" });
|
||||||
if (response.ok || response.status === 404) {
|
if (response.ok || response.status === 404) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -81,7 +98,7 @@ export function createNodecgProcessManager({
|
|||||||
// retry until timeout
|
// retry until timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleep(500);
|
await sleep(500, resolvedDeps.setTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Timeout esperando NodeCG en ${baseUrl} (${runtimeConfig.startupTimeoutMs}ms).`);
|
throw new Error(`Timeout esperando NodeCG en ${baseUrl} (${runtimeConfig.startupTimeoutMs}ms).`);
|
||||||
@@ -105,7 +122,7 @@ export function createNodecgProcessManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
log(`Stopping NodeCG pid=${pid}`);
|
log(`Stopping NodeCG pid=${pid}`);
|
||||||
killNodeCGProcessTree(pid, "SIGTERM", log);
|
killNodeCGProcessTree(pid, "SIGTERM", log, resolvedDeps);
|
||||||
|
|
||||||
stopNodeCGPromise = new Promise((resolve) => {
|
stopNodeCGPromise = new Promise((resolve) => {
|
||||||
const complete = () => {
|
const complete = () => {
|
||||||
@@ -120,10 +137,10 @@ export function createNodecgProcessManager({
|
|||||||
complete();
|
complete();
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
resolvedDeps.setTimer(() => {
|
||||||
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
||||||
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
|
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));
|
}, 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 indexPath = path.join(nodecgPath, "index.js");
|
||||||
const nodecgBootstrapPath = path.join(nodecgPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
const nodecgBootstrapPath = path.join(nodecgPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
||||||
const bundlePath = path.join(nodecgPath, "bundles", bundleName);
|
const bundlePath = path.join(nodecgPath, "bundles", bundleName);
|
||||||
|
|
||||||
if (!fs.existsSync(nodecgPath)) {
|
if (!pathExists(nodecgPath)) {
|
||||||
throw new Error(`No existe la carpeta NodeCG: ${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.`);
|
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(
|
throw new Error(
|
||||||
[
|
[
|
||||||
"NodeCG está presente pero faltan dependencias internas.",
|
"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(
|
throw new Error(
|
||||||
[
|
[
|
||||||
`No se encontró el bundle '${bundleName}'.`,
|
`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 {
|
function killNodeCGProcessTree(
|
||||||
if (process.platform === "win32") {
|
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 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",
|
stdio: "ignore",
|
||||||
shell: true,
|
shell: true,
|
||||||
});
|
});
|
||||||
@@ -190,11 +227,11 @@ function killNodeCGProcessTree(pid: number, signal: NodeJS.Signals, log: (...arg
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
process.kill(-pid, signal);
|
deps.killProcess(-pid, signal);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, signal);
|
deps.killProcess(pid, signal);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
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) => {
|
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