mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-06 05:32:06 +00:00
feat: complete pending roadmap items with doctor, hardening, and code quality
This commit is contained in:
@@ -67,7 +67,9 @@ export function parseEnvPort(name: string, fallback: string): string {
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
|
||||
if (!Number.isFinite(parsedValue) || parsedValue < MIN_TCP_PORT || parsedValue > MAX_TCP_PORT) {
|
||||
throw new Error(`La variable ${name} debe ser un puerto TCP válido (${MIN_TCP_PORT}-${MAX_TCP_PORT}). Valor recibido: '${rawValue}'.`);
|
||||
throw new Error(
|
||||
`La variable ${name} debe ser un puerto TCP válido (${MIN_TCP_PORT}-${MAX_TCP_PORT}). Valor recibido: '${rawValue}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
return String(parsedValue);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { app, dialog } from "electron";
|
||||
|
||||
import { logger } from "./logger";
|
||||
|
||||
export function log(...args: unknown[]): void {
|
||||
console.log("[scoreko-electron]", ...args);
|
||||
logger.info("runtime", { args });
|
||||
}
|
||||
|
||||
export function formatErrorMessage(error: unknown): string {
|
||||
@@ -17,7 +19,10 @@ export function showFatalError(message: string, error?: unknown): void {
|
||||
const formattedError = error ? formatErrorMessage(error) : undefined;
|
||||
const details = formattedError ? `${message}\n\n${formattedError}` : message;
|
||||
|
||||
console.error(details);
|
||||
logger.error("fatal-startup-error", {
|
||||
message,
|
||||
error: formattedError,
|
||||
});
|
||||
|
||||
if (!app.isReady()) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
type LogContext = Record<string, unknown>;
|
||||
|
||||
function write(level: LogLevel, message: string, context?: LogContext): void {
|
||||
const payload = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
source: "scoreko-electron",
|
||||
message,
|
||||
...(context ? { context } : {}),
|
||||
};
|
||||
|
||||
const line = JSON.stringify(payload);
|
||||
|
||||
if (level === "error") {
|
||||
console.error(line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (level === "warn") {
|
||||
console.warn(line);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (message: string, context?: LogContext): void => write("debug", message, context),
|
||||
info: (message: string, context?: LogContext): void => write("info", message, context),
|
||||
warn: (message: string, context?: LogContext): void => write("warn", message, context),
|
||||
error: (message: string, context?: LogContext): void => write("error", message, context),
|
||||
};
|
||||
+1
-1
@@ -34,7 +34,7 @@ async function launchApplication(): Promise<void> {
|
||||
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
|
||||
loadingWindow = createLoadingWindow({ appConfig, rootPath });
|
||||
|
||||
nodecgManager.startNodecgProcess();
|
||||
await nodecgManager.startNodecgProcess();
|
||||
|
||||
await nodecgManager.waitForNodecgReady(Date.now());
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChildProcess, spawn, SpawnOptions } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||
@@ -25,10 +26,12 @@ type NodecgProcessManagerDeps = {
|
||||
setTimer: (handler: () => void, timeoutMs: number) => unknown;
|
||||
stdoutWrite: (chunk: string) => void;
|
||||
stderrWrite: (chunk: string) => void;
|
||||
probePortAvailable: (port: number) => Promise<boolean>;
|
||||
hasReadWriteAccess: (candidatePath: string) => boolean;
|
||||
};
|
||||
|
||||
export type NodecgProcessManager = {
|
||||
startNodecgProcess: () => ChildProcess;
|
||||
startNodecgProcess: () => Promise<ChildProcess>;
|
||||
waitForNodecgReady: (startTime: number) => Promise<void>;
|
||||
stopNodecgProcessGracefully: () => Promise<void>;
|
||||
getProcess: () => ChildProcess | null;
|
||||
@@ -47,8 +50,21 @@ export function createNodecgProcessManager({
|
||||
let nodecgProcess: ChildProcess | null = null;
|
||||
let stopNodecgPromise: Promise<void> | null = null;
|
||||
|
||||
const startNodecgProcess = (): ChildProcess => {
|
||||
validateNodecgInstall(nodecgRootPath, appConfig.bundleName, resolvedDeps.pathExists);
|
||||
const startNodecgProcess = async (): Promise<ChildProcess> => {
|
||||
validateNodecgInstall(
|
||||
nodecgRootPath,
|
||||
appConfig.bundleName,
|
||||
resolvedDeps.pathExists,
|
||||
resolvedDeps.hasReadWriteAccess,
|
||||
);
|
||||
|
||||
const portAsNumber = Number.parseInt(appConfig.nodecgPort, 10);
|
||||
const isPortAvailable = await resolvedDeps.probePortAvailable(portAsNumber);
|
||||
if (!isPortAvailable) {
|
||||
throw new Error(
|
||||
`El puerto ${appConfig.nodecgPort} ya está en uso. Cierra el proceso que lo ocupa o configura NODECG_PORT antes de iniciar.`,
|
||||
);
|
||||
}
|
||||
|
||||
const indexPath = path.join(nodecgRootPath, "index.js");
|
||||
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
|
||||
@@ -138,12 +154,15 @@ export function createNodecgProcessManager({
|
||||
complete();
|
||||
});
|
||||
|
||||
resolvedDeps.setTimer(() => {
|
||||
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
||||
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
|
||||
killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps);
|
||||
}
|
||||
}, Math.max(0, appConfig.nodecgKillTimeoutMs));
|
||||
resolvedDeps.setTimer(
|
||||
() => {
|
||||
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
||||
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
|
||||
killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps);
|
||||
}
|
||||
},
|
||||
Math.max(0, appConfig.nodecgKillTimeoutMs),
|
||||
);
|
||||
});
|
||||
|
||||
return stopNodecgPromise;
|
||||
@@ -169,10 +188,17 @@ function resolveDeps(deps?: Partial<NodecgProcessManagerDeps>): NodecgProcessMan
|
||||
setTimer: deps?.setTimer ?? setTimeout,
|
||||
stdoutWrite: deps?.stdoutWrite ?? ((chunk) => process.stdout.write(chunk)),
|
||||
stderrWrite: deps?.stderrWrite ?? ((chunk) => process.stderr.write(chunk)),
|
||||
probePortAvailable: deps?.probePortAvailable ?? probePortAvailable,
|
||||
hasReadWriteAccess: deps?.hasReadWriteAccess ?? hasReadWriteAccess,
|
||||
};
|
||||
}
|
||||
|
||||
function validateNodecgInstall(nodecgRootPath: string, bundleName: string, pathExists: (candidatePath: string) => boolean): void {
|
||||
function validateNodecgInstall(
|
||||
nodecgRootPath: string,
|
||||
bundleName: string,
|
||||
pathExists: (candidatePath: string) => boolean,
|
||||
hasReadWriteAccessToPath: (candidatePath: string) => boolean,
|
||||
): void {
|
||||
const indexPath = path.join(nodecgRootPath, "index.js");
|
||||
const nodecgBootstrapPath = path.join(nodecgRootPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
||||
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
|
||||
@@ -181,6 +207,10 @@ function validateNodecgInstall(nodecgRootPath: string, bundleName: string, pathE
|
||||
throw new Error(`No existe la carpeta NodeCG: ${nodecgRootPath}`);
|
||||
}
|
||||
|
||||
if (!hasReadWriteAccessToPath(nodecgRootPath)) {
|
||||
throw new Error(`Sin permisos de lectura/escritura sobre NodeCG: ${nodecgRootPath}`);
|
||||
}
|
||||
|
||||
if (!pathExists(indexPath)) {
|
||||
throw new Error(`No se encontró ${indexPath}. Copia una instalación completa de NodeCG en lib/nodecg.`);
|
||||
}
|
||||
@@ -207,6 +237,31 @@ function validateNodecgInstall(nodecgRootPath: string, bundleName: string, pathE
|
||||
}
|
||||
}
|
||||
|
||||
function hasReadWriteAccess(candidatePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(candidatePath, fs.constants.R_OK | fs.constants.W_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function probePortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
|
||||
server.once("error", () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function killNodecgProcessTree(
|
||||
pid: number,
|
||||
signal: NodeJS.Signals,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export function getRemainingDelayMs(targetDelayMs: number, startedAtMs: number, currentTimeMs: number = Date.now()): number {
|
||||
export function getRemainingDelayMs(
|
||||
targetDelayMs: number,
|
||||
startedAtMs: number,
|
||||
currentTimeMs: number = Date.now(),
|
||||
): number {
|
||||
return Math.max(0, targetDelayMs - (currentTimeMs - startedAtMs));
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: Wind
|
||||
return window;
|
||||
}
|
||||
|
||||
export function createLoadingWindow({ appConfig, rootPath }: Omit<WindowFactoryDependencies, "mainDashboardUrl">): BrowserWindow {
|
||||
export function createLoadingWindow({
|
||||
appConfig,
|
||||
rootPath,
|
||||
}: Omit<WindowFactoryDependencies, "mainDashboardUrl">): BrowserWindow {
|
||||
const window = new BrowserWindow(createWindowOptions({ appConfig, rootPath, isLoadingWindow: true }));
|
||||
|
||||
window.on("page-title-updated", (event) => {
|
||||
|
||||
@@ -13,11 +13,17 @@ test("shouldAllowInternalNavigation permite navegación interna esperada", () =>
|
||||
});
|
||||
|
||||
test("shouldAllowInternalNavigation rechaza host no permitido", () => {
|
||||
assert.equal(shouldAllowInternalNavigation("http://evil.local:9090/bundles/scoreko-dev/dashboard/page.html", dashboardUrl), false);
|
||||
assert.equal(
|
||||
shouldAllowInternalNavigation("http://evil.local:9090/bundles/scoreko-dev/dashboard/page.html", dashboardUrl),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldAllowInternalNavigation rechaza puerto distinto", () => {
|
||||
assert.equal(shouldAllowInternalNavigation("http://localhost:8080/bundles/scoreko-dev/dashboard/page.html", dashboardUrl), false);
|
||||
assert.equal(
|
||||
shouldAllowInternalNavigation("http://localhost:8080/bundles/scoreko-dev/dashboard/page.html", dashboardUrl),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldAllowInternalNavigation rechaza esquemas inseguros", () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ function getBaseConfig(): AppRuntimeConfig {
|
||||
};
|
||||
}
|
||||
|
||||
test("startNodeCG valida instalación de NodeCG antes de arrancar", () => {
|
||||
test("startNodeCG valida instalación de NodeCG antes de arrancar", async () => {
|
||||
const manager = createNodecgProcessManager({
|
||||
isDev: true,
|
||||
nodecgRootPath: "/fake/nodecg",
|
||||
@@ -46,11 +46,29 @@ test("startNodeCG valida instalación de NodeCG antes de arrancar", () => {
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(() => {
|
||||
manager.startNodecgProcess();
|
||||
await assert.rejects(async () => {
|
||||
await manager.startNodecgProcess();
|
||||
}, /No existe la carpeta NodeCG/);
|
||||
});
|
||||
|
||||
test("startNodeCG falla si no hay permisos de lectura/escritura", async () => {
|
||||
const manager = createNodecgProcessManager({
|
||||
isDev: true,
|
||||
nodecgRootPath: "/fake/nodecg",
|
||||
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||
appConfig: getBaseConfig(),
|
||||
log: () => undefined,
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
hasReadWriteAccess: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await manager.startNodecgProcess();
|
||||
}, /Sin permisos de lectura\/escritura/);
|
||||
});
|
||||
|
||||
test("waitForNodeCGReady resuelve cuando el endpoint responde 404", async () => {
|
||||
const child = new MockChildProcess(4321);
|
||||
const manager = createNodecgProcessManager({
|
||||
@@ -62,17 +80,19 @@ test("waitForNodeCGReady resuelve cuando el endpoint responde 404", async () =>
|
||||
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) => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
manager.startNodecgProcess();
|
||||
await manager.startNodecgProcess();
|
||||
await assert.doesNotReject(async () => {
|
||||
await manager.waitForNodecgReady(Date.now());
|
||||
});
|
||||
@@ -92,20 +112,22 @@ test("stopNodeCG envía SIGTERM y luego SIGKILL si el proceso no sale", async ()
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 } as Response),
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: (pid, signal) => {
|
||||
killSignals.push({ pid, signal });
|
||||
},
|
||||
setTimer: ((handler: (...args: unknown[]) => void, _timeoutMs: number) => {
|
||||
setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => {
|
||||
timers.push(() => handler());
|
||||
return 0 as never;
|
||||
}),
|
||||
},
|
||||
stdoutWrite: () => undefined,
|
||||
stderrWrite: () => undefined,
|
||||
probePortAvailable: async () => true,
|
||||
hasReadWriteAccess: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
manager.startNodecgProcess();
|
||||
await manager.startNodecgProcess();
|
||||
const stopPromise = manager.stopNodecgProcessGracefully();
|
||||
|
||||
assert.deepEqual(killSignals, [{ pid: -9999, signal: "SIGTERM" }]);
|
||||
@@ -123,7 +145,6 @@ test("stopNodeCG envía SIGTERM y luego SIGKILL si el proceso no sale", async ()
|
||||
await stopPromise;
|
||||
});
|
||||
|
||||
|
||||
test("stopNodeCG reutiliza la misma promesa cuando se invoca en paralelo", async () => {
|
||||
const child = new MockChildProcess(5555);
|
||||
|
||||
@@ -136,15 +157,17 @@ test("stopNodeCG reutiliza la misma promesa cuando se invoca en paralelo", async
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 } as Response),
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: () => undefined,
|
||||
setTimer: () => 0,
|
||||
stdoutWrite: () => undefined,
|
||||
stderrWrite: () => undefined,
|
||||
probePortAvailable: async () => true,
|
||||
hasReadWriteAccess: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
manager.startNodecgProcess();
|
||||
await manager.startNodecgProcess();
|
||||
const firstStop = manager.stopNodecgProcessGracefully();
|
||||
const secondStop = manager.stopNodecgProcessGracefully();
|
||||
|
||||
@@ -170,7 +193,7 @@ test("stopNodeCG normaliza timeout negativo a cero", async () => {
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 } as Response),
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: () => undefined,
|
||||
setTimer: (handler, timeoutMs) => {
|
||||
timeouts.push(timeoutMs);
|
||||
@@ -179,10 +202,12 @@ test("stopNodeCG normaliza timeout negativo a cero", async () => {
|
||||
},
|
||||
stdoutWrite: () => undefined,
|
||||
stderrWrite: () => undefined,
|
||||
probePortAvailable: async () => true,
|
||||
hasReadWriteAccess: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
manager.startNodecgProcess();
|
||||
await manager.startNodecgProcess();
|
||||
const stopPromise = manager.stopNodecgProcessGracefully();
|
||||
|
||||
assert.ok(timeouts.includes(0));
|
||||
@@ -190,3 +215,22 @@ test("stopNodeCG normaliza timeout negativo a cero", async () => {
|
||||
child.emit("exit", 0, null);
|
||||
await stopPromise;
|
||||
});
|
||||
|
||||
test("startNodeCG falla si el puerto ya está ocupado", async () => {
|
||||
const manager = createNodecgProcessManager({
|
||||
isDev: true,
|
||||
nodecgRootPath: "/fake/nodecg",
|
||||
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();
|
||||
}, /ya está en uso/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user