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", userDataDirectoryName: "scoreko", nodecgPort: "9090", bundleName: "scoreko-dev", mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true", loadingDashboardRoute: "dashboard/loading/main.html?standalone=true", loadDelayMs: 10000, startupTimeoutMs: 100, nodecgKillTimeoutMs: 10, }; } test("startNodeCG validates NodeCG installation before starting", async () => { const manager = createNodecgProcessManager({ isDev: true, nodecgRootPath: "/fake/scoreko-dev", nodecgBaseUrl: "http://127.0.0.1:9090", appConfig: getBaseConfig(), log: () => undefined, deps: { pathExists: () => false, spawnProcess: () => { throw new Error("it must not try to start if validation fails"); }, }, }); await assert.rejects(async () => { await manager.startNodecgProcess(); }, /Scoreko app folder does not exist/); }); test("startNodeCG fails when there are no read/write permissions", async () => { const manager = createNodecgProcessManager({ isDev: true, nodecgRootPath: "/fake/scoreko-dev", nodecgBaseUrl: "http://127.0.0.1:9090", appConfig: getBaseConfig(), log: () => undefined, deps: { pathExists: () => true, hasReadWriteAccess: () => false, }, }); await assert.rejects(async () => { await manager.startNodecgProcess(); }, /No read\/write permissions on scoreko app folder/); }); test("waitForNodeCGReady resolves when endpoint returns 404", async () => { const child = new MockChildProcess(4321); const manager = createNodecgProcessManager({ isDev: true, nodecgRootPath: "/fake/scoreko-dev", nodecgBaseUrl: "http://127.0.0.1:9090", appConfig: getBaseConfig(), log: () => undefined, deps: { platform: "linux", 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, probePortAvailable: async () => true, hasReadWriteAccess: () => true, }, }); await manager.startNodecgProcess(); await assert.doesNotReject(async () => { await manager.waitForNodecgReady(Date.now()); }); }); test("stopNodeCG sends SIGTERM and then SIGKILL if the process does not exit", async () => { const child = new MockChildProcess(9999); const timers: Array<() => void> = []; const killSignals: Array<{ pid: number; signal: NodeJS.Signals }> = []; const manager = createNodecgProcessManager({ isDev: true, nodecgRootPath: "/fake/scoreko-dev", nodecgBaseUrl: "http://127.0.0.1:9090", appConfig: getBaseConfig(), log: () => undefined, deps: { platform: "linux", 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, probePortAvailable: async () => true, hasReadWriteAccess: () => true, }, }); await manager.startNodecgProcess(); const stopPromise = manager.stopNodecgProcessGracefully(); 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; }); test("stopNodeCG reuses the same promise when invoked in parallel", async () => { const child = new MockChildProcess(5555); const manager = createNodecgProcessManager({ isDev: true, nodecgRootPath: "/fake/scoreko-dev", nodecgBaseUrl: "http://127.0.0.1:9090", appConfig: 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: () => undefined, setTimer: () => 0, stdoutWrite: () => undefined, stderrWrite: () => undefined, probePortAvailable: async () => true, hasReadWriteAccess: () => true, }, }); await manager.startNodecgProcess(); const firstStop = manager.stopNodecgProcessGracefully(); const secondStop = manager.stopNodecgProcessGracefully(); assert.equal(firstStop, secondStop); child.emit("exit", 0, null); await firstStop; }); test("stopNodeCG normalizes negative timeout to zero", async () => { const child = new MockChildProcess(7777); const timeouts: number[] = []; const manager = createNodecgProcessManager({ isDev: true, nodecgRootPath: "/fake/scoreko-dev", nodecgBaseUrl: "http://127.0.0.1:9090", appConfig: { ...getBaseConfig(), nodecgKillTimeoutMs: -10, }, log: () => undefined, deps: { pathExists: () => true, spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, fetchUrl: async () => ({ ok: false, status: 404 }) as Response, killProcess: () => undefined, setTimer: (handler, timeoutMs) => { timeouts.push(timeoutMs); handler(); return 0; }, stdoutWrite: () => undefined, stderrWrite: () => undefined, probePortAvailable: async () => true, hasReadWriteAccess: () => true, }, }); await manager.startNodecgProcess(); const stopPromise = manager.stopNodecgProcessGracefully(); assert.ok(timeouts.includes(0)); child.emit("exit", 0, null); await stopPromise; }); test("startNodeCG fails if the port is already in use", async () => { const manager = createNodecgProcessManager({ isDev: true, nodecgRootPath: "/fake/scoreko-dev", nodecgBaseUrl: "http://127.0.0.1:9090", appConfig: getBaseConfig(), log: () => undefined, deps: { pathExists: () => true, hasReadWriteAccess: () => true, probePortAvailable: async () => false, }, }); await assert.rejects(async () => { await manager.startNodecgProcess(); }, /is already in use/); }); test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness", async () => { const child = new MockChildProcess(4242); const manager = createNodecgProcessManager({ isDev: true, nodecgRootPath: "/fake/scoreko-dev", nodecgBaseUrl: "http://127.0.0.1:9090", appConfig: getBaseConfig(), log: () => undefined, deps: { pathExists: () => true, platform: "linux", spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, fetchUrl: async () => { child.emit("exit", 1, null); throw new Error("still starting"); }, setTimer: (handler) => { handler(); return 0; }, stdoutWrite: () => undefined, stderrWrite: () => undefined, probePortAvailable: async () => true, hasReadWriteAccess: () => true, }, }); await manager.startNodecgProcess(); await assert.rejects( async () => { await manager.waitForNodecgReady(Date.now()); }, (error: unknown) => { assert.ok(error instanceof Error); assert.match(error.message, /NodeCG exited before becoming ready/); assert.match(error.message, /Last recorded exit/); assert.match(error.message, /NodeCG path: \/fake\/scoreko-dev/); return true; }, ); });