import assert from "node:assert/strict"; import test from "node:test"; import { createApplicationController, ApplicationWindow } from "../main/app/application-controller"; import { AppRuntimeConfig } from "../main/config/runtime-config"; import { NodecgProcessManager } from "../main/nodecg/process-manager"; class MockWindow implements ApplicationWindow { private destroyed = false; private minimized = false; constructor( private readonly name: string, private readonly events: string[], ) {} close(): void { this.events.push(`${this.name}:close`); this.destroyed = true; } focus(): void { this.events.push(`${this.name}:focus`); } isDestroyed(): boolean { return this.destroyed; } isMinimized(): boolean { return this.minimized; } async loadURL(url: string): Promise { this.events.push(`${this.name}:load:${url}`); } restore(): void { this.events.push(`${this.name}:restore`); this.minimized = false; } show(): void { this.events.push(`${this.name}:show`); } } 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: 0, startupTimeoutMs: 100, nodecgKillTimeoutMs: 10, updatesEnabled: true, updateAssetPattern: "Scoreko-setup-.*\\.exe$", updateCheckDelayMs: 5000, }; } function createMockManager(events: string[]): NodecgProcessManager { return { startNodecgProcess: async () => { events.push("start-nodecg"); }, waitForNodecgReady: async () => { events.push("wait-nodecg"); }, stopNodecgProcessGracefully: async () => { events.push("stop-nodecg"); }, getState: () => "running", }; } test("ApplicationController preserves startup ordering and schedules updates after main window is shown", async () => { const events: string[] = []; const paths = { rootPath: "/app", sourceNodecgRuntimePath: "/app/lib/nodecg", userDataPath: "/user-data/scoreko", nodecgBaseUrl: "http://127.0.0.1:9090", mainDashboardUrl: "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html?standalone=true", loadingDashboardUrl: "http://localhost:9090/bundles/scoreko-dev/dashboard/loading/main.html?standalone=true", }; const controller = createApplicationController({ appConfig: getBaseConfig(), appVersion: "0.1.0", isPackaged: false, isWindows: true, paths, deps: { createLoadingWindow: () => { events.push("create-loading"); return new MockWindow("loading", events); }, createMainWindow: () => { events.push("create-main"); return new MockWindow("main", events); }, createNodecgProcessManager: () => { events.push("create-manager"); return createMockManager(events); }, getAllWindows: () => [], log: () => undefined, prepareRuntime: () => { events.push("prepare-runtime"); return { runtimePath: "/user-data/scoreko/nodecg", installed: false }; }, scheduleUpdateCheck: () => events.push("schedule-update"), setAppUserModelId: () => events.push("set-app-user-model-id"), exit: (code) => events.push(`exit:${code}`), now: () => 0, sleep: async (ms) => { events.push(`sleep:${ms}`); }, }, }); await controller.launch(); assert.equal(controller.getState(), "ready"); assert.deepEqual(events, [ "set-app-user-model-id", "prepare-runtime", "create-manager", "create-main", "create-loading", "start-nodecg", "wait-nodecg", `loading:load:${paths.loadingDashboardUrl}`, "loading:show", `main:load:${paths.mainDashboardUrl}`, "main:show", "loading:close", "schedule-update", ]); }); test("ApplicationController directly launches packaged app after runtime install without relaunching", async () => { const events: string[] = []; const controller = createApplicationController({ appConfig: getBaseConfig(), appVersion: "0.1.0", isPackaged: true, isWindows: false, paths: { rootPath: "/app", sourceNodecgRuntimePath: "/app/lib/nodecg", userDataPath: "/user-data/scoreko", nodecgBaseUrl: "http://127.0.0.1:9090", mainDashboardUrl: "http://localhost:9090/main", loadingDashboardUrl: "http://localhost:9090/loading", }, deps: { createLoadingWindow: () => { events.push("create-loading"); return new MockWindow("loading", events); }, createMainWindow: () => { events.push("create-main"); return new MockWindow("main", events); }, createNodecgProcessManager: () => { events.push("create-manager"); return createMockManager(events); }, getAllWindows: () => [], log: (...args) => events.push(String(args[0])), prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: true }), scheduleUpdateCheck: () => events.push("schedule-update"), setAppUserModelId: () => events.push("set-app-user-model-id"), exit: (code) => events.push(`exit:${code}`), now: () => 0, sleep: async (ms) => { events.push(`sleep:${ms}`); }, }, }); await controller.launch(); assert.equal(controller.getState(), "ready"); assert.deepEqual(events, [ "create-manager", "create-main", "create-loading", "start-nodecg", "wait-nodecg", "loading:load:http://localhost:9090/loading", "loading:show", "main:load:http://localhost:9090/main", "main:show", "loading:close", "schedule-update", ]); }); test("ApplicationController activation before readiness routes through launch", async () => { const events: string[] = []; const controller = createApplicationController({ appConfig: getBaseConfig(), appVersion: "0.1.0", isPackaged: false, isWindows: false, paths: { rootPath: "/app", sourceNodecgRuntimePath: "/app/lib/nodecg", userDataPath: "/user-data/scoreko", nodecgBaseUrl: "http://127.0.0.1:9090", mainDashboardUrl: "http://localhost:9090/main", loadingDashboardUrl: "http://localhost:9090/loading", }, deps: { createLoadingWindow: () => new MockWindow("loading", events), createMainWindow: () => new MockWindow("main", events), createNodecgProcessManager: () => createMockManager(events), getAllWindows: () => [], log: () => undefined, prepareRuntime: () => { events.push("prepare-runtime"); return { runtimePath: "/user-data/scoreko/nodecg", installed: false }; }, scheduleUpdateCheck: () => events.push("schedule-update"), setAppUserModelId: () => events.push("set-app-user-model-id"), exit: (code) => events.push(`exit:${code}`), now: () => 0, }, }); await controller.activate(); assert.equal(controller.getState(), "ready"); assert.ok(events.includes("prepare-runtime")); assert.ok(events.includes("start-nodecg")); assert.ok(events.includes("wait-nodecg")); }); test("ApplicationController shutdown is idempotent", async () => { const events: string[] = []; const controller = createApplicationController({ appConfig: getBaseConfig(), appVersion: "0.1.0", isPackaged: false, isWindows: false, paths: { rootPath: "/app", sourceNodecgRuntimePath: "/app/lib/nodecg", userDataPath: "/user-data/scoreko", nodecgBaseUrl: "http://127.0.0.1:9090", mainDashboardUrl: "http://localhost:9090/main", loadingDashboardUrl: "http://localhost:9090/loading", }, deps: { createLoadingWindow: () => new MockWindow("loading", events), createMainWindow: () => new MockWindow("main", events), createNodecgProcessManager: () => createMockManager(events), getAllWindows: () => [], log: () => undefined, prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: false }), scheduleUpdateCheck: () => events.push("schedule-update"), setAppUserModelId: () => events.push("set-app-user-model-id"), exit: (code) => events.push(`exit:${code}`), now: () => 0, }, }); await controller.launch(); await Promise.all([controller.stopNodecgGracefully(), controller.stopNodecgGracefully()]); assert.equal(controller.getState(), "stopped"); assert.equal(events.filter((event) => event === "stop-nodecg").length, 1); });