diff --git a/src/main/main.ts b/src/main/main.ts index 207e283..29a8a8c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { getRuntimeConfig } from "./config/runtime-config"; import { showFatalError, log } from "./errors/error-presenter"; import { createNodecgProcessManager } from "./nodecg/process-manager"; +import { getRemainingDelayMs } from "./utils/timing"; import { createLoadingWindow, createMainWindow } from "./windows/window-factory"; const runtimeConfig = getRuntimeConfig(); @@ -50,7 +51,7 @@ async function launch(): Promise { await mainWindow.loadURL(dashboardUrl); - const remainingLoadingDelay = Math.max(0, runtimeConfig.loadDelayMs - (Date.now() - loadingShownAt)); + const remainingLoadingDelay = getRemainingDelayMs(runtimeConfig.loadDelayMs, loadingShownAt); if (remainingLoadingDelay > 0) { await sleep(remainingLoadingDelay); } diff --git a/src/main/utils/timing.ts b/src/main/utils/timing.ts new file mode 100644 index 0000000..e71815b --- /dev/null +++ b/src/main/utils/timing.ts @@ -0,0 +1,3 @@ +export function getRemainingDelayMs(targetDelayMs: number, startedAtMs: number, currentTimeMs: number = Date.now()): number { + return Math.max(0, targetDelayMs - (currentTimeMs - startedAtMs)); +} diff --git a/src/main/windows/icon-path.ts b/src/main/windows/icon-path.ts new file mode 100644 index 0000000..e537ccc --- /dev/null +++ b/src/main/windows/icon-path.ts @@ -0,0 +1,20 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { AppRuntimeConfig } from "../config/runtime-config"; + +export function resolveAppIconPath( + runtimeConfig: AppRuntimeConfig, + rootPath: string, + pathExists: (candidatePath: string) => boolean = fs.existsSync, +): string | undefined { + const iconCandidates = [ + runtimeConfig.iconPathOverride, + path.join(rootPath, "static", "icons", "icon.ico"), + path.join(rootPath, "static", "icons", "icon.png"), + path.join(rootPath, "static", "icon.ico"), + path.join(rootPath, "static", "icon.png"), + ].filter((candidate): candidate is string => Boolean(candidate)); + + return iconCandidates.find((candidate) => pathExists(candidate)); +} diff --git a/src/main/windows/window-factory.ts b/src/main/windows/window-factory.ts index 8741fbe..2241402 100644 --- a/src/main/windows/window-factory.ts +++ b/src/main/windows/window-factory.ts @@ -1,9 +1,7 @@ import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron"; -import fs from "node:fs"; -import path from "node:path"; - import { AppRuntimeConfig } from "../config/runtime-config"; import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants"; +import { resolveAppIconPath } from "./icon-path"; type WindowFactoryDependencies = { runtimeConfig: AppRuntimeConfig; @@ -90,15 +88,3 @@ function createWindowOptions({ minHeight: DEFAULT_WINDOW_SIZE.minHeight, }; } - -function resolveAppIconPath(runtimeConfig: AppRuntimeConfig, rootPath: string): string | undefined { - const iconCandidates = [ - runtimeConfig.iconPathOverride, - path.join(rootPath, "static", "icons", "icon.ico"), - path.join(rootPath, "static", "icons", "icon.png"), - path.join(rootPath, "static", "icon.ico"), - path.join(rootPath, "static", "icon.png"), - ].filter((candidate): candidate is string => Boolean(candidate)); - - return iconCandidates.find((candidate) => fs.existsSync(candidate)); -} diff --git a/src/tests/icon-path.test.ts b/src/tests/icon-path.test.ts new file mode 100644 index 0000000..e000770 --- /dev/null +++ b/src/tests/icon-path.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { AppRuntimeConfig } from "../main/config/runtime-config"; +import { resolveAppIconPath } from "../main/windows/icon-path"; + +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: 30000, + nodecgKillTimeoutMs: 2500, + }; +} + +test("resolveAppIconPath prioriza iconPathOverride cuando existe", () => { + const runtimeConfig: AppRuntimeConfig = { + ...getBaseConfig(), + iconPathOverride: "/custom/icon.ico", + }; + + const iconPath = resolveAppIconPath(runtimeConfig, "/app", (candidate) => candidate === "/custom/icon.ico"); + + assert.equal(iconPath, "/custom/icon.ico"); +}); + +test("resolveAppIconPath cae al primer icono por defecto existente", () => { + const runtimeConfig = getBaseConfig(); + + const iconPath = resolveAppIconPath(runtimeConfig, "/app", (candidate) => candidate === "/app/static/icons/icon.png"); + + assert.equal(iconPath, "/app/static/icons/icon.png"); +}); + +test("resolveAppIconPath devuelve undefined cuando no hay iconos", () => { + const runtimeConfig = getBaseConfig(); + + const iconPath = resolveAppIconPath(runtimeConfig, "/app", () => false); + + assert.equal(iconPath, undefined); +}); diff --git a/src/tests/process-manager.test.ts b/src/tests/process-manager.test.ts index 54f7503..12595de 100644 --- a/src/tests/process-manager.test.ts +++ b/src/tests/process-manager.test.ts @@ -122,3 +122,71 @@ test("stopNodeCG envía SIGTERM y luego SIGKILL si el proceso no sale", async () child.emit("exit", 0, null); await stopPromise; }); + + +test("stopNodeCG reutiliza la misma promesa cuando se invoca en paralelo", async () => { + const child = new MockChildProcess(5555); + + 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: () => undefined, + setTimer: () => 0, + stdoutWrite: () => undefined, + stderrWrite: () => undefined, + }, + }); + + manager.startNodeCG(); + const firstStop = manager.stopNodeCG(); + const secondStop = manager.stopNodeCG(); + + assert.equal(firstStop, secondStop); + + child.emit("exit", 0, null); + await firstStop; +}); + +test("stopNodeCG normaliza timeout negativo a cero", async () => { + const child = new MockChildProcess(7777); + const timeouts: number[] = []; + + const manager = createNodecgProcessManager({ + isDev: true, + nodecgPath: "/fake/nodecg", + baseUrl: "http://127.0.0.1:9090", + runtimeConfig: { + ...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, + }, + }); + + manager.startNodeCG(); + const stopPromise = manager.stopNodeCG(); + + assert.ok(timeouts.includes(0)); + + child.emit("exit", 0, null); + await stopPromise; +}); diff --git a/src/tests/timing.test.ts b/src/tests/timing.test.ts new file mode 100644 index 0000000..108e0fe --- /dev/null +++ b/src/tests/timing.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getRemainingDelayMs } from "../main/utils/timing"; + +test("getRemainingDelayMs devuelve el tiempo restante cuando aún no se cumple", () => { + const remaining = getRemainingDelayMs(10000, 1000, 4000); + assert.equal(remaining, 7000); +}); + +test("getRemainingDelayMs devuelve 0 si ya pasó el delay", () => { + const remaining = getRemainingDelayMs(1000, 1000, 5000); + assert.equal(remaining, 0); +});