From e3d3936156edf3b0809707a30dcf872d68f5e87f Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Sun, 24 May 2026 16:14:23 +0200 Subject: [PATCH] feat: Implement application bootstrap and window management - Added bootstrap functionality to initialize the Electron application. - Created a new paths module to manage application paths and URLs. - Introduced a shutdown service to handle graceful application shutdowns. - Refactored error logging to use a dedicated logger module. - Implemented process killing logic for NodeCG processes across platforms. - Established navigation policies for internal and external URL handling in windows. - Developed window service for creating and managing application windows. - Added tests for application paths, application controller, navigation policies, process killer, and shutdown service. --- docs/refactor/PHASE_1_SUMMARY.md | 90 ++++++ src/main/app/application-controller.ts | 234 +++++++++++++++ src/main/app/bootstrap.ts | 136 +++++++++ src/main/app/paths.ts | 60 ++++ src/main/app/shutdown-service.ts | 32 +++ src/main/errors/error-presenter.ts | 2 +- src/main/{errors => logging}/logger.ts | 0 src/main/main.ts | 221 +------------- src/main/nodecg/platform-process-killer.ts | 54 ++++ src/main/nodecg/process-manager.ts | 48 +--- ...ation-security.ts => navigation-policy.ts} | 0 .../{window-factory.ts => window-service.ts} | 37 ++- src/tests/app-paths.test.ts | 47 +++ src/tests/application-controller.test.ts | 271 ++++++++++++++++++ ...rity.test.ts => navigation-policy.test.ts} | 2 +- src/tests/platform-process-killer.test.ts | 65 +++++ src/tests/shutdown-service.test.ts | 32 +++ 17 files changed, 1067 insertions(+), 264 deletions(-) create mode 100644 docs/refactor/PHASE_1_SUMMARY.md create mode 100644 src/main/app/application-controller.ts create mode 100644 src/main/app/bootstrap.ts create mode 100644 src/main/app/paths.ts create mode 100644 src/main/app/shutdown-service.ts rename src/main/{errors => logging}/logger.ts (100%) create mode 100644 src/main/nodecg/platform-process-killer.ts rename src/main/windows/{navigation-security.ts => navigation-policy.ts} (100%) rename src/main/windows/{window-factory.ts => window-service.ts} (69%) create mode 100644 src/tests/app-paths.test.ts create mode 100644 src/tests/application-controller.test.ts rename src/tests/{navigation-security.test.ts => navigation-policy.test.ts} (96%) create mode 100644 src/tests/platform-process-killer.test.ts create mode 100644 src/tests/shutdown-service.test.ts diff --git a/docs/refactor/PHASE_1_SUMMARY.md b/docs/refactor/PHASE_1_SUMMARY.md new file mode 100644 index 0000000..067aee6 --- /dev/null +++ b/docs/refactor/PHASE_1_SUMMARY.md @@ -0,0 +1,90 @@ +# Phase 1 Summary + +## Scope + +Executed the architecture base refactor only. The change keeps the Electron plus local NodeCG product model intact and does not add a renderer, preload, or IPC layer. + +Documentation used as source of truth: + +- `docs/refactor/ARCHITECTURE_AUDIT.md` +- `docs/refactor/ARCHITECTURE_RULES.md` +- `docs/refactor/TARGET_ARCHITECTURE.md` +- `docs/refactor/MIGRATION_PLAN.md` +- `docs/refactor/SESSION_HANDOFF.md` + +## Completed + +- Split the Electron entrypoint into a thin `src/main/main.ts` and explicit bootstrap logic in `src/main/app/bootstrap.ts`. +- Added `src/main/app/application-controller.ts` to own startup, activation, update scheduling, and shutdown coordination. +- Added `src/main/app/paths.ts` for pure root path, userData path, NodeCG runtime path, and dashboard URL construction. +- Added `src/main/app/shutdown-service.ts` so repeated shutdown requests reuse one stop operation. +- Renamed window ownership toward the target architecture: + - `src/main/windows/window-service.ts` + - `src/main/windows/navigation-policy.ts` +- Moved logging to `src/main/logging/logger.ts`. +- Extracted process-tree termination to `src/main/nodecg/platform-process-killer.ts`. +- Normalized imports away from old `window-factory`, `navigation-security`, and `errors/logger` paths. +- Made BrowserWindow security settings explicit: + - `nodeIntegration: false` + - `contextIsolation: true` + - `sandbox: true` + - `webSecurity: true` + - devtools controlled by development mode + - permissions denied by default +- Added architecture-base tests for: + - path and URL helpers + - application startup ordering + - packaged relaunch after runtime installation + - activation before readiness + - shutdown idempotency + - platform process killing + +## Intentionally Not Changed + +- No UX changes. +- No custom renderer. +- No preload script. +- No IPC layer. +- No packaging changes. +- No update-manager rewrite. +- No complex NodeCG lifecycle rewrite. +- No change to the managed runtime location under Electron `userData`. +- No change to preservation of `cfg`, `db`, and `logs`. +- No change to launching NodeCG with `ELECTRON_RUN_AS_NODE`. + +## Compatibility Notes + +- Startup still prepares the managed NodeCG runtime before launching NodeCG. +- Packaged first-run runtime installation still relaunches before NodeCG starts. +- Loading and main windows are still created before NodeCG readiness so the startup experience remains equivalent. +- Dashboard loading remains gated behind NodeCG readiness. +- Update checks are still scheduled only after the main window is shown. +- Shutdown remains idempotent and still stops the NodeCG process tree. +- macOS-style activation now routes through the controller so dashboard loading cannot bypass readiness. + +## Verification + +Commands run successfully: + +```text +npm run typecheck +npm test +npm run lint +``` + +Current test result: + +```text +52 tests passing +``` + +Import/security sanity search: + +```text +rg -n "navigation-security|window-factory|errors/logger|preload|ipcRenderer|ipcMain|nodeIntegration:\s*true|webSecurity:\s*false|any\b" src docs/refactor +``` + +Result: + +- No legacy imports or unsafe Electron settings remain in `src`. +- Remaining matches are source-of-truth documentation references only. diff --git a/src/main/app/application-controller.ts b/src/main/app/application-controller.ts new file mode 100644 index 0000000..ec60c74 --- /dev/null +++ b/src/main/app/application-controller.ts @@ -0,0 +1,234 @@ +import { AppRuntimeConfig } from "../config/runtime-config"; +import { NodecgProcessManager } from "../nodecg/process-manager"; +import { PreparedNodecgRuntime } from "../nodecg/runtime-provisioner"; +import { getRemainingDelayMs } from "../utils/timing"; +import { createShutdownService, ShutdownService } from "./shutdown-service"; + +export type ApplicationState = "idle" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed"; + +export type ApplicationWindow = { + close: () => void; + focus: () => void; + isDestroyed: () => boolean; + isMinimized: () => boolean; + loadURL: (url: string) => Promise; + restore: () => void; + show: () => void; +}; + +export type ApplicationControllerConfig = { + appConfig: AppRuntimeConfig; + appVersion: string; + isPackaged: boolean; + isWindows: boolean; + paths: { + rootPath: string; + sourceNodecgRuntimePath: string; + userDataPath: string; + nodecgBaseUrl: string; + mainDashboardUrl: string; + loadingDashboardUrl: string; + }; + deps: { + createLoadingWindow: () => ApplicationWindow; + createMainWindow: () => ApplicationWindow; + createNodecgProcessManager: (runtimePath: string) => NodecgProcessManager; + getAllWindows: () => ApplicationWindow[]; + log: (...args: unknown[]) => void; + prepareRuntime: (config: { + sourceRuntimePath: string; + userDataPath: string; + appVersion: string; + bundleName: string; + log: (...args: unknown[]) => void; + }) => PreparedNodecgRuntime; + relaunch: () => void; + scheduleUpdateCheck: (config: { + getParentWindow: () => ApplicationWindow | null; + beforeInstall: () => Promise; + }) => void; + setAppUserModelId: (userModelId: string) => void; + exit: (code: number) => void; + now?: () => number; + sleep?: (ms: number) => Promise; + }; +}; + +export type ApplicationController = { + activate: () => Promise; + focusExistingWindow: () => void; + getState: () => ApplicationState; + launch: () => Promise; + stopNodecgGracefully: () => Promise; +}; + +export function createApplicationController({ + appConfig, + appVersion, + deps, + isPackaged, + isWindows, + paths, +}: ApplicationControllerConfig): ApplicationController { + let state: ApplicationState = "idle"; + let mainWindow: ApplicationWindow | null = null; + let loadingWindow: ApplicationWindow | null = null; + let nodecgManager: NodecgProcessManager | null = null; + let launchPromise: Promise | null = null; + + const shutdownService: ShutdownService = createShutdownService(async () => { + await (nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve()); + }); + + const now = deps.now ?? Date.now; + const sleep = deps.sleep ?? defaultSleep; + + const closeLoadingWindow = (): void => { + if (!loadingWindow || loadingWindow.isDestroyed()) { + return; + } + + loadingWindow.close(); + loadingWindow = null; + }; + + const focusExistingWindow = (): void => { + const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : loadingWindow; + + if (!targetWindow || targetWindow.isDestroyed()) { + return; + } + + if (targetWindow.isMinimized()) { + targetWindow.restore(); + } + + targetWindow.show(); + targetWindow.focus(); + }; + + const startNodecg = async (): Promise => { + if (!nodecgManager) { + throw new Error("NodeCG process manager is not initialized."); + } + + await nodecgManager.startNodecgProcess(); + await nodecgManager.waitForNodecgReady(now()); + }; + + const launch = async (): Promise => { + if (launchPromise) { + return launchPromise; + } + + launchPromise = (async () => { + if (isWindows) { + deps.setAppUserModelId(appConfig.userModelId); + } + + state = "preparing"; + const preparedRuntime = deps.prepareRuntime({ + sourceRuntimePath: paths.sourceNodecgRuntimePath, + userDataPath: paths.userDataPath, + appVersion, + bundleName: appConfig.bundleName, + log: deps.log, + }); + + if (preparedRuntime.installed && isPackaged) { + deps.log("Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG."); + deps.relaunch(); + deps.exit(0); + state = "stopped"; + return; + } + + nodecgManager = deps.createNodecgProcessManager(preparedRuntime.runtimePath); + + mainWindow = deps.createMainWindow(); + loadingWindow = deps.createLoadingWindow(); + + state = "starting"; + await startNodecg(); + + if (!loadingWindow || loadingWindow.isDestroyed()) { + state = "ready"; + return; + } + + await loadingWindow.loadURL(paths.loadingDashboardUrl); + loadingWindow.show(); + + const loadingShownAt = now(); + + if (!mainWindow) { + state = "ready"; + return; + } + + await mainWindow.loadURL(paths.mainDashboardUrl); + + const remainingLoadingDelay = getRemainingDelayMs(appConfig.loadDelayMs, loadingShownAt, now()); + if (remainingLoadingDelay > 0) { + await sleep(remainingLoadingDelay); + } + + mainWindow.show(); + closeLoadingWindow(); + deps.scheduleUpdateCheck({ + getParentWindow: () => mainWindow, + beforeInstall: stopNodecgGracefully, + }); + + state = "ready"; + })(); + + try { + await launchPromise; + } catch (error) { + state = "failed"; + launchPromise = null; + closeLoadingWindow(); + throw error; + } + }; + + const activate = async (): Promise => { + if (deps.getAllWindows().length > 0) { + focusExistingWindow(); + return; + } + + if (state !== "ready") { + await launch(); + return; + } + + mainWindow = deps.createMainWindow(); + await mainWindow.loadURL(paths.mainDashboardUrl); + mainWindow.show(); + }; + + const stopNodecgGracefully = async (): Promise => { + if (shutdownService.getState() === "running") { + state = "stopping"; + } + + await shutdownService.stop(); + state = "stopped"; + }; + + return { + activate, + focusExistingWindow, + getState: () => state, + launch, + stopNodecgGracefully, + }; +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/src/main/app/bootstrap.ts b/src/main/app/bootstrap.ts new file mode 100644 index 0000000..095b59a --- /dev/null +++ b/src/main/app/bootstrap.ts @@ -0,0 +1,136 @@ +import { app, BrowserWindow } from "electron"; + +import { getRuntimeConfig } from "../config/runtime-config"; +import { showFatalError, log } from "../errors/error-presenter"; +import { createNodecgProcessManager } from "../nodecg/process-manager"; +import { prepareUserNodecgRuntime } from "../nodecg/runtime-provisioner"; +import { scheduleUpdateCheck } from "../updates/update-manager"; +import { createLoadingWindow, createMainWindow } from "../windows/window-service"; +import { createApplicationController } from "./application-controller"; +import { getApplicationPaths } from "./paths"; + +export function bootstrap(): void { + const appConfig = getRuntimeConfig(); + const isDev = !app.isPackaged; + const paths = getApplicationPaths({ + appConfig, + appDataPath: app.getPath("appData"), + compiledMainDir: __dirname, + isDev, + resourcesPath: process.resourcesPath, + }); + + app.setName(appConfig.title); + app.setPath("userData", paths.userDataPath); + + const hasSingleInstanceLock = app.requestSingleInstanceLock(); + + if (!hasSingleInstanceLock) { + app.quit(); + } + + const controller = createApplicationController({ + appConfig, + appVersion: app.getVersion(), + isPackaged: app.isPackaged, + isWindows: process.platform === "win32", + paths, + deps: { + createLoadingWindow: () => + createLoadingWindow({ + allowDevTools: isDev, + appConfig, + rootPath: paths.rootPath, + }), + createMainWindow: () => + createMainWindow({ + allowDevTools: isDev, + appConfig, + rootPath: paths.rootPath, + mainDashboardUrl: paths.mainDashboardUrl, + }), + createNodecgProcessManager: (runtimePath) => + createNodecgProcessManager({ + isDev, + nodecgRootPath: runtimePath, + nodecgBaseUrl: paths.nodecgBaseUrl, + appConfig, + log, + }), + getAllWindows: () => BrowserWindow.getAllWindows(), + log, + prepareRuntime: prepareUserNodecgRuntime, + relaunch: () => app.relaunch(), + scheduleUpdateCheck: ({ getParentWindow, beforeInstall }) => { + scheduleUpdateCheck({ + appConfig, + rootPath: paths.rootPath, + getParentWindow: () => getParentWindow() as BrowserWindow | null, + beforeInstall, + log, + }); + }, + setAppUserModelId: (userModelId) => app.setAppUserModelId(userModelId), + exit: (code) => app.exit(code), + }, + }); + + app.on("ready", () => { + if (!hasSingleInstanceLock) { + return; + } + + controller.launch().catch((error: unknown) => { + showFatalError("No se pudo iniciar Scoreko.", error); + app.exit(1); + }); + }); + + app.on("second-instance", () => { + controller.focusExistingWindow(); + }); + + app.on("activate", () => { + controller.activate().catch((error: unknown) => { + showFatalError("No se pudo reactivar Scoreko.", error); + }); + }); + + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } + }); + + app.on("before-quit", (event) => { + if (controller.getState() === "stopping" || controller.getState() === "stopped") { + return; + } + + event.preventDefault(); + + controller.stopNodecgGracefully().finally(() => { + app.quit(); + }); + }); + + app.on("will-quit", () => { + if (controller.getState() !== "stopping" && controller.getState() !== "stopped") { + void controller.stopNodecgGracefully(); + } + }); + + process.on("exit", () => { + if (controller.getState() !== "stopping" && controller.getState() !== "stopped") { + void controller.stopNodecgGracefully(); + } + }); + + process.on("uncaughtException", (error) => { + showFatalError("Unexpected error in Electron main process.", error); + }); + + process.on("unhandledRejection", (reason) => { + showFatalError("Unhandled promise in Electron main process.", reason); + }); +} diff --git a/src/main/app/paths.ts b/src/main/app/paths.ts new file mode 100644 index 0000000..8323a58 --- /dev/null +++ b/src/main/app/paths.ts @@ -0,0 +1,60 @@ +import path from "node:path"; + +import { AppRuntimeConfig } from "../config/runtime-config"; + +export type ApplicationPaths = { + rootPath: string; + sourceNodecgRuntimePath: string; + userDataPath: string; + nodecgBaseUrl: string; + mainDashboardUrl: string; + loadingDashboardUrl: string; +}; + +export function getRootPath(isDev: boolean, compiledMainDir: string, resourcesPath: string): string { + return isDev ? path.resolve(compiledMainDir, "../..") : resourcesPath; +} + +export function getUserDataPath(appDataPath: string, userDataDirectoryName: string): string { + return path.join(appDataPath, userDataDirectoryName); +} + +export function getSourceNodecgRuntimePath(rootPath: string): string { + return path.resolve(rootPath, "lib", "nodecg"); +} + +export function getNodecgBaseUrl(nodecgPort: string): string { + return `http://127.0.0.1:${nodecgPort}`; +} + +export function getDashboardUrl(nodecgPort: string, bundleName: string, dashboardRoute: string): string { + return `http://localhost:${nodecgPort}/bundles/${bundleName}/${dashboardRoute}`; +} + +export function getApplicationPaths({ + appConfig, + appDataPath, + compiledMainDir, + isDev, + resourcesPath, +}: { + appConfig: Pick< + AppRuntimeConfig, + "bundleName" | "loadingDashboardRoute" | "mainDashboardRoute" | "nodecgPort" | "userDataDirectoryName" + >; + appDataPath: string; + compiledMainDir: string; + isDev: boolean; + resourcesPath: string; +}): ApplicationPaths { + const rootPath = getRootPath(isDev, compiledMainDir, resourcesPath); + + return { + rootPath, + sourceNodecgRuntimePath: getSourceNodecgRuntimePath(rootPath), + userDataPath: getUserDataPath(appDataPath, appConfig.userDataDirectoryName), + nodecgBaseUrl: getNodecgBaseUrl(appConfig.nodecgPort), + mainDashboardUrl: getDashboardUrl(appConfig.nodecgPort, appConfig.bundleName, appConfig.mainDashboardRoute), + loadingDashboardUrl: getDashboardUrl(appConfig.nodecgPort, appConfig.bundleName, appConfig.loadingDashboardRoute), + }; +} diff --git a/src/main/app/shutdown-service.ts b/src/main/app/shutdown-service.ts new file mode 100644 index 0000000..1ec253c --- /dev/null +++ b/src/main/app/shutdown-service.ts @@ -0,0 +1,32 @@ +export type AppShutdownState = "running" | "stopping" | "stopped"; + +export type ShutdownService = { + getState: () => AppShutdownState; + stop: () => Promise; +}; + +export function createShutdownService(stopRuntime: () => Promise): ShutdownService { + let state: AppShutdownState = "running"; + let stopPromise: Promise | null = null; + + return { + getState: () => state, + stop: () => { + if (state === "stopped") { + return Promise.resolve(); + } + + if (stopPromise) { + return stopPromise; + } + + state = "stopping"; + stopPromise = stopRuntime().finally(() => { + state = "stopped"; + stopPromise = null; + }); + + return stopPromise; + }, + }; +} diff --git a/src/main/errors/error-presenter.ts b/src/main/errors/error-presenter.ts index 9245bb1..feeadfd 100644 --- a/src/main/errors/error-presenter.ts +++ b/src/main/errors/error-presenter.ts @@ -1,6 +1,6 @@ import { app, dialog } from "electron"; -import { logger } from "./logger"; +import { logger } from "../logging/logger"; export function log(...args: unknown[]): void { logger.info("runtime", { args }); diff --git a/src/main/errors/logger.ts b/src/main/logging/logger.ts similarity index 100% rename from src/main/errors/logger.ts rename to src/main/logging/logger.ts diff --git a/src/main/main.ts b/src/main/main.ts index 08b79ef..c7fd811 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,220 +1,3 @@ -import { app, BrowserWindow } from "electron"; -import path from "node:path"; +import { bootstrap } from "./app/bootstrap"; -import { getRuntimeConfig } from "./config/runtime-config"; -import { showFatalError, log } from "./errors/error-presenter"; -import { createNodecgProcessManager, NodecgProcessManager } from "./nodecg/process-manager"; -import { prepareUserNodecgRuntime } from "./nodecg/runtime-provisioner"; -import { scheduleUpdateCheck } from "./updates/update-manager"; -import { getRemainingDelayMs } from "./utils/timing"; -import { createLoadingWindow, createMainWindow } from "./windows/window-factory"; - -const appConfig = getRuntimeConfig(); - -// Force a stable userData folder name; overridable via SCOREKO_APP_USER_DATA_DIRECTORY. -app.setName(appConfig.title); -app.setPath("userData", path.join(app.getPath("appData"), appConfig.userDataDirectoryName)); - -const isDev = !app.isPackaged; -const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath; -const sourceNodecgRuntimePath = path.resolve(rootPath, "lib", "nodecg"); -const mainDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.mainDashboardRoute}`; -const loadingDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.loadingDashboardRoute}`; -const nodecgBaseUrl = `http://127.0.0.1:${appConfig.nodecgPort}`; - -const hasSingleInstanceLock = app.requestSingleInstanceLock(); - -if (!hasSingleInstanceLock) { - app.quit(); -} - -type AppShutdownState = "running" | "stopping" | "stopped"; - -let mainWindow: BrowserWindow | null = null; -let loadingWindow: BrowserWindow | null = null; -let nodecgManager: NodecgProcessManager | null = null; -let shutdownState: AppShutdownState = "running"; - -function focusExistingWindow(): void { - const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : loadingWindow; - - if (!targetWindow || targetWindow.isDestroyed()) { - return; - } - - if (targetWindow.isMinimized()) { - targetWindow.restore(); - } - - targetWindow.show(); - targetWindow.focus(); -} - -async function launchApplication(): Promise { - const preparedRuntime = prepareUserNodecgRuntime({ - sourceRuntimePath: sourceNodecgRuntimePath, - userDataPath: app.getPath("userData"), - appVersion: app.getVersion(), - bundleName: appConfig.bundleName, - log, - }); - - if (preparedRuntime.installed && app.isPackaged) { - log("Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG."); - app.relaunch(); - app.exit(0); - return; - } - - nodecgManager = createNodecgProcessManager({ - isDev, - nodecgRootPath: preparedRuntime.runtimePath, - nodecgBaseUrl, - appConfig, - log, - }); - - // We create both windows early so startup feels instant while NodeCG is booting in the background. - mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl }); - loadingWindow = createLoadingWindow({ appConfig, rootPath }); - - await startNodecg(); - - if (!loadingWindow || loadingWindow.isDestroyed()) { - return; - } - - await loadingWindow.loadURL(loadingDashboardUrl); - loadingWindow.show(); - - const loadingShownAt = Date.now(); - - if (!mainWindow) { - return; - } - - await mainWindow.loadURL(mainDashboardUrl); - - // Keep the loading overlay visible for a minimum amount of time to avoid abrupt flashes. - const remainingLoadingDelay = getRemainingDelayMs(appConfig.loadDelayMs, loadingShownAt); - if (remainingLoadingDelay > 0) { - await sleep(remainingLoadingDelay); - } - - mainWindow.show(); - closeLoadingWindow(); - scheduleUpdateCheck({ - appConfig, - rootPath, - getParentWindow: () => mainWindow, - beforeInstall: stopNodecgGracefully, - log, - }); -} - -async function startNodecg(): Promise { - if (!nodecgManager) { - throw new Error("NodeCG process manager is not initialized."); - } - - await nodecgManager.startNodecgProcess(); - await nodecgManager.waitForNodecgReady(Date.now()); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function closeLoadingWindow(): void { - if (!loadingWindow || loadingWindow.isDestroyed()) { - return; - } - - loadingWindow.close(); - loadingWindow = null; -} - -function stopNodecgGracefully(): Promise { - if (shutdownState === "stopped") { - return Promise.resolve(); - } - - if (shutdownState === "stopping") { - return nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve(); - } - - shutdownState = "stopping"; - - return (nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve()).finally(() => { - shutdownState = "stopped"; - }); -} - -app.on("ready", () => { - if (!hasSingleInstanceLock) { - return; - } - - if (process.platform === "win32") { - app.setAppUserModelId(appConfig.userModelId); - } - - launchApplication().catch((error: unknown) => { - showFatalError("No se pudo iniciar Scoreko.", error); - closeLoadingWindow(); - app.exit(1); - }); -}); - -app.on("second-instance", () => { - focusExistingWindow(); -}); - -app.on("activate", async () => { - if (BrowserWindow.getAllWindows().length === 0) { - mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl }); - await mainWindow.loadURL(mainDashboardUrl); - mainWindow.show(); - } -}); - -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } -}); - -app.on("before-quit", (event) => { - if (shutdownState !== "running") { - return; - } - - // Block the default quit flow until we ask NodeCG to stop cleanly. - event.preventDefault(); - - stopNodecgGracefully().finally(() => { - app.quit(); - }); -}); - -app.on("will-quit", () => { - if (shutdownState === "running") { - void stopNodecgGracefully(); - } -}); - -process.on("exit", () => { - if (shutdownState === "running") { - void stopNodecgGracefully(); - } -}); - -process.on("uncaughtException", (error) => { - showFatalError("Unexpected error in Electron main process.", error); -}); - -process.on("unhandledRejection", (reason) => { - showFatalError("Unhandled promise in Electron main process.", reason); -}); +bootstrap(); diff --git a/src/main/nodecg/platform-process-killer.ts b/src/main/nodecg/platform-process-killer.ts new file mode 100644 index 0000000..5edeb4e --- /dev/null +++ b/src/main/nodecg/platform-process-killer.ts @@ -0,0 +1,54 @@ +import { ChildProcess, SpawnOptions } from "node:child_process"; + +export type PlatformProcessKillerDeps = { + platform: NodeJS.Platform; + spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess; + killProcess: (pid: number, signal: NodeJS.Signals) => void; + log: (...args: unknown[]) => void; +}; + +export function killProcessTree(pid: number, signal: NodeJS.Signals, deps: PlatformProcessKillerDeps): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) { + deps.log(`Invalid pid for process tree termination: ${pid}`); + return false; + } + + if (deps.platform === "win32") { + return killWindowsProcessTree(pid, signal, deps); + } + + return killPosixProcessTree(pid, signal, deps.killProcess); +} + +function killWindowsProcessTree( + pid: number, + signal: NodeJS.Signals, + deps: Pick, +): boolean { + const args = ["/pid", String(pid), "/T", ...(signal === "SIGKILL" ? ["/F"] : [])]; + const killer = deps.spawnProcess("taskkill", args, { + stdio: "ignore", + shell: false, + windowsHide: true, + }); + + killer.on("error", (error) => { + deps.log(`taskkill error for pid=${pid}`, error); + }); + + return true; +} + +function killPosixProcessTree(pid: number, signal: NodeJS.Signals, killProcess: PlatformProcessKillerDeps["killProcess"]): boolean { + try { + killProcess(-pid, signal); + return true; + } catch { + try { + killProcess(pid, signal); + return true; + } catch { + return false; + } + } +} diff --git a/src/main/nodecg/process-manager.ts b/src/main/nodecg/process-manager.ts index 9ebd205..022823c 100644 --- a/src/main/nodecg/process-manager.ts +++ b/src/main/nodecg/process-manager.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { AppRuntimeConfig } from "../config/runtime-config"; import { NODE_RUNTIME_NAME } from "../constants"; +import { killProcessTree } from "./platform-process-killer"; type NodecgProcessManagerConfig = { isDev: boolean; @@ -161,7 +162,12 @@ export function createNodecgProcessManager({ } log(`Stopping NodeCG pid=${pid}`); - killNodecgProcessTree(pid, "SIGTERM", log, resolvedDeps); + killProcessTree(pid, "SIGTERM", { + platform: resolvedDeps.platform, + spawnProcess: resolvedDeps.spawnProcess, + killProcess: resolvedDeps.killProcess, + log, + }); stopNodecgPromise = new Promise((resolve) => { let completed = false; @@ -189,7 +195,12 @@ export function createNodecgProcessManager({ () => { if (processToStop.exitCode === null && processToStop.signalCode === null) { log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`); - killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps); + killProcessTree(pid, "SIGKILL", { + platform: resolvedDeps.platform, + spawnProcess: resolvedDeps.spawnProcess, + killProcess: resolvedDeps.killProcess, + log, + }); complete(); } }, @@ -315,39 +326,6 @@ function probePortAvailable(port: number): Promise { }); } -function killNodecgProcessTree( - pid: number, - signal: NodeJS.Signals, - log: (...args: unknown[]) => void, - deps: Pick, -): boolean { - if (deps.platform === "win32") { - const force = signal === "SIGKILL" ? "/F" : ""; - const killer = deps.spawnProcess("taskkill", ["/pid", String(pid), "/T", ...(force ? [force] : [])], { - stdio: "ignore", - shell: true, - }); - - killer.on("error", (error) => { - log(`taskkill error for pid=${pid}`, error); - }); - - return true; - } - - try { - deps.killProcess(-pid, signal); - return true; - } catch { - try { - deps.killProcess(pid, signal); - return true; - } catch { - return false; - } - } -} - function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise { return new Promise((resolve) => { setTimer(resolve, ms); diff --git a/src/main/windows/navigation-security.ts b/src/main/windows/navigation-policy.ts similarity index 100% rename from src/main/windows/navigation-security.ts rename to src/main/windows/navigation-policy.ts diff --git a/src/main/windows/window-factory.ts b/src/main/windows/window-service.ts similarity index 69% rename from src/main/windows/window-factory.ts rename to src/main/windows/window-service.ts index 565fb45..1095b57 100644 --- a/src/main/windows/window-factory.ts +++ b/src/main/windows/window-service.ts @@ -1,19 +1,27 @@ import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron"; + import { AppRuntimeConfig } from "../config/runtime-config"; import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants"; import { resolveAppIconPath } from "./icon-path"; -import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-security"; +import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-policy"; -type WindowFactoryDependencies = { +type WindowServiceDependencies = { appConfig: AppRuntimeConfig; + allowDevTools: boolean; rootPath: string; mainDashboardUrl: string; }; -export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: WindowFactoryDependencies): BrowserWindow { - const windowOptions = createWindowOptions({ appConfig, rootPath, isLoadingWindow: false }); +export function createMainWindow({ + allowDevTools, + appConfig, + rootPath, + mainDashboardUrl, +}: WindowServiceDependencies): BrowserWindow { + const windowOptions = createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: false }); const window = new BrowserWindow(windowOptions); + denyPermissionsByDefault(window); window.setMenuBarVisibility(false); window.webContents.setWindowOpenHandler(({ url }) => { @@ -44,10 +52,13 @@ export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: Wind } export function createLoadingWindow({ + allowDevTools, appConfig, rootPath, -}: Omit): BrowserWindow { - const window = new BrowserWindow(createWindowOptions({ appConfig, rootPath, isLoadingWindow: true })); +}: Omit): BrowserWindow { + const window = new BrowserWindow(createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: true })); + + denyPermissionsByDefault(window); window.on("page-title-updated", (event) => { event.preventDefault(); @@ -56,11 +67,13 @@ export function createLoadingWindow({ return window; } -function createWindowOptions({ +export function createWindowOptions({ + allowDevTools, appConfig, rootPath, isLoadingWindow, }: { + allowDevTools: boolean; appConfig: AppRuntimeConfig; rootPath: string; isLoadingWindow: boolean; @@ -74,8 +87,10 @@ function createWindowOptions({ backgroundColor: DEFAULT_WINDOW_BACKGROUND, webPreferences: { contextIsolation: true, + devTools: allowDevTools, + nodeIntegration: false, sandbox: true, - ...(isLoadingWindow ? {} : { nodeIntegration: false }), + webSecurity: true, }, }; @@ -100,3 +115,9 @@ function createWindowOptions({ minHeight: DEFAULT_WINDOW_SIZE.minHeight, }; } + +function denyPermissionsByDefault(window: BrowserWindow): void { + window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => { + callback(false); + }); +} diff --git a/src/tests/app-paths.test.ts b/src/tests/app-paths.test.ts new file mode 100644 index 0000000..2bb7b28 --- /dev/null +++ b/src/tests/app-paths.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; + +import { + getApplicationPaths, + getDashboardUrl, + getNodecgBaseUrl, + getRootPath, + getSourceNodecgRuntimePath, + getUserDataPath, +} from "../main/app/paths"; + +test("app path helpers build deterministic development paths and URLs", () => { + const compiledMainDir = path.join("repo", "dist", "main"); + const rootPath = getRootPath(true, compiledMainDir, "/resources"); + + assert.equal(rootPath, path.resolve(compiledMainDir, "../..")); + assert.equal(getSourceNodecgRuntimePath(rootPath), path.resolve(rootPath, "lib", "nodecg")); + assert.equal(getUserDataPath("/app-data", "scoreko"), path.join("/app-data", "scoreko")); + assert.equal(getNodecgBaseUrl("9090"), "http://127.0.0.1:9090"); + assert.equal( + getDashboardUrl("9090", "scoreko-dev", "dashboard/main.html?standalone=true"), + "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html?standalone=true", + ); +}); + +test("getApplicationPaths keeps packaged root under Electron resources", () => { + const paths = getApplicationPaths({ + appConfig: { + userDataDirectoryName: "scoreko", + nodecgPort: "9090", + bundleName: "scoreko-dev", + mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true", + loadingDashboardRoute: "dashboard/loading/main.html?standalone=true", + }, + appDataPath: "/users/test/AppData/Roaming", + compiledMainDir: "/app/dist/main", + isDev: false, + resourcesPath: "/opt/Scoreko/resources", + }); + + assert.equal(paths.rootPath, "/opt/Scoreko/resources"); + assert.equal(paths.sourceNodecgRuntimePath, path.resolve("/opt/Scoreko/resources", "lib", "nodecg")); + assert.equal(paths.userDataPath, path.join("/users/test/AppData/Roaming", "scoreko")); + assert.equal(paths.nodecgBaseUrl, "http://127.0.0.1:9090"); +}); diff --git a/src/tests/application-controller.test.ts b/src/tests/application-controller.test.ts new file mode 100644 index 0000000..478f10b --- /dev/null +++ b/src/tests/application-controller.test.ts @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +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"); + return new EventEmitter() as import("node:child_process").ChildProcess; + }, + waitForNodecgReady: async () => { + events.push("wait-nodecg"); + }, + stopNodecgProcessGracefully: async () => { + events.push("stop-nodecg"); + }, + getProcess: () => null, + }; +} + +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 }; + }, + relaunch: () => events.push("relaunch"), + 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 relaunches packaged app after runtime install before starting NodeCG", 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: () => { + throw new Error("window creation should wait until after relaunch decisions"); + }, + createMainWindow: () => { + throw new Error("window creation should wait until after relaunch decisions"); + }, + createNodecgProcessManager: () => { + throw new Error("NodeCG should not start before relaunch"); + }, + getAllWindows: () => [], + log: (...args) => events.push(String(args[0])), + prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: true }), + relaunch: () => events.push("relaunch"), + scheduleUpdateCheck: () => events.push("schedule-update"), + setAppUserModelId: () => events.push("set-app-user-model-id"), + exit: (code) => events.push(`exit:${code}`), + }, + }); + + await controller.launch(); + + assert.equal(controller.getState(), "stopped"); + assert.deepEqual(events, [ + "Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.", + "relaunch", + "exit:0", + ]); +}); + +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 }; + }, + relaunch: () => events.push("relaunch"), + 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 }), + relaunch: () => events.push("relaunch"), + 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); +}); diff --git a/src/tests/navigation-security.test.ts b/src/tests/navigation-policy.test.ts similarity index 96% rename from src/tests/navigation-security.test.ts rename to src/tests/navigation-policy.test.ts index b13b3e5..91e3235 100644 --- a/src/tests/navigation-security.test.ts +++ b/src/tests/navigation-policy.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "../main/windows/navigation-security"; +import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "../main/windows/navigation-policy"; const dashboardUrl = "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html"; diff --git a/src/tests/platform-process-killer.test.ts b/src/tests/platform-process-killer.test.ts new file mode 100644 index 0000000..8581168 --- /dev/null +++ b/src/tests/platform-process-killer.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { SpawnOptions } from "node:child_process"; +import test from "node:test"; + +import { killProcessTree } from "../main/nodecg/platform-process-killer"; + +test("killProcessTree validates pid before building Windows taskkill command", () => { + const spawnCalls: Array<{ command: string; args: string[]; options: SpawnOptions }> = []; + + const killed = killProcessTree(Number.NaN, "SIGTERM", { + platform: "win32", + spawnProcess: (command, args, options) => { + spawnCalls.push({ command, args, options }); + return new EventEmitter() as import("node:child_process").ChildProcess; + }, + killProcess: () => undefined, + log: () => undefined, + }); + + assert.equal(killed, false); + assert.deepEqual(spawnCalls, []); +}); + +test("killProcessTree builds a narrow Windows taskkill invocation", () => { + const spawnCalls: Array<{ command: string; args: string[]; options: SpawnOptions }> = []; + + const killed = killProcessTree(1234, "SIGKILL", { + platform: "win32", + spawnProcess: (command, args, options) => { + spawnCalls.push({ command, args, options }); + return new EventEmitter() as import("node:child_process").ChildProcess; + }, + killProcess: () => undefined, + log: () => undefined, + }); + + assert.equal(killed, true); + assert.equal(spawnCalls[0]?.command, "taskkill"); + assert.deepEqual(spawnCalls[0]?.args, ["/pid", "1234", "/T", "/F"]); + assert.equal(spawnCalls[0]?.options.shell, false); + assert.equal(spawnCalls[0]?.options.windowsHide, true); +}); + +test("killProcessTree falls back from POSIX process group to child pid", () => { + const killCalls: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + const killed = killProcessTree(1234, "SIGTERM", { + platform: "linux", + spawnProcess: () => new EventEmitter() as import("node:child_process").ChildProcess, + killProcess: (pid, signal) => { + killCalls.push({ pid, signal }); + if (pid < 0) { + throw new Error("process group unavailable"); + } + }, + log: () => undefined, + }); + + assert.equal(killed, true); + assert.deepEqual(killCalls, [ + { pid: -1234, signal: "SIGTERM" }, + { pid: 1234, signal: "SIGTERM" }, + ]); +}); diff --git a/src/tests/shutdown-service.test.ts b/src/tests/shutdown-service.test.ts new file mode 100644 index 0000000..4809ae4 --- /dev/null +++ b/src/tests/shutdown-service.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createShutdownService } from "../main/app/shutdown-service"; + +test("shutdown service reuses the same stop promise while stopping", async () => { + let stopCalls = 0; + let releaseStop: () => void = () => { + throw new Error("stop promise was not created"); + }; + const service = createShutdownService( + () => + new Promise((resolve) => { + stopCalls += 1; + releaseStop = resolve; + }), + ); + + const first = service.stop(); + const second = service.stop(); + + assert.equal(first, second); + assert.equal(stopCalls, 1); + assert.equal(service.getState(), "stopping"); + + releaseStop(); + await first; + + assert.equal(service.getState(), "stopped"); + await service.stop(); + assert.equal(stopCalls, 1); +});