From e5e77321da5a67e582ecae6e18ef14bd69cf7566 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 19 Nov 2025 20:43:37 -0500 Subject: [PATCH 01/25] Include nodemon config in build artifact --- api/ecosystem.config.json | 22 - api/generated-schema.graphql | 4 +- api/legacy/generated-schema-legacy.graphql | 2 +- api/nodemon.json | 18 + api/package.json | 3 +- api/scripts/build.ts | 4 +- .../__test__/core/utils/pm2/dummy-process.js | 5 - .../unraid-api-running.integration.test.ts | 222 ------- .../unraid-api-running.integration.test.ts | 54 ++ api/src/core/log.ts | 4 +- api/src/core/utils/pm2/unraid-api-running.ts | 40 -- .../core/utils/process/unraid-api-running.ts | 23 + api/src/environment.ts | 15 +- .../cli/__test__/report.command.test.ts | 8 +- api/src/unraid-api/cli/cli-services.module.ts | 4 +- api/src/unraid-api/cli/cli.module.ts | 4 +- api/src/unraid-api/cli/generated/graphql.ts | 19 +- api/src/unraid-api/cli/logs.command.ts | 13 +- .../unraid-api/cli/nodemon.service.spec.ts | 99 +++ api/src/unraid-api/cli/nodemon.service.ts | 133 ++++ api/src/unraid-api/cli/pm2.service.spec.ts | 76 --- api/src/unraid-api/cli/pm2.service.ts | 134 ---- api/src/unraid-api/cli/report.command.ts | 4 +- api/src/unraid-api/cli/restart.command.ts | 26 +- api/src/unraid-api/cli/start.command.ts | 31 +- api/src/unraid-api/cli/status.command.ts | 11 +- api/src/unraid-api/cli/stop.command.ts | 29 +- .../resolvers/info/versions/versions.model.ts | 4 +- .../info/versions/versions.resolver.ts | 3 +- api/src/unraid-api/main.ts | 10 - pnpm-lock.yaml | 607 +----------------- web/composables/gql/graphql.ts | 4 +- web/src/composables/gql/graphql.ts | 19 +- web/src/composables/gql/index.ts | 4 +- 34 files changed, 433 insertions(+), 1225 deletions(-) delete mode 100644 api/ecosystem.config.json create mode 100644 api/nodemon.json delete mode 100644 api/src/__test__/core/utils/pm2/dummy-process.js delete mode 100644 api/src/__test__/core/utils/pm2/unraid-api-running.integration.test.ts create mode 100644 api/src/__test__/core/utils/process/unraid-api-running.integration.test.ts delete mode 100644 api/src/core/utils/pm2/unraid-api-running.ts create mode 100644 api/src/core/utils/process/unraid-api-running.ts create mode 100644 api/src/unraid-api/cli/nodemon.service.spec.ts create mode 100644 api/src/unraid-api/cli/nodemon.service.ts delete mode 100644 api/src/unraid-api/cli/pm2.service.spec.ts delete mode 100644 api/src/unraid-api/cli/pm2.service.ts diff --git a/api/ecosystem.config.json b/api/ecosystem.config.json deleted file mode 100644 index 4fea24e6ef..0000000000 --- a/api/ecosystem.config.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/pm2-ecosystem", - "apps": [ - { - "name": "unraid-api", - "script": "./dist/main.js", - "cwd": "/usr/local/unraid-api", - "exec_mode": "fork", - "wait_ready": true, - "listen_timeout": 15000, - "max_restarts": 10, - "min_uptime": 10000, - "watch": false, - "interpreter": "/usr/local/bin/node", - "ignore_watch": ["node_modules", "src", ".env.*", "myservers.cfg"], - "out_file": "/var/log/graphql-api.log", - "error_file": "/var/log/graphql-api.log", - "merge_logs": true, - "kill_timeout": 10000 - } - ] -} diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 0dfe521f9e..f0fbb669df 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1673,8 +1673,8 @@ type PackageVersions { """npm version""" npm: String - """pm2 version""" - pm2: String + """nodemon version""" + nodemon: String """Git version""" git: String diff --git a/api/legacy/generated-schema-legacy.graphql b/api/legacy/generated-schema-legacy.graphql index 0928c60b90..b13c1ef314 100644 --- a/api/legacy/generated-schema-legacy.graphql +++ b/api/legacy/generated-schema-legacy.graphql @@ -1257,7 +1257,7 @@ type Versions { openssl: String perl: String php: String - pm2: String + nodemon: String postfix: String postgresql: String python: String diff --git a/api/nodemon.json b/api/nodemon.json new file mode 100644 index 0000000000..91e2dfae2a --- /dev/null +++ b/api/nodemon.json @@ -0,0 +1,18 @@ +{ + "watch": [ + "dist/main.js", + "myservers.cfg" + ], + "ignore": [ + "node_modules", + "src", + ".env.*" + ], + "exec": "node ./dist/main.js", + "signal": "SIGTERM", + "ext": "js,json", + "restartable": "rs", + "env": { + "NODE_ENV": "production" + } +} diff --git a/api/package.json b/api/package.json index 26e51095bf..a09f9500a4 100644 --- a/api/package.json +++ b/api/package.json @@ -137,7 +137,7 @@ "pino": "9.9.0", "pino-http": "10.5.0", "pino-pretty": "13.1.1", - "pm2": "6.0.8", + "nodemon": "3.1.10", "reflect-metadata": "^0.1.14", "rxjs": "7.8.2", "semver": "7.7.2", @@ -203,7 +203,6 @@ "eslint-plugin-no-relative-import-paths": "1.6.1", "eslint-plugin-prettier": "5.5.4", "jiti": "2.5.1", - "nodemon": "3.1.10", "prettier": "3.6.2", "rollup-plugin-node-externals": "8.1.0", "supertest": "7.1.4", diff --git a/api/scripts/build.ts b/api/scripts/build.ts index 924b3f4ca3..6c6bb98ac5 100755 --- a/api/scripts/build.ts +++ b/api/scripts/build.ts @@ -7,7 +7,7 @@ import { exit } from 'process'; import type { PackageJson } from 'type-fest'; import { $, cd } from 'zx'; -import { getDeploymentVersion } from './get-deployment-version.js'; +import { getDeploymentVersion } from '@app/../scripts/get-deployment-version.js'; type ApiPackageJson = PackageJson & { version: string; @@ -94,7 +94,7 @@ try { await writeFile('./deploy/pack/package.json', JSON.stringify(parsedPackageJson, null, 4)); // Copy necessary files to the pack directory - await $`cp -r dist README.md .env.* ecosystem.config.json ./deploy/pack/`; + await $`cp -r dist README.md .env.* nodemon.json ./deploy/pack/`; // Change to the pack directory and install dependencies cd('./deploy/pack'); diff --git a/api/src/__test__/core/utils/pm2/dummy-process.js b/api/src/__test__/core/utils/pm2/dummy-process.js deleted file mode 100644 index 85ace81c08..0000000000 --- a/api/src/__test__/core/utils/pm2/dummy-process.js +++ /dev/null @@ -1,5 +0,0 @@ -/* eslint-disable no-undef */ -// Dummy process for PM2 testing -setInterval(() => { - // Keep process alive -}, 1000); \ No newline at end of file diff --git a/api/src/__test__/core/utils/pm2/unraid-api-running.integration.test.ts b/api/src/__test__/core/utils/pm2/unraid-api-running.integration.test.ts deleted file mode 100644 index 6c05e817c6..0000000000 --- a/api/src/__test__/core/utils/pm2/unraid-api-running.integration.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { existsSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { execa } from 'execa'; -import pm2 from 'pm2'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; - -import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running.js'; - -const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const PROJECT_ROOT = join(__dirname, '../../../../..'); -const DUMMY_PROCESS_PATH = join(__dirname, 'dummy-process.js'); -const CLI_PATH = join(PROJECT_ROOT, 'dist/cli.js'); -const TEST_PROCESS_NAME = 'test-unraid-api'; - -// Shared PM2 connection state -let pm2Connected = false; - -// Helper to ensure PM2 connection is established -async function ensurePM2Connection() { - if (pm2Connected) return; - - return new Promise((resolve, reject) => { - pm2.connect((err) => { - if (err) { - reject(err); - return; - } - pm2Connected = true; - resolve(); - }); - }); -} - -// Helper to delete specific test processes (lightweight, reuses connection) -async function deleteTestProcesses() { - if (!pm2Connected) { - // No connection, nothing to clean up - return; - } - - const deletePromise = new Promise((resolve) => { - // Delete specific processes we might have created - const processNames = ['unraid-api', TEST_PROCESS_NAME]; - let deletedCount = 0; - - const deleteNext = () => { - if (deletedCount >= processNames.length) { - resolve(); - return; - } - - const processName = processNames[deletedCount]; - pm2.delete(processName, () => { - // Ignore errors, process might not exist - deletedCount++; - deleteNext(); - }); - }; - - deleteNext(); - }); - - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => resolve(), 3000); // 3 second timeout - }); - - return Promise.race([deletePromise, timeoutPromise]); -} - -// Helper to ensure PM2 is completely clean (heavy cleanup with daemon kill) -async function cleanupAllPM2Processes() { - // First delete test processes if we have a connection - if (pm2Connected) { - await deleteTestProcesses(); - } - - return new Promise((resolve) => { - // Always connect fresh for daemon kill (in case we weren't connected) - pm2.connect((err) => { - if (err) { - // If we can't connect, assume PM2 is not running - pm2Connected = false; - resolve(); - return; - } - - // Kill the daemon to ensure fresh state - pm2.killDaemon(() => { - pm2.disconnect(); - pm2Connected = false; - // Small delay to let PM2 fully shutdown - setTimeout(resolve, 500); - }); - }); - }); -} - -describe.skipIf(!!process.env.CI)('PM2 integration tests', () => { - beforeAll(async () => { - // Set PM2_HOME to use home directory for testing (not /var/log) - process.env.PM2_HOME = join(homedir(), '.pm2'); - - // Build the CLI if it doesn't exist (only for CLI tests) - if (!existsSync(CLI_PATH)) { - console.log('Building CLI for integration tests...'); - try { - await execa('pnpm', ['build'], { - cwd: PROJECT_ROOT, - stdio: 'inherit', - timeout: 120000, // 2 minute timeout for build - }); - } catch (error) { - console.error('Failed to build CLI:', error); - throw new Error( - 'Cannot run CLI integration tests without built CLI. Run `pnpm build` first.' - ); - } - } - - // Only do a full cleanup once at the beginning - await cleanupAllPM2Processes(); - }, 150000); // 2.5 minute timeout for setup - - afterAll(async () => { - // Only do a full cleanup once at the end - await cleanupAllPM2Processes(); - }); - - afterEach(async () => { - // Lightweight cleanup after each test - just delete our test processes - await deleteTestProcesses(); - }, 5000); // 5 second timeout for cleanup - - describe('isUnraidApiRunning function', () => { - it('should return false when PM2 is not running the unraid-api process', async () => { - const result = await isUnraidApiRunning(); - expect(result).toBe(false); - }); - - it('should return true when PM2 has unraid-api process running', async () => { - // Ensure PM2 connection - await ensurePM2Connection(); - - // Start a dummy process with the name 'unraid-api' - await new Promise((resolve, reject) => { - pm2.start( - { - script: DUMMY_PROCESS_PATH, - name: 'unraid-api', - }, - (startErr) => { - if (startErr) return reject(startErr); - resolve(); - } - ); - }); - - // Give PM2 time to start the process - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const result = await isUnraidApiRunning(); - expect(result).toBe(true); - }, 30000); - - it('should return false when unraid-api process is stopped', async () => { - // Ensure PM2 connection - await ensurePM2Connection(); - - // Start and then stop the process - await new Promise((resolve, reject) => { - pm2.start( - { - script: DUMMY_PROCESS_PATH, - name: 'unraid-api', - }, - (startErr) => { - if (startErr) return reject(startErr); - - // Stop the process after starting - setTimeout(() => { - pm2.stop('unraid-api', (stopErr) => { - if (stopErr) return reject(stopErr); - resolve(); - }); - }, 1000); - } - ); - }); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const result = await isUnraidApiRunning(); - expect(result).toBe(false); - }, 30000); - - it('should handle PM2 connection errors gracefully', async () => { - // Disconnect PM2 first to ensure we're testing fresh connection - await new Promise((resolve) => { - pm2.disconnect(); - pm2Connected = false; - setTimeout(resolve, 100); - }); - - // Set an invalid PM2_HOME to force connection failure - const originalPM2Home = process.env.PM2_HOME; - process.env.PM2_HOME = '/invalid/path/that/does/not/exist'; - - const result = await isUnraidApiRunning(); - expect(result).toBe(false); - - // Restore original PM2_HOME - if (originalPM2Home) { - process.env.PM2_HOME = originalPM2Home; - } else { - delete process.env.PM2_HOME; - } - }, 15000); // 15 second timeout to allow for the Promise.race timeout - }); -}); diff --git a/api/src/__test__/core/utils/process/unraid-api-running.integration.test.ts b/api/src/__test__/core/utils/process/unraid-api-running.integration.test.ts new file mode 100644 index 0000000000..124641e68d --- /dev/null +++ b/api/src/__test__/core/utils/process/unraid-api-running.integration.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +describe('isUnraidApiRunning (nodemon pid detection)', () => { + let tempDir: string; + let pidPath: string; + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), 'unraid-api-')); + pidPath = join(tempDir, 'nodemon.pid'); + }); + + afterAll(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + afterEach(() => { + vi.resetModules(); + }); + + async function loadIsRunning() { + vi.doMock('@app/environment.js', async () => { + const actual = + await vi.importActual('@app/environment.js'); + return { ...actual, NODEMON_PID_PATH: pidPath }; + }); + + const module = await import('@app/core/utils/process/unraid-api-running.js'); + return module.isUnraidApiRunning; + } + + it('returns false when pid file is missing', async () => { + const isUnraidApiRunning = await loadIsRunning(); + + expect(await isUnraidApiRunning()).toBe(false); + }); + + it('returns true when a live pid is recorded', async () => { + writeFileSync(pidPath, `${process.pid}`); + const isUnraidApiRunning = await loadIsRunning(); + + expect(await isUnraidApiRunning()).toBe(true); + }); + + it('returns false when pid file is invalid', async () => { + writeFileSync(pidPath, 'not-a-number'); + const isUnraidApiRunning = await loadIsRunning(); + + expect(await isUnraidApiRunning()).toBe(false); + }); +}); diff --git a/api/src/core/log.ts b/api/src/core/log.ts index 84f66601fa..4d0311b35a 100644 --- a/api/src/core/log.ts +++ b/api/src/core/log.ts @@ -17,7 +17,7 @@ const nullDestination = pino.destination({ export const logDestination = process.env.SUPPRESS_LOGS === 'true' ? nullDestination : pino.destination(); -// Since PM2 captures stdout and writes to the log file, we should not colorize stdout +// Since process output is piped directly to the log file, we should not colorize stdout // to avoid ANSI escape codes in the log file const stream = SUPPRESS_LOGS ? nullDestination @@ -25,7 +25,7 @@ const stream = SUPPRESS_LOGS ? pretty({ singleLine: true, hideObject: false, - colorize: false, // No colors since PM2 writes stdout to file + colorize: false, // No colors since logs are written directly to file colorizeObjects: false, levelFirst: false, ignore: 'hostname,pid', diff --git a/api/src/core/utils/pm2/unraid-api-running.ts b/api/src/core/utils/pm2/unraid-api-running.ts deleted file mode 100644 index 4e65aa3ac9..0000000000 --- a/api/src/core/utils/pm2/unraid-api-running.ts +++ /dev/null @@ -1,40 +0,0 @@ -export const isUnraidApiRunning = async (): Promise => { - const { PM2_HOME } = await import('@app/environment.js'); - - // Set PM2_HOME if not already set - if (!process.env.PM2_HOME) { - process.env.PM2_HOME = PM2_HOME; - } - - const pm2Module = await import('pm2'); - const pm2 = pm2Module.default || pm2Module; - - const pm2Promise = new Promise((resolve) => { - pm2.connect(function (err) { - if (err) { - // Don't reject here, resolve with false since we can't connect to PM2 - resolve(false); - return; - } - - // Now try to describe unraid-api specifically - pm2.describe('unraid-api', function (err, processDescription) { - if (err || processDescription.length === 0) { - // Service not found or error occurred - resolve(false); - } else { - const isOnline = processDescription?.[0]?.pm2_env?.status === 'online'; - resolve(isOnline); - } - - pm2.disconnect(); - }); - }); - }); - - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => resolve(false), 10000); // 10 second timeout - }); - - return Promise.race([pm2Promise, timeoutPromise]); -}; diff --git a/api/src/core/utils/process/unraid-api-running.ts b/api/src/core/utils/process/unraid-api-running.ts new file mode 100644 index 0000000000..d1361b21fa --- /dev/null +++ b/api/src/core/utils/process/unraid-api-running.ts @@ -0,0 +1,23 @@ +import { readFile } from 'node:fs/promises'; + +import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { NODEMON_PID_PATH } from '@app/environment.js'; + +export const isUnraidApiRunning = async (): Promise => { + if (!(await fileExists(NODEMON_PID_PATH))) { + return false; + } + + const pidText = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim(); + const pid = Number.parseInt(pidText, 10); + if (Number.isNaN(pid)) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; diff --git a/api/src/environment.ts b/api/src/environment.ts index b1d3c2bad3..3ab43336bf 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -98,13 +98,22 @@ export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK ? 'https://staging.mothership.unraid.net/ws' : 'https://mothership.unraid.net/ws'; -export const PM2_HOME = process.env.PM2_HOME ?? '/var/log/.pm2'; -export const PM2_PATH = join(import.meta.dirname, '../../', 'node_modules', 'pm2', 'bin', 'pm2'); -export const ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json'); export const PATHS_LOGS_DIR = process.env.PATHS_LOGS_DIR ?? process.env.LOGS_DIR ?? '/var/log/unraid-api'; export const PATHS_LOGS_FILE = process.env.PATHS_LOGS_FILE ?? '/var/log/graphql-api.log'; +export const NODEMON_PATH = join( + import.meta.dirname, + '../../', + 'node_modules', + 'nodemon', + 'bin', + 'nodemon.js' +); +export const NODEMON_CONFIG_PATH = join(import.meta.dirname, '../../', 'nodemon.json'); +export const NODEMON_PID_PATH = process.env.NODEMON_PID_PATH ?? '/var/run/unraid-api/nodemon.pid'; +export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? join(import.meta.dirname, '../../'); + export const PATHS_CONFIG_MODULES = process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs'; diff --git a/api/src/unraid-api/cli/__test__/report.command.test.ts b/api/src/unraid-api/cli/__test__/report.command.test.ts index bbedcfbaf2..ffdbd4d77a 100644 --- a/api/src/unraid-api/cli/__test__/report.command.test.ts +++ b/api/src/unraid-api/cli/__test__/report.command.test.ts @@ -26,10 +26,10 @@ const mockApiReportService = { generateReport: vi.fn(), }; -// Mock PM2 check +// Mock process manager check const mockIsUnraidApiRunning = vi.fn().mockResolvedValue(true); -vi.mock('@app/core/utils/pm2/unraid-api-running.js', () => ({ +vi.mock('@app/core/utils/process/unraid-api-running.js', () => ({ isUnraidApiRunning: () => mockIsUnraidApiRunning(), })); @@ -50,7 +50,7 @@ describe('ReportCommand', () => { // Clear mocks vi.clearAllMocks(); - // Reset PM2 mock to default + // Reset nodemon mock to default mockIsUnraidApiRunning.mockResolvedValue(true); }); @@ -150,7 +150,7 @@ describe('ReportCommand', () => { // Reset mocks vi.clearAllMocks(); - // Test with API running but PM2 check returns true + // Test with API running but status check returns true mockIsUnraidApiRunning.mockResolvedValue(true); await reportCommand.report(); expect(mockApiReportService.generateReport).toHaveBeenCalledWith(true); diff --git a/api/src/unraid-api/cli/cli-services.module.ts b/api/src/unraid-api/cli/cli-services.module.ts index 7f248390d0..a92c126944 100644 --- a/api/src/unraid-api/cli/cli-services.module.ts +++ b/api/src/unraid-api/cli/cli-services.module.ts @@ -4,7 +4,7 @@ import { DependencyService } from '@app/unraid-api/app/dependency.service.js'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js'; import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js'; import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js'; @@ -21,7 +21,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u PluginCliModule.register(), UnraidFileModifierModule, ], - providers: [LogService, PM2Service, ApiKeyService, DependencyService, ApiReportService], + providers: [LogService, NodemonService, ApiKeyService, DependencyService, ApiReportService], exports: [ApiReportService, LogService, ApiKeyService], }) export class CliServicesModule {} diff --git a/api/src/unraid-api/cli/cli.module.ts b/api/src/unraid-api/cli/cli.module.ts index 7befdcb0e4..9569475cb2 100644 --- a/api/src/unraid-api/cli/cli.module.ts +++ b/api/src/unraid-api/cli/cli.module.ts @@ -13,6 +13,7 @@ import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.comman import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; import { LogsCommand } from '@app/unraid-api/cli/logs.command.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; import { InstallPluginCommand, ListPluginCommand, @@ -20,7 +21,6 @@ import { RemovePluginCommand, } from '@app/unraid-api/cli/plugins/plugin.command.js'; import { RemovePluginQuestionSet } from '@app/unraid-api/cli/plugins/remove-plugin.questions.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; import { ReportCommand } from '@app/unraid-api/cli/report.command.js'; import { RestartCommand } from '@app/unraid-api/cli/restart.command.js'; import { SSOCommand } from '@app/unraid-api/cli/sso/sso.command.js'; @@ -64,7 +64,7 @@ const DEFAULT_PROVIDERS = [ DeveloperQuestions, DeveloperToolsService, LogService, - PM2Service, + NodemonService, ApiKeyService, DependencyService, ApiReportService, diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 97e116fcbb..2c991a9431 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -559,6 +559,17 @@ export type CpuLoad = { percentUser: Scalars['Float']['output']; }; +export type CpuPackages = Node & { + __typename?: 'CpuPackages'; + id: Scalars['PrefixedID']['output']; + /** Power draw per package (W) */ + power: Array; + /** Temperature per package (°C) */ + temp: Array; + /** Total CPU package power draw (W) */ + totalPower: Scalars['Float']['output']; +}; + export type CpuUtilization = Node & { __typename?: 'CpuUtilization'; /** CPU load for each core */ @@ -869,6 +880,7 @@ export type InfoCpu = Node & { manufacturer?: Maybe; /** CPU model */ model?: Maybe; + packages: CpuPackages; /** Number of physical processors */ processors?: Maybe; /** CPU revision */ @@ -885,6 +897,8 @@ export type InfoCpu = Node & { stepping?: Maybe; /** Number of CPU threads */ threads?: Maybe; + /** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */ + topology: Array>>; /** CPU vendor */ vendor?: Maybe; /** CPU voltage */ @@ -1531,14 +1545,14 @@ export type PackageVersions = { nginx?: Maybe; /** Node.js version */ node?: Maybe; + /** nodemon version */ + nodemon?: Maybe; /** npm version */ npm?: Maybe; /** OpenSSL version */ openssl?: Maybe; /** PHP version */ php?: Maybe; - /** pm2 version */ - pm2?: Maybe; }; export type ParityCheck = { @@ -2053,6 +2067,7 @@ export type Subscription = { parityHistorySubscription: ParityCheck; serversSubscription: Server; systemMetricsCpu: CpuUtilization; + systemMetricsCpuTelemetry: CpuPackages; systemMetricsMemory: MemoryUtilization; upsUpdates: UpsDevice; }; diff --git a/api/src/unraid-api/cli/logs.command.ts b/api/src/unraid-api/cli/logs.command.ts index c15d8e25aa..0e5d7085fe 100644 --- a/api/src/unraid-api/cli/logs.command.ts +++ b/api/src/unraid-api/cli/logs.command.ts @@ -1,6 +1,6 @@ import { Command, CommandRunner, Option } from 'nest-commander'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; interface LogsOptions { lines: number; @@ -8,7 +8,7 @@ interface LogsOptions { @Command({ name: 'logs', description: 'View logs' }) export class LogsCommand extends CommandRunner { - constructor(private readonly pm2: PM2Service) { + constructor(private readonly nodemon: NodemonService) { super(); } @@ -20,13 +20,6 @@ export class LogsCommand extends CommandRunner { async run(_: string[], options?: LogsOptions): Promise { const lines = options?.lines ?? 100; - await this.pm2.run( - { tag: 'PM2 Logs', stdio: 'inherit' }, - 'logs', - 'unraid-api', - '--lines', - lines.toString(), - '--raw' - ); + await this.nodemon.logs(lines); } } diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts new file mode 100644 index 0000000000..7ee7dae84b --- /dev/null +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -0,0 +1,99 @@ +import { createWriteStream } from 'node:fs'; +import * as fs from 'node:fs/promises'; + +import { execa } from 'execa'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; + +vi.mock('node:fs', () => ({ + createWriteStream: vi.fn(() => ({ pipe: vi.fn() })), +})); +vi.mock('node:fs/promises'); +vi.mock('execa', () => ({ execa: vi.fn() })); +vi.mock('@app/core/utils/files/file-exists.js', () => ({ + fileExists: vi.fn().mockResolvedValue(false), +})); +vi.mock('@app/environment.js', () => ({ + NODEMON_CONFIG_PATH: '/etc/unraid-api/nodemon.json', + NODEMON_PATH: '/usr/bin/nodemon', + NODEMON_PID_PATH: '/var/run/unraid-api/nodemon.pid', + PATHS_LOGS_DIR: '/var/log/unraid-api', + PATHS_LOGS_FILE: '/var/log/graphql-api.log', + UNRAID_API_CWD: '/usr/local/unraid-api', +})); + +describe('NodemonService', () => { + const logger = { + trace: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + log: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + } as unknown as NodemonService['logger']; + + const mockMkdir = vi.mocked(fs.mkdir); + const mockWriteFile = vi.mocked(fs.writeFile); + const mockRm = vi.mocked(fs.rm); + + beforeEach(() => { + vi.clearAllMocks(); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined as unknown as void); + mockRm.mockResolvedValue(undefined as unknown as void); + vi.mocked(fileExists).mockResolvedValue(false); + }); + + it('ensures directories needed by nodemon exist', async () => { + const service = new NodemonService(logger); + + await service.ensureNodemonDependencies(); + + expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true }); + expect(mockMkdir).toHaveBeenCalledWith('/var/run/unraid-api', { recursive: true }); + }); + + it('starts nodemon and writes pid file', async () => { + const service = new NodemonService(logger); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 123, + stdout, + stderr, + unref, + } as unknown as ReturnType); + + await service.start({ env: { LOG_LEVEL: 'DEBUG' } }); + + expect(execa).toHaveBeenCalledWith( + '/usr/bin/nodemon', + ['--config', '/etc/unraid-api/nodemon.json', '--quiet'], + { + cwd: '/usr/local/unraid-api', + env: expect.objectContaining({ LOG_LEVEL: 'DEBUG' }), + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + expect(createWriteStream).toHaveBeenCalledWith('/var/log/graphql-api.log', { flags: 'a' }); + expect(stdout.pipe).toHaveBeenCalled(); + expect(stderr.pipe).toHaveBeenCalled(); + expect(unref).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '123'); + expect(logger.info).toHaveBeenCalledWith('Started nodemon (pid 123)'); + }); + + it('returns not running when pid file is missing', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(false); + + const result = await service.status(); + + expect(result).toBe(false); + expect(logger.info).toHaveBeenCalledWith('unraid-api is not running (no pid file).'); + }); +}); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts new file mode 100644 index 0000000000..1671e45263 --- /dev/null +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -0,0 +1,133 @@ +import { Injectable } from '@nestjs/common'; +import { createWriteStream } from 'node:fs'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { execa } from 'execa'; + +import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { + NODEMON_CONFIG_PATH, + NODEMON_PATH, + NODEMON_PID_PATH, + PATHS_LOGS_DIR, + PATHS_LOGS_FILE, + UNRAID_API_CWD, +} from '@app/environment.js'; +import { LogService } from '@app/unraid-api/cli/log.service.js'; + +type StartOptions = { + env?: Record; +}; + +type StopOptions = { + /** When true, uses SIGKILL instead of SIGTERM */ + force?: boolean; + /** Suppress warnings when there is no pid file */ + quiet?: boolean; +}; + +@Injectable() +export class NodemonService { + constructor(private readonly logger: LogService) {} + + async ensureNodemonDependencies() { + try { + await mkdir(PATHS_LOGS_DIR, { recursive: true }); + await mkdir(dirname(NODEMON_PID_PATH), { recursive: true }); + } catch (error) { + this.logger.error( + `Failed to fully ensure nodemon dependencies: ${error instanceof Error ? error.message : error}` + ); + } + } + + private async getStoredPid(): Promise { + if (!(await fileExists(NODEMON_PID_PATH))) return null; + const contents = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim(); + const pid = Number.parseInt(contents, 10); + return Number.isNaN(pid) ? null : pid; + } + + private async isPidRunning(pid: number): Promise { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } + + async start(options: StartOptions = {}) { + await this.ensureNodemonDependencies(); + await this.stop({ quiet: true }); + + const env = { ...process.env, ...options.env } as Record; + const logStream = createWriteStream(PATHS_LOGS_FILE, { flags: 'a' }); + + const nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], { + cwd: UNRAID_API_CWD, + env, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + nodemonProcess.stdout?.pipe(logStream); + nodemonProcess.stderr?.pipe(logStream); + nodemonProcess.unref(); + + if (nodemonProcess.pid) { + await writeFile(NODEMON_PID_PATH, `${nodemonProcess.pid}`); + this.logger.info(`Started nodemon (pid ${nodemonProcess.pid})`); + } else { + this.logger.error('Failed to determine nodemon pid.'); + } + } + + async stop(options: StopOptions = {}) { + const pid = await this.getStoredPid(); + if (!pid) { + if (!options.quiet) { + this.logger.warn('No nodemon pid file found. Nothing to stop.'); + } + return; + } + + const signal: NodeJS.Signals = options.force ? 'SIGKILL' : 'SIGTERM'; + try { + process.kill(pid, signal); + this.logger.trace(`Sent ${signal} to nodemon (pid ${pid})`); + } catch (error) { + this.logger.error(`Failed to stop nodemon (pid ${pid}): ${error}`); + } finally { + await rm(NODEMON_PID_PATH, { force: true }); + } + } + + async restart(options: StartOptions = {}) { + await this.stop({ quiet: true }); + await this.start(options); + } + + async status(): Promise { + const pid = await this.getStoredPid(); + if (!pid) { + this.logger.info('unraid-api is not running (no pid file).'); + return false; + } + + const running = await this.isPidRunning(pid); + if (running) { + this.logger.info(`unraid-api is running under nodemon (pid ${pid}).`); + } else { + this.logger.warn(`Found nodemon pid file (${pid}) but the process is not running.`); + await rm(NODEMON_PID_PATH, { force: true }); + } + return running; + } + + async logs(lines = 100) { + const { stdout } = await execa('tail', ['-n', `${lines}`, PATHS_LOGS_FILE]); + this.logger.log(stdout); + } +} diff --git a/api/src/unraid-api/cli/pm2.service.spec.ts b/api/src/unraid-api/cli/pm2.service.spec.ts deleted file mode 100644 index 8c16cd5188..0000000000 --- a/api/src/unraid-api/cli/pm2.service.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import * as fs from 'node:fs/promises'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { LogService } from '@app/unraid-api/cli/log.service.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; - -vi.mock('node:fs/promises'); -vi.mock('execa'); -vi.mock('@app/core/utils/files/file-exists.js', () => ({ - fileExists: vi.fn().mockResolvedValue(false), -})); -vi.mock('@app/environment.js', () => ({ - PATHS_LOGS_DIR: '/var/log/unraid-api', - PM2_HOME: '/var/log/.pm2', - PM2_PATH: '/path/to/pm2', - ECOSYSTEM_PATH: '/path/to/ecosystem.config.json', - SUPPRESS_LOGS: false, - LOG_LEVEL: 'info', -})); - -describe('PM2Service', () => { - let pm2Service: PM2Service; - let logService: LogService; - const mockMkdir = vi.mocked(fs.mkdir); - - beforeEach(() => { - vi.clearAllMocks(); - logService = { - trace: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - log: vi.fn(), - info: vi.fn(), - debug: vi.fn(), - } as unknown as LogService; - pm2Service = new PM2Service(logService); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('ensurePm2Dependencies', () => { - it('should create logs directory and log that PM2 will handle its own directory', async () => { - mockMkdir.mockResolvedValue(undefined); - - await pm2Service.ensurePm2Dependencies(); - - expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true }); - expect(mockMkdir).toHaveBeenCalledTimes(1); // Only logs directory, not PM2_HOME - expect(logService.trace).toHaveBeenCalledWith( - 'PM2_HOME will be created at /var/log/.pm2 when PM2 daemon starts' - ); - }); - - it('should log error but not throw when logs directory creation fails', async () => { - mockMkdir.mockRejectedValue(new Error('Disk full')); - - await expect(pm2Service.ensurePm2Dependencies()).resolves.not.toThrow(); - - expect(logService.error).toHaveBeenCalledWith( - expect.stringContaining('Failed to fully ensure PM2 dependencies: Disk full') - ); - }); - - it('should handle mkdir with recursive flag for nested logs path', async () => { - mockMkdir.mockResolvedValue(undefined); - - await pm2Service.ensurePm2Dependencies(); - - expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true }); - expect(mockMkdir).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/api/src/unraid-api/cli/pm2.service.ts b/api/src/unraid-api/cli/pm2.service.ts deleted file mode 100644 index b16a4a40b1..0000000000 --- a/api/src/unraid-api/cli/pm2.service.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { mkdir, rm } from 'node:fs/promises'; -import { join } from 'node:path'; - -import type { Options, Result, ResultPromise } from 'execa'; -import { execa, ExecaError } from 'execa'; - -import { fileExists } from '@app/core/utils/files/file-exists.js'; -import { PATHS_LOGS_DIR, PM2_HOME, PM2_PATH } from '@app/environment.js'; -import { LogService } from '@app/unraid-api/cli/log.service.js'; - -type CmdContext = Options & { - /** A tag for logging & debugging purposes. Should represent the operation being performed. */ - tag: string; - /** Default: false. - * - * When true, results will not be automatically handled and logged. - * The caller must handle desired effects, such as logging, error handling, etc. - */ - raw?: boolean; -}; - -@Injectable() -export class PM2Service { - constructor(private readonly logger: LogService) {} - - // Type Overload: if raw is true, return an execa ResultPromise (which is a Promise with extra properties) - /** - * Executes a PM2 command with the specified context and arguments. - * Handles logging automatically (stdout -> trace, stderr -> error), unless the `raw` flag is - * set to true, in which case the caller must handle desired effects. - * - * @param context - Execa Options for command execution, such as a unique tag for logging - * and whether the result should be handled raw. - * @param args - The arguments to pass to the PM2 command. - * @returns ResultPromise\<@param context\> When raw is true - * @returns Promise\ When raw is false - */ - run(context: T & { raw: true }, ...args: string[]): ResultPromise; - - run(context: CmdContext & { raw?: false }, ...args: string[]): Promise; - - async run(context: CmdContext, ...args: string[]) { - const { tag, raw, ...execOptions } = context; - // Default to true to match execa's default behavior - execOptions.extendEnv ??= true; - execOptions.shell ??= 'bash'; - - // Ensure /usr/local/bin is in PATH for Node.js - const currentPath = execOptions.env?.PATH || process.env.PATH || '/usr/bin:/bin:/usr/sbin:/sbin'; - const needsPathUpdate = !currentPath.includes('/usr/local/bin'); - const finalPath = needsPathUpdate ? `/usr/local/bin:${currentPath}` : currentPath; - - // Always ensure PM2_HOME is set in the environment for every PM2 command - execOptions.env = { - ...execOptions.env, - PM2_HOME, - ...(needsPathUpdate && { PATH: finalPath }), - }; - - const pm2Args = args.some((arg) => arg === '--no-color') ? args : ['--no-color', ...args]; - const runCommand = () => execa(PM2_PATH, pm2Args, execOptions satisfies Options); - if (raw) { - return runCommand(); - } - return runCommand() - .then((result) => { - this.logger.trace(result.stdout); - return result; - }) - .catch((result: Result) => { - this.logger.error(`PM2 error occurred from tag "${tag}": ${result.stdout}\n`); - return result; - }); - } - - /** - * Deletes the PM2 dump file. - * - * This method removes the PM2 dump file located at `~/.pm2/dump.pm2` by default. - * It logs a message indicating that the PM2 dump has been cleared. - * - * @returns A promise that resolves once the dump file is removed. - */ - async deleteDump(dumpFile = join(PM2_HOME, 'dump.pm2')) { - await rm(dumpFile, { force: true }); - this.logger.trace('PM2 dump cleared.'); - } - - async forceKillPm2Daemon() { - try { - // Find all PM2 daemon processes and kill them - const pids = (await execa('pgrep', ['-i', 'PM2'])).stdout.split('\n').filter(Boolean); - if (pids.length > 0) { - await execa('kill', ['-9', ...pids]); - this.logger.trace(`Killed PM2 daemon processes: ${pids.join(', ')}`); - } - } catch (err) { - if (err instanceof ExecaError && err.exitCode === 1) { - this.logger.trace('No PM2 daemon processes found.'); - } else { - this.logger.error(`Error force killing PM2 daemon: ${err}`); - } - } - } - - async deletePm2Home() { - if ((await fileExists(PM2_HOME)) && (await fileExists(join(PM2_HOME, 'pm2.log')))) { - await rm(PM2_HOME, { recursive: true, force: true }); - this.logger.trace('PM2 home directory cleared.'); - } else { - this.logger.trace('PM2 home directory does not exist.'); - } - } - - /** - * Ensures that the dependencies necessary for PM2 to start and operate are present. - * Creates PM2_HOME directory with proper permissions if it doesn't exist. - */ - async ensurePm2Dependencies() { - try { - // Create logs directory - await mkdir(PATHS_LOGS_DIR, { recursive: true }); - - // PM2 automatically creates and manages its home directory when the daemon starts - this.logger.trace(`PM2_HOME will be created at ${PM2_HOME} when PM2 daemon starts`); - } catch (error) { - // Log error but don't throw - let PM2 fail with its own error messages if the setup is incomplete - this.logger.error( - `Failed to fully ensure PM2 dependencies: ${error instanceof Error ? error.message : error}. PM2 may encounter issues during operation.` - ); - } - } -} diff --git a/api/src/unraid-api/cli/report.command.ts b/api/src/unraid-api/cli/report.command.ts index 1e03dea5c5..49188cf77c 100644 --- a/api/src/unraid-api/cli/report.command.ts +++ b/api/src/unraid-api/cli/report.command.ts @@ -33,9 +33,9 @@ export class ReportCommand extends CommandRunner { async report(): Promise { try { // Check if API is running - const { isUnraidApiRunning } = await import('@app/core/utils/pm2/unraid-api-running.js'); + const { isUnraidApiRunning } = await import('@app/core/utils/process/unraid-api-running.js'); const apiRunning = await isUnraidApiRunning().catch((err) => { - this.logger.debug('failed to get PM2 state with error: ' + err); + this.logger.debug('failed to check nodemon state with error: ' + err); return false; }); diff --git a/api/src/unraid-api/cli/restart.command.ts b/api/src/unraid-api/cli/restart.command.ts index 66d54a513e..166162fa6b 100644 --- a/api/src/unraid-api/cli/restart.command.ts +++ b/api/src/unraid-api/cli/restart.command.ts @@ -2,9 +2,9 @@ import { Command, CommandRunner, Option } from 'nest-commander'; import type { LogLevel } from '@app/core/log.js'; import { levels } from '@app/core/log.js'; -import { ECOSYSTEM_PATH, LOG_LEVEL } from '@app/environment.js'; +import { LOG_LEVEL } from '@app/environment.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; export interface LogLevelOptions { logLevel?: LogLevel; @@ -22,7 +22,7 @@ export function parseLogLevelOption(val: string, allowedLevels: string[] = [...l export class RestartCommand extends CommandRunner { constructor( private readonly logger: LogService, - private readonly pm2: PM2Service + private readonly nodemon: NodemonService ) { super(); } @@ -30,23 +30,9 @@ export class RestartCommand extends CommandRunner { async run(_?: string[], options: LogLevelOptions = {}): Promise { try { this.logger.info('Restarting the Unraid API...'); - const env = { LOG_LEVEL: options.logLevel }; - const { stderr, stdout } = await this.pm2.run( - { tag: 'PM2 Restart', raw: true, extendEnv: true, env }, - 'restart', - ECOSYSTEM_PATH, - '--update-env', - '--mini-list' - ); - - if (stderr) { - this.logger.error(stderr.toString()); - process.exit(1); - } else if (stdout) { - this.logger.info(stdout.toString()); - } else { - this.logger.info('Unraid API restarted'); - } + const env = { LOG_LEVEL: options.logLevel?.toUpperCase() }; + await this.nodemon.restart({ env }); + this.logger.info('Unraid API restarted'); } catch (error) { if (error instanceof Error) { this.logger.error(error.message); diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index 64c7d890d0..61660612c9 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -3,46 +3,23 @@ import { Command, CommandRunner, Option } from 'nest-commander'; import type { LogLevel } from '@app/core/log.js'; import type { LogLevelOptions } from '@app/unraid-api/cli/restart.command.js'; import { levels } from '@app/core/log.js'; -import { ECOSYSTEM_PATH, LOG_LEVEL } from '@app/environment.js'; +import { LOG_LEVEL } from '@app/environment.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; import { parseLogLevelOption } from '@app/unraid-api/cli/restart.command.js'; @Command({ name: 'start', description: 'Start the Unraid API' }) export class StartCommand extends CommandRunner { constructor( private readonly logger: LogService, - private readonly pm2: PM2Service + private readonly nodemon: NodemonService ) { super(); } - async cleanupPM2State() { - await this.pm2.ensurePm2Dependencies(); - await this.pm2.run({ tag: 'PM2 Stop' }, 'stop', ECOSYSTEM_PATH); - await this.pm2.run({ tag: 'PM2 Update' }, 'update'); - await this.pm2.deleteDump(); - await this.pm2.run({ tag: 'PM2 Delete' }, 'delete', ECOSYSTEM_PATH); - } - async run(_: string[], options: LogLevelOptions): Promise { this.logger.info('Starting the Unraid API'); - await this.cleanupPM2State(); - const env = { LOG_LEVEL: options.logLevel }; - const { stderr, stdout } = await this.pm2.run( - { tag: 'PM2 Start', raw: true, extendEnv: true, env }, - 'start', - ECOSYSTEM_PATH, - '--update-env', - '--mini-list' - ); - if (stdout) { - this.logger.log(stdout.toString()); - } - if (stderr) { - this.logger.error(stderr.toString()); - process.exit(1); - } + await this.nodemon.start({ env: { LOG_LEVEL: options.logLevel?.toUpperCase() } }); } @Option({ diff --git a/api/src/unraid-api/cli/status.command.ts b/api/src/unraid-api/cli/status.command.ts index 6e1b6b6e2e..489198e3b9 100644 --- a/api/src/unraid-api/cli/status.command.ts +++ b/api/src/unraid-api/cli/status.command.ts @@ -1,18 +1,13 @@ import { Command, CommandRunner } from 'nest-commander'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; @Command({ name: 'status', description: 'Check status of unraid-api service' }) export class StatusCommand extends CommandRunner { - constructor(private readonly pm2: PM2Service) { + constructor(private readonly nodemon: NodemonService) { super(); } async run(): Promise { - await this.pm2.run( - { tag: 'PM2 Status', stdio: 'inherit', raw: true }, - 'status', - 'unraid-api', - '--mini-list' - ); + await this.nodemon.status(); } } diff --git a/api/src/unraid-api/cli/stop.command.ts b/api/src/unraid-api/cli/stop.command.ts index 995dd07437..376c89c6e2 100644 --- a/api/src/unraid-api/cli/stop.command.ts +++ b/api/src/unraid-api/cli/stop.command.ts @@ -1,41 +1,28 @@ import { Command, CommandRunner, Option } from 'nest-commander'; -import { ECOSYSTEM_PATH } from '@app/environment.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; interface StopCommandOptions { - delete: boolean; + force: boolean; } @Command({ name: 'stop', description: 'Stop the Unraid API', }) export class StopCommand extends CommandRunner { - constructor(private readonly pm2: PM2Service) { + constructor(private readonly nodemon: NodemonService) { super(); } @Option({ - flags: '-d, --delete', - description: 'Delete the PM2 home directory', + flags: '-f, --force', + description: 'Forcefully stop the API process', }) - parseDelete(): boolean { + parseForce(): boolean { return true; } - async run(_: string[], options: StopCommandOptions = { delete: false }) { - if (options.delete) { - await this.pm2.run({ tag: 'PM2 Kill', stdio: 'inherit' }, 'kill', '--no-autorestart'); - await this.pm2.forceKillPm2Daemon(); - await this.pm2.deletePm2Home(); - } else { - await this.pm2.run( - { tag: 'PM2 Delete', stdio: 'inherit' }, - 'delete', - ECOSYSTEM_PATH, - '--no-autorestart', - '--mini-list' - ); - } + async run(_: string[], options: StopCommandOptions = { force: false }) { + await this.nodemon.stop({ force: options.force }); } } diff --git a/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts b/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts index dd6fe5d880..2080cbbb91 100644 --- a/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts @@ -25,8 +25,8 @@ export class PackageVersions { @Field(() => String, { nullable: true, description: 'npm version' }) npm?: string; - @Field(() => String, { nullable: true, description: 'pm2 version' }) - pm2?: string; + @Field(() => String, { nullable: true, description: 'nodemon version' }) + nodemon?: string; @Field(() => String, { nullable: true, description: 'Git version' }) git?: string; diff --git a/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts b/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts index a711a17dd1..836122b3b5 100644 --- a/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts @@ -3,6 +3,7 @@ import { ResolveField, Resolver } from '@nestjs/graphql'; import { versions } from 'systeminformation'; +import { getPackageJson } from '@app/environment.js'; import { CoreVersions, InfoVersions, @@ -34,7 +35,7 @@ export class VersionsResolver { openssl: softwareVersions.openssl, node: softwareVersions.node, npm: softwareVersions.npm, - pm2: softwareVersions.pm2, + nodemon: getPackageJson().dependencies?.nodemon, git: softwareVersions.git, nginx: softwareVersions.nginx, php: softwareVersions.php, diff --git a/api/src/unraid-api/main.ts b/api/src/unraid-api/main.ts index 4b753abfaa..cc07b5b639 100644 --- a/api/src/unraid-api/main.ts +++ b/api/src/unraid-api/main.ts @@ -140,16 +140,6 @@ export async function bootstrapNestServer(): Promise { apiLogger.info('Server listening on %s', result); } - // This 'ready' signal tells pm2 that the api has started. - // PM2 documents this as Graceful Start or Clean Restart. - // See https://pm2.keymetrics.io/docs/usage/signals-clean-restart/ - if (process.send) { - process.send('ready'); - } else { - apiLogger.warn( - 'Warning: process.send is unavailable. This will affect IPC communication with PM2.' - ); - } apiLogger.info('Nest Server is now listening'); return app; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8db98eb3a4..a9a59a9b9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,9 @@ importers: node-window-polyfill: specifier: 1.0.4 version: 1.0.4 + nodemon: + specifier: 3.1.10 + version: 3.1.10 openid-client: specifier: 6.6.4 version: 6.6.4 @@ -286,9 +289,6 @@ importers: pino-pretty: specifier: 13.1.1 version: 13.1.1 - pm2: - specifier: 6.0.8 - version: 6.0.8 reflect-metadata: specifier: ^0.1.14 version: 0.1.14 @@ -458,9 +458,6 @@ importers: jiti: specifier: 2.5.1 version: 2.5.1 - nodemon: - specifier: 3.1.10 - version: 3.1.10 prettier: specifier: 3.6.2 version: 3.6.2 @@ -4028,20 +4025,6 @@ packages: resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@pm2/agent@2.1.1': - resolution: {integrity: sha512-0V9ckHWd/HSC8BgAbZSoq8KXUG81X97nSkAxmhKDhmF8vanyaoc1YXwc2KVkbWz82Rg4gjd2n9qiT3i7bdvGrQ==} - - '@pm2/io@6.1.0': - resolution: {integrity: sha512-IxHuYURa3+FQ6BKePlgChZkqABUKFYH6Bwbw7V/pWU1pP6iR1sCI26l7P9ThUEB385ruZn/tZS3CXDUF5IA1NQ==} - engines: {node: '>=6.0'} - - '@pm2/js-api@0.8.0': - resolution: {integrity: sha512-nmWzrA/BQZik3VBz+npRcNIu01kdBhWL0mxKmP1ciF/gTcujPTQqt027N9fc1pK9ERM8RipFhymw7RcmCyOEYA==} - engines: {node: '>=4.0'} - - '@pm2/pm2-version-check@1.0.4': - resolution: {integrity: sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==} - '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -4764,9 +4747,6 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - '@tootallnate/quickjs-emscripten@0.23.0': - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@ts-morph/common@0.25.0': resolution: {integrity: sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==} @@ -5771,16 +5751,6 @@ packages: alien-signals@2.0.5: resolution: {integrity: sha512-PdJB6+06nUNAClInE3Dweq7/2xVAYM64vvvS1IHVHSJmgeOtEdrAGyp7Z2oJtYm0B342/Exd2NT0uMJaThcjLQ==} - amp-message@0.1.2: - resolution: {integrity: sha512-JqutcFwoU1+jhv7ArgW38bqrE+LQdcRv4NxNw0mp0JHQyB6tXesWRjtYKlDgHRY2o3JE5UTaBGUK8kSWUdxWUg==} - - amp@0.3.1: - resolution: {integrity: sha512-OwIuC4yZaRogHKiuU5WlMR5Xk/jAcpPtawWL05Gj8Lvm2F6mwoJt4O/bHI+DHwG79vWd+8OFYM4/BzYqyRd3qw==} - - ansi-colors@4.1.3: - resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} - engines: {node: '>=6'} - ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -5824,10 +5794,6 @@ packages: ansi_up@6.0.6: resolution: {integrity: sha512-yIa1x3Ecf8jWP4UWEunNjqNX6gzE4vg2gGz+xqRGY+TBSucnYp6RRdPV4brmtg6bQ1ljD48mZ5iGSEj7QEpRKA==} - ansis@4.0.0-node10: - resolution: {integrity: sha512-BRrU0Bo1X9dFGw6KgGz6hWrqQuOlVEDOzkb0QSLZY9sXHqA7pNj7yHPVJRz7y/rj4EOJ3d/D5uxH+ee9leYgsg==} - engines: {node: '>=10'} - ansis@4.1.0: resolution: {integrity: sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==} engines: {node: '>=14'} @@ -5935,10 +5901,6 @@ packages: resolution: {integrity: sha512-ZtfIlyTCmnAXPCQo4mSDtFsHL7L3q0sJfpVYPmy5uYPjs+fynzOuc1Cg6yQ9fF6h61RjEWtOlRFwV1Kc80Qs6A==} engines: {node: '>=4'} - ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -5967,9 +5929,6 @@ packages: async@1.5.2: resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==} - async@2.6.4: - resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} - async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -6055,10 +6014,6 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} - basic-ftp@5.0.5: - resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} - engines: {node: '>=10.0.0'} - bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} @@ -6082,17 +6037,9 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} - blessed@0.1.81: - resolution: {integrity: sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==} - engines: {node: '>= 0.8.0'} - hasBin: true - blob-to-buffer@1.2.9: resolution: {integrity: sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA==} - bodec@0.1.0: - resolution: {integrity: sha512-Ylo+MAo5BDUq1KA3f3R/MFhh+g8cnHmo8bz3YPGhI1znrMaf77ol1sfvYJzsw3nTE+Y2GryfDxBaR+AqpAkEHQ==} - body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -6273,9 +6220,6 @@ packages: chardet@2.1.0: resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} - charm@0.1.2: - resolution: {integrity: sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==} - check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -6330,10 +6274,6 @@ packages: resolution: {integrity: sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==} engines: {node: '>= 0.2.0'} - cli-tableau@2.0.1: - resolution: {integrity: sha512-he+WTicka9cl0Fg/y+YyxcN6/bfQ/1O3QmgxRXDhABKqLzvoOSM4fMzp39uMyLBulAFuywD2N7UaoQE7WaADxQ==} - engines: {node: '>=8.10.0'} - cli-truncate@4.0.0: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} @@ -6439,9 +6379,6 @@ packages: resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} engines: {node: '>=20'} - commander@2.15.1: - resolution: {integrity: sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==} - commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -6630,9 +6567,6 @@ packages: resolution: {integrity: sha512-ciiYNLfSlF9MrDqnbMdRWFiA6oizSF7kA1osPP9lRzNu0Uu+AWog1UKy7SkckiDY2irrNjeO6qLyKnXC8oxmrw==} engines: {node: '>=18.x'} - croner@4.1.97: - resolution: {integrity: sha512-/f6gpQuxDaqXu+1kwQYSckUglPaOrHdbIlBAu0YuW8/Cdb45XwXYNUBXg3r/9Mo6n540Kn/smKcZWko5x99KrQ==} - croner@9.1.0: resolution: {integrity: sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==} engines: {node: '>=18.0'} @@ -6724,9 +6658,6 @@ packages: csv-parse@5.6.0: resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==} - culvert@0.1.2: - resolution: {integrity: sha512-yi1x3EAWKjQTreYWeSd98431AV+IEE0qoDyOoaHJ7KJ21gv6HtBXHVLX74opVSGqcR8/AbjJBHAHpcOy2bj5Gg==} - d@1.0.2: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} @@ -6735,10 +6666,6 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -6764,15 +6691,9 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - dayjs@1.11.13: - resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} - dayjs@1.11.14: resolution: {integrity: sha512-E8fIdSxUlyqSA8XYGnNa3IkIzxtEmFjI+JU/6ic0P1zmSqyL6HyG5jHnpPjRguDNiaHLpfvHKWFiohNsJLqcJQ==} - dayjs@1.8.36: - resolution: {integrity: sha512-3VmRXEtw7RZKAf+4Tv1Ym9AGeo8r8+CjDi26x+7SYQil1UqtqdaokhzoEJohqlzt0m5kacJSDhJQkG/LWhpRBw==} - db0@0.3.2: resolution: {integrity: sha512-xzWNQ6jk/+NtdfLyXEipbX55dmDSeteLFt/ayF+wZUU5bzKgmrDOxmInUTbyVRp46YwnJdkDA1KhB7WIXFofJw==} peerDependencies: @@ -6819,15 +6740,6 @@ packages: supports-color: optional: true - debug@4.3.7: - resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -6911,10 +6823,6 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} - degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -7151,10 +7059,6 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} - enquirer@2.3.6: - resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} - engines: {node: '>=8.6'} - entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -7407,11 +7311,6 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true @@ -7602,9 +7501,6 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - eventemitter2@5.0.1: - resolution: {integrity: sha512-5EM1GHXycJBS6mauYAbVKT1cVs7POKWb2NXD4Vyt8dDqeZa7LaDK1/sjtL+Zb0lzTpSNil4596Dyu97hz37QLg==} - eventemitter2@6.4.9: resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} @@ -7674,9 +7570,6 @@ packages: resolution: {integrity: sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ==} engines: {node: ^12.20 || >= 14.13} - extrareqp2@1.0.0: - resolution: {integrity: sha512-Gum0g1QYb6wpPJCVypWP3bbIuaibcFiJcpuPM10YSXp/tzqi84x9PJageob+eN4xVRIOto4wjSGNLyMD54D2xA==} - fast-check@4.2.0: resolution: {integrity: sha512-buxrKEaSseOwFjt6K1REcGMeFOrb0wk3cXifeMAG8yahcE9kV20PjQn1OdzPGL6OBFTbYXfjleNBARf/aCfV1A==} engines: {node: '>=12.17.0'} @@ -7700,9 +7593,6 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-json-patch@3.1.1: - resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -7756,9 +7646,6 @@ packages: fbjs@3.0.5: resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} - fclone@1.0.11: - resolution: {integrity: sha512-GDqVQezKzRABdeqflsgMr7ktzgF9CyS+p2oe0jJqUY6izSSbhPIQJDpoU4PtGcD7VPM9xh/dVrTu6z1nwgmEGw==} - fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} @@ -8001,25 +7888,10 @@ packages: get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} - get-uri@6.0.4: - resolution: {integrity: sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==} - engines: {node: '>= 14'} - giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true - git-node-fs@1.0.0: - resolution: {integrity: sha512-bLQypt14llVXBg0S0u8q8HmU7g9p3ysH+NvVlae5vILuUvs759665HvmR5+wb04KjHyjFcDRxdYb4kyNnluMUQ==} - peerDependencies: - js-git: ^0.7.8 - peerDependenciesMeta: - js-git: - optional: true - - git-sha1@0.1.2: - resolution: {integrity: sha512-2e/nZezdVlyCopOCYHeW0onkbZg7xP1Ad6pndPy1rCygeRykefUS6r7oA5cJRGEFvseiaz5a/qUHFVX1dd6Isg==} - git-up@8.1.1: resolution: {integrity: sha512-FDenSF3fVqBYSaJoYy1KSc2wosx0gCvKP+c+PRBht7cAaiCeQlBtfBDX9vgnNOHmdePlSFITVcn4pFfcgNvx3g==} @@ -8454,10 +8326,6 @@ packages: resolution: {integrity: sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==} engines: {node: '>=12.22.0'} - ip-address@9.0.5: - resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} - engines: {node: '>= 12'} - ip@2.0.1: resolution: {integrity: sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==} @@ -8818,9 +8686,6 @@ packages: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} - js-git@0.7.8: - resolution: {integrity: sha512-+E5ZH/HeRnoc/LW0AmAyhU+mNcWBzAKE+30+IDMLSLbbK+Tdt02AdkOKq9u15rlJsDEGFqtgckc8ZM59LhhiUA==} - js-stringify@1.0.2: resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} @@ -8834,9 +8699,6 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true - jsbn@1.1.0: - resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} - jsdom@26.1.0: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} @@ -9415,11 +9277,6 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} @@ -9434,9 +9291,6 @@ packages: mocked-exports@0.1.1: resolution: {integrity: sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA==} - module-details-from-path@1.0.3: - resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==} - motion-dom@12.23.12: resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} @@ -9516,11 +9370,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - needle@2.4.0: - resolution: {integrity: sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg==} - engines: {node: '>= 4.4.x'} - hasBin: true - negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -9561,10 +9410,6 @@ packages: resolution: {integrity: sha512-Nc3loyVASW59W+8fLDZT1lncpG7llffyZ2o0UQLx/Fr20i7P8oP+lE7+TEcFvXj9IUWU6LjB9P3BH+iFGyp+mg==} engines: {node: ^14.16.0 || >=16.0.0} - netmask@2.0.2: - resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} - engines: {node: '>= 0.4.0'} - next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} @@ -9912,14 +9757,6 @@ packages: resolution: {integrity: sha512-lwx6u1CotQYPVju77R+D0vFomni/AqRfqLmqQ8hekklqZ6gAY9rONh7lBQ0uxWMkC2AuX9b2DVAl8To0NyP1JA==} engines: {node: '>=12'} - pac-proxy-agent@7.1.0: - resolution: {integrity: sha512-Z5FnLVVZSnX7WjBg0mhDtydeRZ1xMcATZThjySQUHqr+0ksP8kqaw23fNKkaaN/Z8gwLUs/W7xdl0I75eP2Xyw==} - engines: {node: '>= 14'} - - pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -10099,14 +9936,6 @@ packages: engines: {node: '>=0.10'} hasBin: true - pidusage@2.0.21: - resolution: {integrity: sha512-cv3xAQos+pugVX+BfXpHsbyz/dLzX+lr44zNMsYiGxUw+kV5sgQCIcLd1z+0vq+KyC7dJ+/ts2PsfgWfSC3WXA==} - engines: {node: '>=8'} - - pidusage@3.0.2: - resolution: {integrity: sha512-g0VU+y08pKw5M8EZ2rIGiEBaB8wrQMjYGFfW2QVIfyT8V+fq8YFLkvlz4bz5ljvFDJYNFCWT3PWqcRr2FKO81w==} - engines: {node: '>=10'} - pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -10172,29 +10001,6 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} - pm2-axon-rpc@0.7.1: - resolution: {integrity: sha512-FbLvW60w+vEyvMjP/xom2UPhUN/2bVpdtLfKJeYM3gwzYhoTEEChCOICfFzxkxuoEleOlnpjie+n1nue91bDQw==} - engines: {node: '>=5'} - - pm2-axon@4.0.1: - resolution: {integrity: sha512-kES/PeSLS8orT8dR5jMlNl+Yu4Ty3nbvZRmaAtROuVm9nYYGiaoXqqKQqQYzWQzMYWUKHMQTvBlirjE5GIIxqg==} - engines: {node: '>=5'} - - pm2-deploy@1.0.2: - resolution: {integrity: sha512-YJx6RXKrVrWaphEYf++EdOOx9EH18vM8RSZN/P1Y+NokTKqYAca/ejXwVLyiEpNju4HPZEk3Y2uZouwMqUlcgg==} - engines: {node: '>=4.0.0'} - - pm2-multimeter@0.1.2: - resolution: {integrity: sha512-S+wT6XfyKfd7SJIBqRgOctGxaBzUOmVQzTAS+cg04TsEUObJVreha7lvCfX8zzGVr871XwCSnHUU7DQQ5xEsfA==} - - pm2-sysmonit@1.2.8: - resolution: {integrity: sha512-ACOhlONEXdCTVwKieBIQLSi2tQZ8eKinhcr9JpZSUAL8Qy0ajIgRtsLxG/lwPOW3JEKqPyw/UaHmTWhUzpP4kA==} - - pm2@6.0.8: - resolution: {integrity: sha512-y7sO+UuGjfESK/ChRN+efJKAsHrBd95GY2p1GQfjVTtOfFtUfiW0NOuUhP5dN5QTF2F0EWcepgkLqbF32j90Iw==} - engines: {node: '>=16.0.0'} - hasBin: true - portfinder@1.0.35: resolution: {integrity: sha512-73JaFg4NwYNAufDtS5FsFu/PdM49ahJrO1i44aCRsDWju1z5wuGDaqyFUQWR6aJoK2JPDWlaYYAGFNIGTSUHSw==} engines: {node: '>= 10.12'} @@ -10512,9 +10318,6 @@ packages: promise@7.3.1: resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} - promptly@2.2.0: - resolution: {integrity: sha512-aC9j+BZsRSSzEsXBNBwDnAxujdx19HycZoKgRgzWnS8eOHg1asuf9heuLprfbe739zY3IdUQx+Egv6Jn135WHA==} - prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -10539,13 +10342,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-agent@6.4.0: - resolution: {integrity: sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==} - engines: {node: '>= 14'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} @@ -10662,10 +10458,6 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - read@1.0.7: - resolution: {integrity: sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==} - engines: {node: '>=0.8'} - readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -10787,10 +10579,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - require-in-the-middle@5.2.0: - resolution: {integrity: sha512-efCx3b+0Z69/LGJmm9Yvi4cqEdxnoGnxYxGxBghkkTTFeXRtTCmmhO0AnAfHz59k957uTSuy8WaHqOs8wbYUWg==} - engines: {node: '>=6'} - require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} @@ -10916,9 +10704,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - run-series@1.1.9: - resolution: {integrity: sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==} - rxjs@6.6.7: resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} engines: {npm: '>=2.0.0'} @@ -11073,9 +10858,6 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} - shimmer@1.2.1: - resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} - side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -11146,24 +10928,12 @@ packages: resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} engines: {node: '>=18'} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smob@1.5.0: resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==} snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.4: - resolution: {integrity: sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -11219,9 +10989,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - sprintf-js@1.1.2: - resolution: {integrity: sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==} - sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} @@ -11696,9 +11463,6 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tslib@1.9.3: - resolution: {integrity: sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==} - tslib@2.4.1: resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} @@ -11713,19 +11477,12 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - tv4@1.3.0: - resolution: {integrity: sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==} - engines: {node: '>= 0.8.0'} - tw-animate-css@1.3.7: resolution: {integrity: sha512-lvLb3hTIpB5oGsk8JmLoAjeCHV58nKa2zHYn8yWOoG5JJusH3bhJlF2DLAZ/5NmJ+jyH3ssiAx/2KmbhavJy/A==} tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} - tx2@1.0.5: - resolution: {integrity: sha512-sJ24w0y03Md/bxzK4FU8J8JveYYUbSs2FViLJ2D/8bytSiyPRbuE3DyL/9UKYXTZlV3yXq0L8GLlhobTnekCVg==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -12406,10 +12163,6 @@ packages: jsdom: optional: true - vizion@2.2.1: - resolution: {integrity: sha512-sfAcO2yeSU0CSPFI/DmZp3FsFE9T+8913nv1xWBOyzODv13fwkn6Vl7HqxGpkr9F608M+8SuFId3s+BlZqfXww==} - engines: {node: '>=4.0'} - void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -12434,8 +12187,8 @@ packages: vue-component-type-helpers@3.0.6: resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==} - vue-component-type-helpers@3.1.3: - resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==} + vue-component-type-helpers@3.1.4: + resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -16053,56 +15806,6 @@ snapshots: '@pkgr/core@0.2.7': {} - '@pm2/agent@2.1.1': - dependencies: - async: 3.2.6 - chalk: 3.0.0 - dayjs: 1.8.36 - debug: 4.3.7 - eventemitter2: 5.0.1 - fast-json-patch: 3.1.1 - fclone: 1.0.11 - pm2-axon: 4.0.1 - pm2-axon-rpc: 0.7.1 - proxy-agent: 6.4.0 - semver: 7.5.4 - ws: 7.5.10 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@pm2/io@6.1.0': - dependencies: - async: 2.6.4 - debug: 4.3.7 - eventemitter2: 6.4.9 - require-in-the-middle: 5.2.0 - semver: 7.5.4 - shimmer: 1.2.1 - signal-exit: 3.0.7 - tslib: 1.9.3 - transitivePeerDependencies: - - supports-color - - '@pm2/js-api@0.8.0': - dependencies: - async: 2.6.4 - debug: 4.3.7 - eventemitter2: 6.4.9 - extrareqp2: 1.0.0(debug@4.3.7) - ws: 7.5.10 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@pm2/pm2-version-check@1.0.4': - dependencies: - debug: 4.4.1(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -16500,7 +16203,7 @@ snapshots: storybook: 9.1.3(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)) type-fest: 2.19.0 vue: 3.5.20(typescript@5.9.2) - vue-component-type-helpers: 3.1.3 + vue-component-type-helpers: 3.1.4 '@swc/core-darwin-arm64@1.13.5': optional: true @@ -16728,8 +16431,6 @@ snapshots: '@tokenizer/token@0.3.0': {} - '@tootallnate/quickjs-emscripten@0.23.0': {} - '@ts-morph/common@0.25.0': dependencies: minimatch: 9.0.5 @@ -17885,14 +17586,6 @@ snapshots: alien-signals@2.0.5: {} - amp-message@0.1.2: - dependencies: - amp: 0.3.1 - - amp@0.3.1: {} - - ansi-colors@4.1.3: {} - ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -17923,8 +17616,6 @@ snapshots: ansi_up@6.0.6: {} - ansis@4.0.0-node10: {} - ansis@4.1.0: {} anymatch@3.1.3: @@ -18065,10 +17756,6 @@ snapshots: dependencies: tslib: 2.8.1 - ast-types@0.13.4: - dependencies: - tslib: 2.8.1 - ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -18096,10 +17783,6 @@ snapshots: async@1.5.2: {} - async@2.6.4: - dependencies: - lodash: 4.17.21 - async@3.2.6: {} asynckit@0.4.0: {} @@ -18138,7 +17821,7 @@ snapshots: axios@0.26.1: dependencies: - follow-redirects: 1.15.9(debug@4.3.7) + follow-redirects: 1.15.9 transitivePeerDependencies: - debug @@ -18203,8 +17886,6 @@ snapshots: dependencies: safe-buffer: 5.1.2 - basic-ftp@5.0.5: {} - bcrypt-pbkdf@1.0.2: dependencies: tweetnacl: 0.14.5 @@ -18229,12 +17910,8 @@ snapshots: blake3-wasm@2.1.5: {} - blessed@0.1.81: {} - blob-to-buffer@1.2.9: {} - bodec@0.1.0: {} - body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -18489,8 +18166,6 @@ snapshots: chardet@2.1.0: {} - charm@0.1.2: {} - check-error@2.1.1: {} chokidar@3.6.0: @@ -18556,10 +18231,6 @@ snapshots: dependencies: colors: 1.0.3 - cli-tableau@2.0.1: - dependencies: - chalk: 3.0.0 - cli-truncate@4.0.0: dependencies: slice-ansi: 5.0.0 @@ -18649,8 +18320,6 @@ snapshots: commander@14.0.0: {} - commander@2.15.1: {} - commander@2.20.3: {} commander@5.1.0: {} @@ -18844,8 +18513,6 @@ snapshots: '@types/luxon': 3.6.2 luxon: 3.6.1 - croner@4.1.97: {} - croner@9.1.0: {} cross-fetch@3.2.0: @@ -18969,8 +18636,6 @@ snapshots: csv-parse@5.6.0: {} - culvert@0.1.2: {} - d@1.0.2: dependencies: es5-ext: 0.10.64 @@ -18978,8 +18643,6 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-uri-to-buffer@6.0.2: {} - data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -19009,12 +18672,8 @@ snapshots: dateformat@4.6.3: {} - dayjs@1.11.13: {} - dayjs@1.11.14: {} - dayjs@1.8.36: {} - db0@0.3.2: {} de-indent@1.0.2: {} @@ -19029,10 +18688,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.7: - dependencies: - ms: 2.1.3 - debug@4.4.1(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -19116,12 +18771,6 @@ snapshots: defu@6.1.4: {} - degenerator@5.0.1: - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -19327,10 +18976,6 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.2 - enquirer@2.3.6: - dependencies: - ansi-colors: 4.1.3 - entities@4.5.0: {} entities@6.0.1: {} @@ -19671,14 +19316,6 @@ snapshots: escape-string-regexp@5.0.0: {} - escodegen@2.1.0: - dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - eslint-config-prettier@10.1.8(eslint@9.34.0(jiti@2.5.1)): dependencies: eslint: 9.34.0(jiti@2.5.1) @@ -19910,8 +19547,6 @@ snapshots: event-target-shim@5.0.1: {} - eventemitter2@5.0.1: {} - eventemitter2@6.4.9: {} eventemitter3@3.1.2: {} @@ -20029,12 +19664,6 @@ snapshots: extract-files@11.0.0: {} - extrareqp2@1.0.0(debug@4.3.7): - dependencies: - follow-redirects: 1.15.9(debug@4.3.7) - transitivePeerDependencies: - - debug - fast-check@4.2.0: dependencies: pure-rand: 7.0.1 @@ -20057,8 +19686,6 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-json-patch@3.1.1: {} - fast-json-stable-stringify@2.1.0: {} fast-json-stringify@6.0.1: @@ -20150,8 +19777,6 @@ snapshots: transitivePeerDependencies: - encoding - fclone@1.0.11: {} - fd-package-json@2.0.0: dependencies: walk-up-path: 4.0.0 @@ -20254,9 +19879,7 @@ snapshots: dependencies: tabbable: 6.2.0 - follow-redirects@1.15.9(debug@4.3.7): - optionalDependencies: - debug: 4.3.7 + follow-redirects@1.15.9: {} fontaine@0.6.0: dependencies: @@ -20416,14 +20039,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-uri@6.0.4: - dependencies: - basic-ftp: 5.0.5 - data-uri-to-buffer: 6.0.2 - debug: 4.4.1(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - giget@2.0.0: dependencies: citty: 0.1.6 @@ -20433,12 +20048,6 @@ snapshots: nypm: 0.6.1 pathe: 2.0.3 - git-node-fs@1.0.0(js-git@0.7.8): - optionalDependencies: - js-git: 0.7.8 - - git-sha1@0.1.2: {} - git-up@8.1.1: dependencies: is-ssh: 1.4.1 @@ -20752,7 +20361,7 @@ snapshots: http-proxy@1.18.1: dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.15.9(debug@4.3.7) + follow-redirects: 1.15.9 requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -20932,11 +20541,6 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@9.0.5: - dependencies: - jsbn: 1.1.0 - sprintf-js: 1.1.3 - ip@2.0.1: {} ipaddr.js@1.9.1: {} @@ -21255,13 +20859,6 @@ snapshots: js-cookie@3.0.5: {} - js-git@0.7.8: - dependencies: - bodec: 0.1.0 - culvert: 0.1.2 - git-sha1: 0.1.2 - pako: 0.2.9 - js-stringify@1.0.2: {} js-tokens@4.0.0: {} @@ -21272,8 +20869,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsbn@1.1.0: {} - jsdom@26.1.0: dependencies: cssstyle: 4.5.0 @@ -21815,8 +21410,6 @@ snapshots: mkdirp-classic@0.5.3: {} - mkdirp@1.0.4: {} - mkdirp@3.0.1: {} mlly@1.7.4: @@ -21835,8 +21428,6 @@ snapshots: mocked-exports@0.1.1: {} - module-details-from-path@1.0.3: {} - motion-dom@12.23.12: dependencies: motion-utils: 12.23.6 @@ -21896,14 +21487,6 @@ snapshots: natural-compare@1.4.0: {} - needle@2.4.0: - dependencies: - debug: 3.2.7 - iconv-lite: 0.4.24 - sax: 1.4.1 - transitivePeerDependencies: - - supports-color - negotiator@0.6.3: {} negotiator@0.6.4: {} @@ -21949,8 +21532,6 @@ snapshots: qs: 6.14.0 optional: true - netmask@2.0.2: {} - next-tick@1.1.0: {} nitropack@2.12.5(@netlify/blobs@9.1.2)(xml2js@0.6.2): @@ -22580,24 +22161,6 @@ snapshots: p-timeout: 6.1.4 optional: true - pac-proxy-agent@7.1.0: - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.3 - debug: 4.4.1(supports-color@5.5.0) - get-uri: 6.0.4 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - pac-resolver@7.0.1: - dependencies: - degenerator: 5.0.1 - netmask: 2.0.2 - package-json-from-dist@1.0.1: {} package-json@10.0.1: @@ -22746,15 +22309,6 @@ snapshots: pidtree@0.6.0: {} - pidusage@2.0.21: - dependencies: - safe-buffer: 5.2.1 - optional: true - - pidusage@3.0.2: - dependencies: - safe-buffer: 5.2.1 - pify@2.3.0: {} pify@3.0.0: {} @@ -22840,79 +22394,6 @@ snapshots: dependencies: find-up: 3.0.0 - pm2-axon-rpc@0.7.1: - dependencies: - debug: 4.4.1(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - - pm2-axon@4.0.1: - dependencies: - amp: 0.3.1 - amp-message: 0.1.2 - debug: 4.4.1(supports-color@5.5.0) - escape-string-regexp: 4.0.0 - transitivePeerDependencies: - - supports-color - - pm2-deploy@1.0.2: - dependencies: - run-series: 1.1.9 - tv4: 1.3.0 - - pm2-multimeter@0.1.2: - dependencies: - charm: 0.1.2 - - pm2-sysmonit@1.2.8: - dependencies: - async: 3.2.6 - debug: 4.4.1(supports-color@5.5.0) - pidusage: 2.0.21 - systeminformation: 5.27.8 - tx2: 1.0.5 - transitivePeerDependencies: - - supports-color - optional: true - - pm2@6.0.8: - dependencies: - '@pm2/agent': 2.1.1 - '@pm2/io': 6.1.0 - '@pm2/js-api': 0.8.0 - '@pm2/pm2-version-check': 1.0.4 - ansis: 4.0.0-node10 - async: 3.2.6 - blessed: 0.1.81 - chokidar: 3.6.0 - cli-tableau: 2.0.1 - commander: 2.15.1 - croner: 4.1.97 - dayjs: 1.11.13 - debug: 4.4.1(supports-color@5.5.0) - enquirer: 2.3.6 - eventemitter2: 5.0.1 - fclone: 1.0.11 - js-yaml: 4.1.0 - mkdirp: 1.0.4 - needle: 2.4.0 - pidusage: 3.0.2 - pm2-axon: 4.0.1 - pm2-axon-rpc: 0.7.1 - pm2-deploy: 1.0.2 - pm2-multimeter: 0.1.2 - promptly: 2.2.0 - semver: 7.7.2 - source-map-support: 0.5.21 - sprintf-js: 1.1.2 - vizion: 2.2.1 - optionalDependencies: - pm2-sysmonit: 1.2.8 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - portfinder@1.0.35: dependencies: async: 3.2.6 @@ -23162,10 +22643,6 @@ snapshots: dependencies: asap: 2.0.6 - promptly@2.2.0: - dependencies: - read: 1.0.7 - prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -23207,21 +22684,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-agent@6.4.0: - dependencies: - agent-base: 7.1.3 - debug: 4.4.1(supports-color@5.5.0) - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 7.18.3 - pac-proxy-agent: 7.1.0 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - proxy-from-env@1.1.0: {} - pstree.remy@1.1.8: {} pug-attrs@3.0.0: @@ -23362,10 +22824,6 @@ snapshots: dependencies: pify: 2.3.0 - read@1.0.7: - dependencies: - mute-stream: 0.0.8 - readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -23525,14 +22983,6 @@ snapshots: require-from-string@2.0.2: {} - require-in-the-middle@5.2.0: - dependencies: - debug: 4.4.1(supports-color@5.5.0) - module-details-from-path: 1.0.3 - resolve: 1.22.10 - transitivePeerDependencies: - - supports-color - require-main-filename@2.0.0: {} requires-port@1.0.0: {} @@ -23680,8 +23130,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - run-series@1.1.9: {} - rxjs@6.6.7: dependencies: tslib: 1.14.1 @@ -23924,8 +23372,6 @@ snapshots: shell-quote@1.8.3: {} - shimmer@1.2.1: {} - side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -24008,8 +23454,6 @@ snapshots: ansi-styles: 6.2.1 is-fullwidth-code-point: 5.0.0 - smart-buffer@4.2.0: {} - smob@1.5.0: {} snake-case@3.0.4: @@ -24017,19 +23461,6 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.3 - debug: 4.4.1(supports-color@5.5.0) - socks: 2.8.4 - transitivePeerDependencies: - - supports-color - - socks@2.8.4: - dependencies: - ip-address: 9.0.5 - smart-buffer: 4.2.0 - sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 @@ -24078,8 +23509,6 @@ snapshots: sprintf-js@1.0.3: {} - sprintf-js@1.1.2: {} - sprintf-js@1.1.3: {} ssh2@1.16.0: @@ -24570,8 +23999,6 @@ snapshots: tslib@1.14.1: {} - tslib@1.9.3: {} - tslib@2.4.1: {} tslib@2.6.3: {} @@ -24585,17 +24012,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - tv4@1.3.0: {} - tw-animate-css@1.3.7: {} tweetnacl@0.14.5: {} - tx2@1.0.5: - dependencies: - json-stringify-safe: 5.0.1 - optional: true - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -25311,13 +24731,6 @@ snapshots: - tsx - yaml - vizion@2.2.1: - dependencies: - async: 2.6.4 - git-node-fs: 1.0.0(js-git@0.7.8) - ini: 1.3.8 - js-git: 0.7.8 - void-elements@3.1.0: {} vscode-uri@3.1.0: {} @@ -25339,7 +24752,7 @@ snapshots: vue-component-type-helpers@3.0.6: {} - vue-component-type-helpers@3.1.3: {} + vue-component-type-helpers@3.1.4: {} vue-demi@0.14.10(vue@3.5.20(typescript@5.9.2)): dependencies: diff --git a/web/composables/gql/graphql.ts b/web/composables/gql/graphql.ts index a6171b6772..8335211509 100644 --- a/web/composables/gql/graphql.ts +++ b/web/composables/gql/graphql.ts @@ -1523,8 +1523,8 @@ export type PackageVersions = { openssl?: Maybe; /** PHP version */ php?: Maybe; - /** pm2 version */ - pm2?: Maybe; + /** nodemon version */ + nodemon?: Maybe; }; export type ParityCheck = { diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts index e683aa0c02..a44fe13e7c 100644 --- a/web/src/composables/gql/graphql.ts +++ b/web/src/composables/gql/graphql.ts @@ -559,6 +559,17 @@ export type CpuLoad = { percentUser: Scalars['Float']['output']; }; +export type CpuPackages = Node & { + __typename?: 'CpuPackages'; + id: Scalars['PrefixedID']['output']; + /** Power draw per package (W) */ + power: Array; + /** Temperature per package (°C) */ + temp: Array; + /** Total CPU package power draw (W) */ + totalPower: Scalars['Float']['output']; +}; + export type CpuUtilization = Node & { __typename?: 'CpuUtilization'; /** CPU load for each core */ @@ -869,6 +880,7 @@ export type InfoCpu = Node & { manufacturer?: Maybe; /** CPU model */ model?: Maybe; + packages: CpuPackages; /** Number of physical processors */ processors?: Maybe; /** CPU revision */ @@ -885,6 +897,8 @@ export type InfoCpu = Node & { stepping?: Maybe; /** Number of CPU threads */ threads?: Maybe; + /** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */ + topology: Array>>; /** CPU vendor */ vendor?: Maybe; /** CPU voltage */ @@ -1531,14 +1545,14 @@ export type PackageVersions = { nginx?: Maybe; /** Node.js version */ node?: Maybe; + /** nodemon version */ + nodemon?: Maybe; /** npm version */ npm?: Maybe; /** OpenSSL version */ openssl?: Maybe; /** PHP version */ php?: Maybe; - /** pm2 version */ - pm2?: Maybe; }; export type ParityCheck = { @@ -2053,6 +2067,7 @@ export type Subscription = { parityHistorySubscription: ParityCheck; serversSubscription: Server; systemMetricsCpu: CpuUtilization; + systemMetricsCpuTelemetry: CpuPackages; systemMetricsMemory: MemoryUtilization; upsUpdates: UpsDevice; }; diff --git a/web/src/composables/gql/index.ts b/web/src/composables/gql/index.ts index c682b1e2f9..0ea4a91cf8 100644 --- a/web/src/composables/gql/index.ts +++ b/web/src/composables/gql/index.ts @@ -1,2 +1,2 @@ -export * from './fragment-masking'; -export * from './gql'; +export * from "./fragment-masking"; +export * from "./gql"; From f6521d8c1ca7e91ea3f5bf5dba64c529b6b633d1 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 19 Nov 2025 20:59:07 -0500 Subject: [PATCH 02/25] Add SUPPRESS_LOGS to environment mocks --- api/src/__test__/graphql/resolvers/rclone-api.service.test.ts | 2 ++ api/src/unraid-api/cli/nodemon.service.spec.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts index e4adb7452b..1ac4560378 100644 --- a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts +++ b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts @@ -51,6 +51,8 @@ vi.mock('@app/store/index.js', () => ({ })); vi.mock('@app/environment.js', () => ({ ENVIRONMENT: 'development', + SUPPRESS_LOGS: false, + LOG_LEVEL: 'INFO', environment: { IS_MAIN_PROCESS: true, }, diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 7ee7dae84b..4d09f15c16 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -16,6 +16,8 @@ vi.mock('@app/core/utils/files/file-exists.js', () => ({ fileExists: vi.fn().mockResolvedValue(false), })); vi.mock('@app/environment.js', () => ({ + LOG_LEVEL: 'INFO', + SUPPRESS_LOGS: false, NODEMON_CONFIG_PATH: '/etc/unraid-api/nodemon.json', NODEMON_PATH: '/usr/bin/nodemon', NODEMON_PID_PATH: '/var/run/unraid-api/nodemon.pid', From 6d3d623b6672980a79568b6cafe0f74a4b64f8cf Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 16:00:31 -0500 Subject: [PATCH 03/25] feat: Refactor nodemon configuration and improve error handling in NodemonService - Updated nodemon.json to remove unnecessary watch entry. - Adjusted NODEMON_CONFIG_PATH and UNRAID_API_CWD paths for better structure. - Enhanced error handling in isUnraidApiRunning and start methods of NodemonService to ensure proper logging and resource management. - Added tests for error scenarios in NodemonService to ensure robustness. --- api/nodemon.json | 3 +- .../core/utils/process/unraid-api-running.ts | 18 +-- api/src/environment.ts | 4 +- .../unraid-api/cli/nodemon.service.spec.ts | 109 +++++++++++++++++- api/src/unraid-api/cli/nodemon.service.ts | 80 ++++++++----- 5 files changed, 174 insertions(+), 40 deletions(-) diff --git a/api/nodemon.json b/api/nodemon.json index 91e2dfae2a..a97e2430a9 100644 --- a/api/nodemon.json +++ b/api/nodemon.json @@ -1,7 +1,6 @@ { "watch": [ - "dist/main.js", - "myservers.cfg" + "dist/main.js" ], "ignore": [ "node_modules", diff --git a/api/src/core/utils/process/unraid-api-running.ts b/api/src/core/utils/process/unraid-api-running.ts index d1361b21fa..c4ee4d7e67 100644 --- a/api/src/core/utils/process/unraid-api-running.ts +++ b/api/src/core/utils/process/unraid-api-running.ts @@ -4,17 +4,17 @@ import { fileExists } from '@app/core/utils/files/file-exists.js'; import { NODEMON_PID_PATH } from '@app/environment.js'; export const isUnraidApiRunning = async (): Promise => { - if (!(await fileExists(NODEMON_PID_PATH))) { - return false; - } + try { + if (!(await fileExists(NODEMON_PID_PATH))) { + return false; + } - const pidText = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim(); - const pid = Number.parseInt(pidText, 10); - if (Number.isNaN(pid)) { - return false; - } + const pidText = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim(); + const pid = Number.parseInt(pidText, 10); + if (Number.isNaN(pid)) { + return false; + } - try { process.kill(pid, 0); return true; } catch { diff --git a/api/src/environment.ts b/api/src/environment.ts index 3ab43336bf..6f085e41c2 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -110,9 +110,9 @@ export const NODEMON_PATH = join( 'bin', 'nodemon.js' ); -export const NODEMON_CONFIG_PATH = join(import.meta.dirname, '../../', 'nodemon.json'); +export const NODEMON_CONFIG_PATH = join(import.meta.dirname, '../', 'nodemon.json'); export const NODEMON_PID_PATH = process.env.NODEMON_PID_PATH ?? '/var/run/unraid-api/nodemon.pid'; -export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? join(import.meta.dirname, '../../'); +export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? join(import.meta.dirname, '../'); export const PATHS_CONFIG_MODULES = process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs'; diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 4d09f15c16..1799e3c221 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -8,7 +8,7 @@ import { fileExists } from '@app/core/utils/files/file-exists.js'; import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; vi.mock('node:fs', () => ({ - createWriteStream: vi.fn(() => ({ pipe: vi.fn() })), + createWriteStream: vi.fn(() => ({ pipe: vi.fn(), close: vi.fn() })), })); vi.mock('node:fs/promises'); vi.mock('execa', () => ({ execa: vi.fn() })); @@ -57,8 +57,21 @@ describe('NodemonService', () => { expect(mockMkdir).toHaveBeenCalledWith('/var/run/unraid-api', { recursive: true }); }); + it('throws error when directory creation fails', async () => { + const service = new NodemonService(logger); + const error = new Error('Permission denied'); + mockMkdir.mockRejectedValue(error); + + await expect(service.ensureNodemonDependencies()).rejects.toThrow('Permission denied'); + expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true }); + }); + it('starts nodemon and writes pid file', async () => { const service = new NodemonService(logger); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); const stdout = { pipe: vi.fn() }; const stderr = { pipe: vi.fn() }; const unref = vi.fn(); @@ -87,6 +100,60 @@ describe('NodemonService', () => { expect(unref).toHaveBeenCalled(); expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '123'); expect(logger.info).toHaveBeenCalledWith('Started nodemon (pid 123)'); + expect(logStream.close).not.toHaveBeenCalled(); + }); + + it('throws error and aborts start when directory creation fails', async () => { + const service = new NodemonService(logger); + const error = new Error('Permission denied'); + mockMkdir.mockRejectedValue(error); + + await expect(service.start()).rejects.toThrow('Permission denied'); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to ensure nodemon dependencies: Permission denied' + ); + expect(execa).not.toHaveBeenCalled(); + }); + + it('throws error and closes logStream when execa fails', async () => { + const service = new NodemonService(logger); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const error = new Error('Command not found'); + vi.mocked(execa).mockImplementation(() => { + throw error; + }); + + await expect(service.start()).rejects.toThrow('Failed to start nodemon: Command not found'); + expect(logStream.close).toHaveBeenCalled(); + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it('throws error and closes logStream when pid is missing', async () => { + const service = new NodemonService(logger); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: undefined, + stdout, + stderr, + unref, + } as unknown as ReturnType); + + await expect(service.start()).rejects.toThrow( + 'Failed to start nodemon: process spawned but no PID was assigned' + ); + expect(logStream.close).toHaveBeenCalled(); + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); }); it('returns not running when pid file is missing', async () => { @@ -98,4 +165,44 @@ describe('NodemonService', () => { expect(result).toBe(false); expect(logger.info).toHaveBeenCalledWith('unraid-api is not running (no pid file).'); }); + + it('logs stdout when tail succeeds', async () => { + const service = new NodemonService(logger); + vi.mocked(execa).mockResolvedValue({ + stdout: 'log line 1\nlog line 2', + } as unknown as Awaited>); + + const result = await service.logs(50); + + expect(execa).toHaveBeenCalledWith('tail', ['-n', '50', '/var/log/graphql-api.log']); + expect(logger.log).toHaveBeenCalledWith('log line 1\nlog line 2'); + expect(result).toBe('log line 1\nlog line 2'); + }); + + it('handles ENOENT error when log file is missing', async () => { + const service = new NodemonService(logger); + const error = new Error('ENOENT: no such file or directory'); + (error as Error & { code?: string }).code = 'ENOENT'; + vi.mocked(execa).mockRejectedValue(error); + + const result = await service.logs(); + + expect(logger.error).toHaveBeenCalledWith( + 'Log file not found: /var/log/graphql-api.log (ENOENT: no such file or directory)' + ); + expect(result).toBe(''); + }); + + it('handles non-zero exit error from tail', async () => { + const service = new NodemonService(logger); + const error = new Error('Command failed with exit code 1'); + vi.mocked(execa).mockRejectedValue(error); + + const result = await service.logs(100); + + expect(logger.error).toHaveBeenCalledWith( + 'Failed to read logs from /var/log/graphql-api.log: Command failed with exit code 1' + ); + expect(result).toBe(''); + }); }); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 1671e45263..8e558b27ed 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -32,14 +32,8 @@ export class NodemonService { constructor(private readonly logger: LogService) {} async ensureNodemonDependencies() { - try { - await mkdir(PATHS_LOGS_DIR, { recursive: true }); - await mkdir(dirname(NODEMON_PID_PATH), { recursive: true }); - } catch (error) { - this.logger.error( - `Failed to fully ensure nodemon dependencies: ${error instanceof Error ? error.message : error}` - ); - } + await mkdir(PATHS_LOGS_DIR, { recursive: true }); + await mkdir(dirname(NODEMON_PID_PATH), { recursive: true }); } private async getStoredPid(): Promise { @@ -59,28 +53,47 @@ export class NodemonService { } async start(options: StartOptions = {}) { - await this.ensureNodemonDependencies(); + try { + await this.ensureNodemonDependencies(); + } catch (error) { + this.logger.error( + `Failed to ensure nodemon dependencies: ${error instanceof Error ? error.message : error}` + ); + throw error; + } + await this.stop({ quiet: true }); - const env = { ...process.env, ...options.env } as Record; + const overrides = Object.fromEntries( + Object.entries(options.env ?? {}).filter(([, value]) => value !== undefined) + ); + const env = { ...process.env, ...overrides } as Record; const logStream = createWriteStream(PATHS_LOGS_FILE, { flags: 'a' }); - const nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], { - cwd: UNRAID_API_CWD, - env, - detached: true, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - nodemonProcess.stdout?.pipe(logStream); - nodemonProcess.stderr?.pipe(logStream); - nodemonProcess.unref(); + let nodemonProcess; + try { + nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], { + cwd: UNRAID_API_CWD, + env, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + nodemonProcess.stdout?.pipe(logStream); + nodemonProcess.stderr?.pipe(logStream); + nodemonProcess.unref(); + + if (!nodemonProcess.pid) { + logStream.close(); + throw new Error('Failed to start nodemon: process spawned but no PID was assigned'); + } - if (nodemonProcess.pid) { await writeFile(NODEMON_PID_PATH, `${nodemonProcess.pid}`); this.logger.info(`Started nodemon (pid ${nodemonProcess.pid})`); - } else { - this.logger.error('Failed to determine nodemon pid.'); + } catch (error) { + logStream.close(); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to start nodemon: ${errorMessage}`); } } @@ -126,8 +139,23 @@ export class NodemonService { return running; } - async logs(lines = 100) { - const { stdout } = await execa('tail', ['-n', `${lines}`, PATHS_LOGS_FILE]); - this.logger.log(stdout); + async logs(lines = 100): Promise { + try { + const { stdout } = await execa('tail', ['-n', `${lines}`, PATHS_LOGS_FILE]); + this.logger.log(stdout); + return stdout; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const isFileNotFound = + errorMessage.includes('ENOENT') || + (error instanceof Error && 'code' in error && error.code === 'ENOENT'); + + if (isFileNotFound) { + this.logger.error(`Log file not found: ${PATHS_LOGS_FILE} (${errorMessage})`); + } else { + this.logger.error(`Failed to read logs from ${PATHS_LOGS_FILE}: ${errorMessage}`); + } + return ''; + } } } From b35da132346d96b91b5bbcd79067554269f914c7 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 16:53:01 -0500 Subject: [PATCH 04/25] refactor: Update environment configuration for nodemon paths - Introduced UNRAID_API_ROOT to streamline path definitions for nodemon. - Replaced direct usage of import.meta.dirname with UNRAID_API_ROOT in NODEMON_PATH and UNRAID_API_CWD for improved clarity and maintainability. - Added dirname import to facilitate the new path structure. --- api/src/environment.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/api/src/environment.ts b/api/src/environment.ts index 6f085e41c2..cef6282071 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -2,7 +2,7 @@ // Non-function exports from this module are loaded into the NestJS Config at runtime. import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { PackageJson, SetRequired } from 'type-fest'; @@ -65,6 +65,7 @@ export const getPackageJsonDependencies = (): string[] | undefined => { }; export const API_VERSION = process.env.npm_package_version ?? getPackageJson().version; +export const UNRAID_API_ROOT = dirname(getPackageJsonPath()); /** Controls how the app is built/run (i.e. in terms of optimization) */ export const NODE_ENV = @@ -102,17 +103,10 @@ export const PATHS_LOGS_DIR = process.env.PATHS_LOGS_DIR ?? process.env.LOGS_DIR ?? '/var/log/unraid-api'; export const PATHS_LOGS_FILE = process.env.PATHS_LOGS_FILE ?? '/var/log/graphql-api.log'; -export const NODEMON_PATH = join( - import.meta.dirname, - '../../', - 'node_modules', - 'nodemon', - 'bin', - 'nodemon.js' -); -export const NODEMON_CONFIG_PATH = join(import.meta.dirname, '../', 'nodemon.json'); +export const NODEMON_PATH = join(UNRAID_API_ROOT, 'node_modules', 'nodemon', 'bin', 'nodemon.js'); +export const NODEMON_CONFIG_PATH = join(UNRAID_API_ROOT, 'nodemon.json'); export const NODEMON_PID_PATH = process.env.NODEMON_PID_PATH ?? '/var/run/unraid-api/nodemon.pid'; -export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? join(import.meta.dirname, '../'); +export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? UNRAID_API_ROOT; export const PATHS_CONFIG_MODULES = process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs'; From d4f90d6d64321f8412a93e4dedfe45d2abdbb836 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 17:23:16 -0500 Subject: [PATCH 05/25] test: Add unit tests for nodemon path configuration and enhance error handling - Introduced a new test file to validate nodemon path configurations, ensuring they anchor to the package root by default. - Enhanced the NodemonService to throw an error when nodemon exits immediately after starting, improving robustness. - Added tests to cover scenarios where nodemon fails to start, ensuring proper logging and resource cleanup. --- .../environment.nodemon-paths.test.ts | 29 +++++++++++++++++++ .../unraid-api/cli/nodemon.service.spec.ts | 29 +++++++++++++++++++ api/src/unraid-api/cli/nodemon.service.ts | 12 ++++++++ 3 files changed, 70 insertions(+) create mode 100644 api/src/__test__/environment.nodemon-paths.test.ts diff --git a/api/src/__test__/environment.nodemon-paths.test.ts b/api/src/__test__/environment.nodemon-paths.test.ts new file mode 100644 index 0000000000..3e5ac9a468 --- /dev/null +++ b/api/src/__test__/environment.nodemon-paths.test.ts @@ -0,0 +1,29 @@ +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('nodemon path configuration', () => { + const originalUnraidApiCwd = process.env.UNRAID_API_CWD; + + beforeEach(() => { + vi.resetModules(); + delete process.env.UNRAID_API_CWD; + }); + + afterEach(() => { + if (originalUnraidApiCwd === undefined) { + delete process.env.UNRAID_API_CWD; + } else { + process.env.UNRAID_API_CWD = originalUnraidApiCwd; + } + }); + + it('anchors nodemon paths to the package root by default', async () => { + const environment = await import('@app/environment.js'); + const { UNRAID_API_ROOT, NODEMON_CONFIG_PATH, NODEMON_PATH, UNRAID_API_CWD } = environment; + + expect(UNRAID_API_CWD).toBe(UNRAID_API_ROOT); + expect(NODEMON_CONFIG_PATH).toBe(join(UNRAID_API_ROOT, 'nodemon.json')); + expect(NODEMON_PATH).toBe(join(UNRAID_API_ROOT, 'node_modules', 'nodemon', 'bin', 'nodemon.js')); + }); +}); diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 1799e3c221..f6eb6d631c 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -39,6 +39,7 @@ describe('NodemonService', () => { const mockMkdir = vi.mocked(fs.mkdir); const mockWriteFile = vi.mocked(fs.writeFile); const mockRm = vi.mocked(fs.rm); + const killSpy = vi.spyOn(process, 'kill'); beforeEach(() => { vi.clearAllMocks(); @@ -46,6 +47,7 @@ describe('NodemonService', () => { mockWriteFile.mockResolvedValue(undefined as unknown as void); mockRm.mockResolvedValue(undefined as unknown as void); vi.mocked(fileExists).mockResolvedValue(false); + killSpy.mockReturnValue(true as unknown as boolean); }); it('ensures directories needed by nodemon exist', async () => { @@ -81,6 +83,7 @@ describe('NodemonService', () => { stderr, unref, } as unknown as ReturnType); + killSpy.mockReturnValue(true as unknown as boolean); await service.start({ env: { LOG_LEVEL: 'DEBUG' } }); @@ -156,6 +159,32 @@ describe('NodemonService', () => { expect(logger.info).not.toHaveBeenCalled(); }); + it('throws when nodemon exits immediately after start', async () => { + const service = new NodemonService(logger); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 456, + stdout, + stderr, + unref, + } as unknown as ReturnType); + killSpy.mockImplementation(() => { + throw new Error('not running'); + }); + const logsSpy = vi.spyOn(service, 'logs').mockResolvedValue('recent log lines'); + + await expect(service.start()).rejects.toThrow(/Nodemon exited immediately/); + expect(logStream.close).toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); + expect(logsSpy).toHaveBeenCalledWith(50); + }); + it('returns not running when pid file is missing', async () => { const service = new NodemonService(logger); vi.mocked(fileExists).mockResolvedValue(false); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 8e558b27ed..e1faf3f30e 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -89,6 +89,18 @@ export class NodemonService { } await writeFile(NODEMON_PID_PATH, `${nodemonProcess.pid}`); + + // Give nodemon a brief moment to boot, then verify it is still alive. + await new Promise((resolve) => setTimeout(resolve, 200)); + const stillRunning = await this.isPidRunning(nodemonProcess.pid); + if (!stillRunning) { + const recentLogs = await this.logs(50); + await rm(NODEMON_PID_PATH, { force: true }); + logStream.close(); + const logMessage = recentLogs ? ` Recent logs:\n${recentLogs}` : ''; + throw new Error(`Nodemon exited immediately after start.${logMessage}`); + } + this.logger.info(`Started nodemon (pid ${nodemonProcess.pid})`); } catch (error) { logStream.close(); From 33e88bc5f566bec89f16bd40b2c6f8c17ce94cad Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 18:06:40 -0500 Subject: [PATCH 06/25] fix: Simplify return type for killSpy in NodemonService tests - Updated the return type of killSpy in nodemon.service.spec.ts to directly return a boolean instead of casting it, improving code clarity and maintainability. --- api/src/unraid-api/cli/nodemon.service.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index f6eb6d631c..6bfd7c3ede 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -47,7 +47,7 @@ describe('NodemonService', () => { mockWriteFile.mockResolvedValue(undefined as unknown as void); mockRm.mockResolvedValue(undefined as unknown as void); vi.mocked(fileExists).mockResolvedValue(false); - killSpy.mockReturnValue(true as unknown as boolean); + killSpy.mockReturnValue(true); }); it('ensures directories needed by nodemon exist', async () => { @@ -83,7 +83,7 @@ describe('NodemonService', () => { stderr, unref, } as unknown as ReturnType); - killSpy.mockReturnValue(true as unknown as boolean); + killSpy.mockReturnValue(true); await service.start({ env: { LOG_LEVEL: 'DEBUG' } }); From 1d9c76f4108be9e372f1c36bf66894ff3afe788c Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 18:53:21 -0500 Subject: [PATCH 07/25] feat: Enhance NodemonService with process management and cleanup - Implemented stopPm2IfRunning method to stop any running pm2-managed instances of unraid-api before starting nodemon. - Added findMatchingNodemonPids method to identify existing nodemon processes, improving resource management. - Updated start method to handle scenarios where a stored pid is running or dead, ensuring proper cleanup and logging. - Introduced new unit tests to validate the new functionality and ensure robustness in process handling. --- .../unraid-api/cli/nodemon.service.spec.ts | 87 +++++++++++++++++++ api/src/unraid-api/cli/nodemon.service.ts | 77 +++++++++++++++- 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 6bfd7c3ede..1834d83929 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -40,6 +40,14 @@ describe('NodemonService', () => { const mockWriteFile = vi.mocked(fs.writeFile); const mockRm = vi.mocked(fs.rm); const killSpy = vi.spyOn(process, 'kill'); + const stopPm2Spy = vi.spyOn( + NodemonService.prototype as unknown as { stopPm2IfRunning: () => Promise }, + 'stopPm2IfRunning' + ); + const findMatchingSpy = vi.spyOn( + NodemonService.prototype as unknown as { findMatchingNodemonPids: () => Promise }, + 'findMatchingNodemonPids' + ); beforeEach(() => { vi.clearAllMocks(); @@ -48,6 +56,8 @@ describe('NodemonService', () => { mockRm.mockResolvedValue(undefined as unknown as void); vi.mocked(fileExists).mockResolvedValue(false); killSpy.mockReturnValue(true); + findMatchingSpy.mockResolvedValue([]); + stopPm2Spy.mockResolvedValue(); }); it('ensures directories needed by nodemon exist', async () => { @@ -84,9 +94,11 @@ describe('NodemonService', () => { unref, } as unknown as ReturnType); killSpy.mockReturnValue(true); + findMatchingSpy.mockResolvedValue([]); await service.start({ env: { LOG_LEVEL: 'DEBUG' } }); + expect(stopPm2Spy).toHaveBeenCalled(); expect(execa).toHaveBeenCalledWith( '/usr/bin/nodemon', ['--config', '/etc/unraid-api/nodemon.json', '--quiet'], @@ -185,6 +197,81 @@ describe('NodemonService', () => { expect(logsSpy).toHaveBeenCalledWith(50); }); + it('is a no-op when a recorded nodemon pid is already running', async () => { + const service = new NodemonService(logger); + vi.spyOn( + service as unknown as { getStoredPid: () => Promise }, + 'getStoredPid' + ).mockResolvedValue(999); + vi.spyOn( + service as unknown as { isPidRunning: (pid: number) => Promise }, + 'isPidRunning' + ).mockResolvedValue(true); + + await service.start(); + + expect(logger.info).toHaveBeenCalledWith( + 'unraid-api already running under nodemon (pid 999); skipping start.' + ); + expect(execa).not.toHaveBeenCalled(); + expect(mockRm).not.toHaveBeenCalled(); + }); + + it('removes stale pid file and starts when recorded pid is dead', async () => { + const service = new NodemonService(logger); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 111, + stdout, + stderr, + unref, + } as unknown as ReturnType); + vi.spyOn( + service as unknown as { getStoredPid: () => Promise }, + 'getStoredPid' + ).mockResolvedValue(555); + vi.spyOn( + service as unknown as { isPidRunning: (pid: number) => Promise }, + 'isPidRunning' + ) + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + vi.spyOn(service, 'logs').mockResolvedValue('recent log lines'); + findMatchingSpy.mockResolvedValue([]); + + await service.start(); + + expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); + expect(execa).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '111'); + expect(logger.warn).toHaveBeenCalledWith( + 'Found nodemon pid file (555) but the process is not running. Cleaning up.' + ); + }); + + it('adopts an already-running nodemon when no pid file exists', async () => { + const service = new NodemonService(logger); + findMatchingSpy.mockResolvedValue([888]); + vi.spyOn( + service as unknown as { isPidRunning: (pid: number) => Promise }, + 'isPidRunning' + ).mockResolvedValue(true); + + await service.start(); + + expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '888'); + expect(logger.info).toHaveBeenCalledWith( + 'unraid-api already running under nodemon (pid 888); discovered via process scan.' + ); + expect(execa).not.toHaveBeenCalled(); + }); + it('returns not running when pid file is missing', async () => { const service = new NodemonService(logger); vi.mocked(fileExists).mockResolvedValue(false); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index e1faf3f30e..1b5db069bf 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; import { createWriteStream } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; import { execa } from 'execa'; @@ -36,6 +37,36 @@ export class NodemonService { await mkdir(dirname(NODEMON_PID_PATH), { recursive: true }); } + private async stopPm2IfRunning() { + const pm2PidPath = '/var/log/.pm2/pm2.pid'; + if (!(await fileExists(pm2PidPath))) return; + + const pm2Candidates = ['/usr/bin/pm2', '/usr/local/bin/pm2']; + const pm2Path = + ( + await Promise.all( + pm2Candidates.map(async (candidate) => + (await fileExists(candidate)) ? candidate : null + ) + ) + ).find(Boolean) ?? null; + + if (!pm2Path) return; + + try { + const { stdout } = await execa(pm2Path, ['jlist']); + const processes = JSON.parse(stdout); + const hasUnraid = + Array.isArray(processes) && processes.some((proc) => proc?.name === 'unraid-api'); + if (!hasUnraid) return; + await execa(pm2Path, ['delete', 'unraid-api']); + this.logger.info('Stopped pm2-managed unraid-api before starting nodemon.'); + } catch (error) { + // PM2 may not be installed or responding; keep this quiet to avoid noisy startup. + this.logger.debug?.('Skipping pm2 cleanup (not installed or not running).'); + } + } + private async getStoredPid(): Promise { if (!(await fileExists(NODEMON_PID_PATH))) return null; const contents = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim(); @@ -52,6 +83,23 @@ export class NodemonService { } } + private async findMatchingNodemonPids(): Promise { + try { + const { stdout } = await execa('ps', ['-eo', 'pid,args']); + return stdout + .split('\n') + .map((line) => line.trim()) + .map((line) => line.match(/^(\d+)\s+(.*)$/)) + .filter((match): match is RegExpMatchArray => Boolean(match)) + .map(([, pid, cmd]) => ({ pid: Number.parseInt(pid, 10), cmd })) + .filter(({ cmd }) => cmd.includes('nodemon') && cmd.includes(NODEMON_CONFIG_PATH)) + .map(({ pid }) => pid) + .filter((pid) => Number.isInteger(pid)); + } catch { + return []; + } + } + async start(options: StartOptions = {}) { try { await this.ensureNodemonDependencies(); @@ -62,7 +110,32 @@ export class NodemonService { throw error; } - await this.stop({ quiet: true }); + await this.stopPm2IfRunning(); + + const existingPid = await this.getStoredPid(); + if (existingPid) { + const running = await this.isPidRunning(existingPid); + if (running) { + this.logger.info( + `unraid-api already running under nodemon (pid ${existingPid}); skipping start.` + ); + return; + } + this.logger.warn( + `Found nodemon pid file (${existingPid}) but the process is not running. Cleaning up.` + ); + await rm(NODEMON_PID_PATH, { force: true }); + } + + const discoveredPids = await this.findMatchingNodemonPids(); + const discoveredPid = discoveredPids.at(0); + if (discoveredPid && (await this.isPidRunning(discoveredPid))) { + await writeFile(NODEMON_PID_PATH, `${discoveredPid}`); + this.logger.info( + `unraid-api already running under nodemon (pid ${discoveredPid}); discovered via process scan.` + ); + return; + } const overrides = Object.fromEntries( Object.entries(options.env ?? {}).filter(([, value]) => value !== undefined) From 9253250dc5d732def337aa760178e1e03ce37c87 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 21:55:59 -0500 Subject: [PATCH 08/25] feat: Add process termination and management in NodemonService - Implemented findDirectMainPids and terminatePids methods to identify and terminate existing unraid-api processes before starting nodemon. - Enhanced the start method to include checks for running processes, ensuring proper cleanup and logging. - Updated unit tests to validate the new process management functionality, improving overall robustness. --- .../unraid-api/cli/nodemon.service.spec.ts | 39 ++++++++++ api/src/unraid-api/cli/nodemon.service.ts | 76 ++++++++++++++++--- 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 1834d83929..f29803b621 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -48,6 +48,14 @@ describe('NodemonService', () => { NodemonService.prototype as unknown as { findMatchingNodemonPids: () => Promise }, 'findMatchingNodemonPids' ); + const findDirectMainSpy = vi.spyOn( + NodemonService.prototype as unknown as { findDirectMainPids: () => Promise }, + 'findDirectMainPids' + ); + const terminateSpy = vi.spyOn( + NodemonService.prototype as unknown as { terminatePids: (pids: number[]) => Promise }, + 'terminatePids' + ); beforeEach(() => { vi.clearAllMocks(); @@ -57,6 +65,8 @@ describe('NodemonService', () => { vi.mocked(fileExists).mockResolvedValue(false); killSpy.mockReturnValue(true); findMatchingSpy.mockResolvedValue([]); + findDirectMainSpy.mockResolvedValue([]); + terminateSpy.mockResolvedValue(); stopPm2Spy.mockResolvedValue(); }); @@ -272,6 +282,35 @@ describe('NodemonService', () => { expect(execa).not.toHaveBeenCalled(); }); + it('terminates direct main.js processes before starting nodemon', async () => { + const service = new NodemonService(logger); + findMatchingSpy.mockResolvedValue([]); + findDirectMainSpy.mockResolvedValue([321, 654]); + + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 777, + stdout, + stderr, + unref, + } as unknown as ReturnType); + + await service.start(); + + expect(terminateSpy).toHaveBeenCalledWith([321, 654]); + expect(execa).toHaveBeenCalledWith( + '/usr/bin/nodemon', + ['--config', '/etc/unraid-api/nodemon.json', '--quiet'], + expect.objectContaining({ cwd: '/usr/local/unraid-api' }) + ); + }); + it('returns not running when pid file is missing', async () => { const service = new NodemonService(logger); vi.mocked(fileExists).mockResolvedValue(false); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 1b5db069bf..b6971f3af0 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { createWriteStream } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { execa } from 'execa'; @@ -51,20 +50,34 @@ export class NodemonService { ) ).find(Boolean) ?? null; - if (!pm2Path) return; + if (pm2Path) { + try { + const { stdout } = await execa(pm2Path, ['jlist']); + const processes = JSON.parse(stdout); + const hasUnraid = + Array.isArray(processes) && processes.some((proc) => proc?.name === 'unraid-api'); + if (hasUnraid) { + await execa(pm2Path, ['delete', 'unraid-api']); + this.logger.info('Stopped pm2-managed unraid-api before starting nodemon.'); + } + } catch (error) { + // PM2 may not be installed or responding; keep this quiet to avoid noisy startup. + this.logger.debug?.('Skipping pm2 cleanup (not installed or not running).'); + } + } + // Fallback: directly kill the pm2 daemon and remove its state, even if pm2 binary is missing. try { - const { stdout } = await execa(pm2Path, ['jlist']); - const processes = JSON.parse(stdout); - const hasUnraid = - Array.isArray(processes) && processes.some((proc) => proc?.name === 'unraid-api'); - if (!hasUnraid) return; - await execa(pm2Path, ['delete', 'unraid-api']); - this.logger.info('Stopped pm2-managed unraid-api before starting nodemon.'); - } catch (error) { - // PM2 may not be installed or responding; keep this quiet to avoid noisy startup. - this.logger.debug?.('Skipping pm2 cleanup (not installed or not running).'); + const pidText = (await readFile(pm2PidPath, 'utf-8')).trim(); + const pid = Number.parseInt(pidText, 10); + if (!Number.isNaN(pid)) { + process.kill(pid, 'SIGTERM'); + this.logger.debug?.(`Sent SIGTERM to pm2 daemon (pid ${pid}).`); + } + } catch { + // ignore } + await rm('/var/log/.pm2', { recursive: true, force: true }); } private async getStoredPid(): Promise { @@ -100,6 +113,37 @@ export class NodemonService { } } + private async findDirectMainPids(): Promise { + try { + const mainPath = join(UNRAID_API_CWD, 'dist', 'main.js'); + const { stdout } = await execa('ps', ['-eo', 'pid,args']); + return stdout + .split('\n') + .map((line) => line.trim()) + .map((line) => line.match(/^(\d+)\s+(.*)$/)) + .filter((match): match is RegExpMatchArray => Boolean(match)) + .map(([, pid, cmd]) => ({ pid: Number.parseInt(pid, 10), cmd })) + .filter(({ cmd }) => cmd.includes(mainPath)) + .map(({ pid }) => pid) + .filter((pid) => Number.isInteger(pid)); + } catch { + return []; + } + } + + private async terminatePids(pids: number[]) { + for (const pid of pids) { + try { + process.kill(pid, 'SIGTERM'); + this.logger.debug?.(`Sent SIGTERM to existing unraid-api process (pid ${pid}).`); + } catch (error) { + this.logger.debug?.( + `Failed to send SIGTERM to pid ${pid}: ${error instanceof Error ? error.message : error}` + ); + } + } + } + async start(options: StartOptions = {}) { try { await this.ensureNodemonDependencies(); @@ -137,6 +181,14 @@ export class NodemonService { return; } + const directMainPids = await this.findDirectMainPids(); + if (directMainPids.length > 0) { + this.logger.warn( + `Found existing unraid-api process(es) running directly: ${directMainPids.join(', ')}. Stopping them before starting nodemon.` + ); + await this.terminatePids(directMainPids); + } + const overrides = Object.fromEntries( Object.entries(options.env ?? {}).filter(([, value]) => value !== undefined) ); From a5e9b833743aa29f595a98596afee385bd9db8e7 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 22:16:32 -0500 Subject: [PATCH 09/25] feat: Implement waitForNodemonExit method and enhance restart logic in NodemonService - Added waitForNodemonExit method to ensure nodemon processes are fully terminated before restarting. - Updated restart method to call waitForNodemonExit, improving process management during restarts. - Introduced a new unit test to validate the behavior of the restart method, ensuring proper sequence of stop, wait, and start operations. --- api/src/unraid-api/cli/nodemon.service.spec.ts | 18 ++++++++++++++++++ api/src/unraid-api/cli/nodemon.service.ts | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index f29803b621..cd317a7e85 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -360,4 +360,22 @@ describe('NodemonService', () => { ); expect(result).toBe(''); }); + + it('waits for nodemon to exit during restart before starting again', async () => { + const service = new NodemonService(logger); + const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue(); + const startSpy = vi.spyOn(service, 'start').mockResolvedValue(); + const waitSpy = vi + .spyOn( + service as unknown as { waitForNodemonExit: () => Promise }, + 'waitForNodemonExit' + ) + .mockResolvedValue(); + + await service.restart({ env: { LOG_LEVEL: 'DEBUG' } }); + + expect(stopSpy).toHaveBeenCalledWith({ quiet: true }); + expect(waitSpy).toHaveBeenCalled(); + expect(startSpy).toHaveBeenCalledWith({ env: { LOG_LEVEL: 'DEBUG' } }); + }); }); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index b6971f3af0..41da0468d1 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -144,6 +144,23 @@ export class NodemonService { } } + private async waitForNodemonExit(timeoutMs = 5000, pollIntervalMs = 100) { + const deadline = Date.now() + timeoutMs; + + // Poll for any remaining nodemon processes that match our config file + while (Date.now() < deadline) { + const pids = await this.findMatchingNodemonPids(); + if (pids.length === 0) return; + + const runningFlags = await Promise.all(pids.map((pid) => this.isPidRunning(pid))); + if (!runningFlags.some(Boolean)) return; + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + this.logger.debug?.('Timed out waiting for nodemon to exit; continuing restart anyway.'); + } + async start(options: StartOptions = {}) { try { await this.ensureNodemonDependencies(); @@ -256,6 +273,7 @@ export class NodemonService { async restart(options: StartOptions = {}) { await this.stop({ quiet: true }); + await this.waitForNodemonExit(); await this.start(options); } From bec54e4feb50d968ef189efde3dffac1878d1df3 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 23 Nov 2025 10:11:42 -0500 Subject: [PATCH 10/25] feat: Enhance NodemonService to improve process management during restarts - Updated the start method to restart nodemon if a recorded pid is already running, ensuring proper cleanup and logging. - Modified the restart method to delegate to start, streamlining the process management logic. - Enhanced unit tests to validate the new behavior, including scenarios for cleaning up stray processes and ensuring fresh starts. --- .../unraid-api/cli/nodemon.service.spec.ts | 101 +++++++++++++++--- api/src/unraid-api/cli/nodemon.service.ts | 30 +++--- 2 files changed, 106 insertions(+), 25 deletions(-) diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index cd317a7e85..4c94c82d78 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -207,8 +207,13 @@ describe('NodemonService', () => { expect(logsSpy).toHaveBeenCalledWith(50); }); - it('is a no-op when a recorded nodemon pid is already running', async () => { + it('restarts when a recorded nodemon pid is already running', async () => { const service = new NodemonService(logger); + const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue(); + vi.spyOn( + service as unknown as { waitForNodemonExit: () => Promise }, + 'waitForNodemonExit' + ).mockResolvedValue(); vi.spyOn( service as unknown as { getStoredPid: () => Promise }, 'getStoredPid' @@ -218,13 +223,28 @@ describe('NodemonService', () => { 'isPidRunning' ).mockResolvedValue(true); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 456, + stdout, + stderr, + unref, + } as unknown as ReturnType); + await service.start(); + expect(stopSpy).toHaveBeenCalledWith({ quiet: true }); + expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); + expect(execa).toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith( - 'unraid-api already running under nodemon (pid 999); skipping start.' + 'unraid-api already running under nodemon (pid 999); restarting for a fresh start.' ); - expect(execa).not.toHaveBeenCalled(); - expect(mockRm).not.toHaveBeenCalled(); }); it('removes stale pid file and starts when recorded pid is dead', async () => { @@ -265,21 +285,36 @@ describe('NodemonService', () => { ); }); - it('adopts an already-running nodemon when no pid file exists', async () => { + it('cleans up stray nodemon when no pid file exists', async () => { const service = new NodemonService(logger); findMatchingSpy.mockResolvedValue([888]); vi.spyOn( service as unknown as { isPidRunning: (pid: number) => Promise }, 'isPidRunning' ).mockResolvedValue(true); + vi.spyOn( + service as unknown as { waitForNodemonExit: () => Promise }, + 'waitForNodemonExit' + ).mockResolvedValue(); + + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 222, + stdout, + stderr, + unref, + } as unknown as ReturnType); await service.start(); - expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '888'); - expect(logger.info).toHaveBeenCalledWith( - 'unraid-api already running under nodemon (pid 888); discovered via process scan.' - ); - expect(execa).not.toHaveBeenCalled(); + expect(terminateSpy).toHaveBeenCalledWith([888]); + expect(execa).toHaveBeenCalled(); }); it('terminates direct main.js processes before starting nodemon', async () => { @@ -364,18 +399,60 @@ describe('NodemonService', () => { it('waits for nodemon to exit during restart before starting again', async () => { const service = new NodemonService(logger); const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue(); - const startSpy = vi.spyOn(service, 'start').mockResolvedValue(); const waitSpy = vi .spyOn( service as unknown as { waitForNodemonExit: () => Promise }, 'waitForNodemonExit' ) .mockResolvedValue(); + vi.spyOn( + service as unknown as { getStoredPid: () => Promise }, + 'getStoredPid' + ).mockResolvedValue(123); + vi.spyOn( + service as unknown as { isPidRunning: (pid: number) => Promise }, + 'isPidRunning' + ).mockResolvedValue(true); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 456, + stdout, + stderr, + unref, + } as unknown as ReturnType); await service.restart({ env: { LOG_LEVEL: 'DEBUG' } }); expect(stopSpy).toHaveBeenCalledWith({ quiet: true }); expect(waitSpy).toHaveBeenCalled(); - expect(startSpy).toHaveBeenCalledWith({ env: { LOG_LEVEL: 'DEBUG' } }); + expect(execa).toHaveBeenCalled(); + }); + + it('performs clean start on restart when nodemon is not running', async () => { + const service = new NodemonService(logger); + const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue(); + const startSpy = vi.spyOn(service, 'start').mockResolvedValue(); + const waitSpy = vi + .spyOn( + service as unknown as { waitForNodemonExit: () => Promise }, + 'waitForNodemonExit' + ) + .mockResolvedValue(); + vi.spyOn( + service as unknown as { getStoredPid: () => Promise }, + 'getStoredPid' + ).mockResolvedValue(null); + + await service.restart(); + + expect(stopSpy).not.toHaveBeenCalled(); + expect(waitSpy).not.toHaveBeenCalled(); + expect(startSpy).toHaveBeenCalled(); }); }); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 41da0468d1..57a1344214 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -178,24 +178,29 @@ export class NodemonService { const running = await this.isPidRunning(existingPid); if (running) { this.logger.info( - `unraid-api already running under nodemon (pid ${existingPid}); skipping start.` + `unraid-api already running under nodemon (pid ${existingPid}); restarting for a fresh start.` ); - return; + await this.stop({ quiet: true }); + await this.waitForNodemonExit(); + await rm(NODEMON_PID_PATH, { force: true }); + } else { + this.logger.warn( + `Found nodemon pid file (${existingPid}) but the process is not running. Cleaning up.` + ); + await rm(NODEMON_PID_PATH, { force: true }); } - this.logger.warn( - `Found nodemon pid file (${existingPid}) but the process is not running. Cleaning up.` - ); - await rm(NODEMON_PID_PATH, { force: true }); } const discoveredPids = await this.findMatchingNodemonPids(); - const discoveredPid = discoveredPids.at(0); - if (discoveredPid && (await this.isPidRunning(discoveredPid))) { - await writeFile(NODEMON_PID_PATH, `${discoveredPid}`); + const liveDiscoveredPids = await Promise.all( + discoveredPids.map(async (pid) => ((await this.isPidRunning(pid)) ? pid : null)) + ).then((pids) => pids.filter((pid): pid is number => pid !== null)); + if (liveDiscoveredPids.length > 0) { this.logger.info( - `unraid-api already running under nodemon (pid ${discoveredPid}); discovered via process scan.` + `Found nodemon process(es) (${liveDiscoveredPids.join(', ')}) without a pid file; restarting for a fresh start.` ); - return; + await this.terminatePids(liveDiscoveredPids); + await this.waitForNodemonExit(); } const directMainPids = await this.findDirectMainPids(); @@ -272,8 +277,7 @@ export class NodemonService { } async restart(options: StartOptions = {}) { - await this.stop({ quiet: true }); - await this.waitForNodemonExit(); + // Delegate to start so both commands share identical logic await this.start(options); } From dc7a449f3ff51594ecb90a23616ebf54881cf50e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 25 Nov 2025 10:40:52 -0500 Subject: [PATCH 11/25] fix: Update log stream handling in NodemonService tests - Modified the log stream mock to use a file descriptor instead of pipe methods, aligning with the actual implementation in NodemonService. - Removed unnecessary stdout and stderr pipe mocks from unit tests, simplifying the test setup while maintaining functionality. - Ensured consistency between the test and implementation for improved clarity and maintainability. --- .../unraid-api/cli/nodemon.service.spec.ts | 56 ++++--------------- api/src/unraid-api/cli/nodemon.service.ts | 4 +- 2 files changed, 12 insertions(+), 48 deletions(-) diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 4c94c82d78..ba8bfc3bef 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -8,7 +8,7 @@ import { fileExists } from '@app/core/utils/files/file-exists.js'; import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; vi.mock('node:fs', () => ({ - createWriteStream: vi.fn(() => ({ pipe: vi.fn(), close: vi.fn() })), + createWriteStream: vi.fn(() => ({ fd: 42, close: vi.fn() })), })); vi.mock('node:fs/promises'); vi.mock('execa', () => ({ execa: vi.fn() })); @@ -90,17 +90,13 @@ describe('NodemonService', () => { it('starts nodemon and writes pid file', async () => { const service = new NodemonService(logger); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 123, - stdout, - stderr, unref, } as unknown as ReturnType); killSpy.mockReturnValue(true); @@ -116,12 +112,10 @@ describe('NodemonService', () => { cwd: '/usr/local/unraid-api', env: expect.objectContaining({ LOG_LEVEL: 'DEBUG' }), detached: true, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', logStream, logStream], } ); expect(createWriteStream).toHaveBeenCalledWith('/var/log/graphql-api.log', { flags: 'a' }); - expect(stdout.pipe).toHaveBeenCalled(); - expect(stderr.pipe).toHaveBeenCalled(); expect(unref).toHaveBeenCalled(); expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '123'); expect(logger.info).toHaveBeenCalledWith('Started nodemon (pid 123)'); @@ -142,7 +136,7 @@ describe('NodemonService', () => { it('throws error and closes logStream when execa fails', async () => { const service = new NodemonService(logger); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); @@ -159,17 +153,13 @@ describe('NodemonService', () => { it('throws error and closes logStream when pid is missing', async () => { const service = new NodemonService(logger); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: undefined, - stdout, - stderr, unref, } as unknown as ReturnType); @@ -183,17 +173,13 @@ describe('NodemonService', () => { it('throws when nodemon exits immediately after start', async () => { const service = new NodemonService(logger); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 456, - stdout, - stderr, unref, } as unknown as ReturnType); killSpy.mockImplementation(() => { @@ -223,17 +209,13 @@ describe('NodemonService', () => { 'isPidRunning' ).mockResolvedValue(true); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 456, - stdout, - stderr, unref, } as unknown as ReturnType); @@ -249,17 +231,13 @@ describe('NodemonService', () => { it('removes stale pid file and starts when recorded pid is dead', async () => { const service = new NodemonService(logger); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 111, - stdout, - stderr, unref, } as unknown as ReturnType); vi.spyOn( @@ -297,17 +275,13 @@ describe('NodemonService', () => { 'waitForNodemonExit' ).mockResolvedValue(); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 222, - stdout, - stderr, unref, } as unknown as ReturnType); @@ -322,17 +296,13 @@ describe('NodemonService', () => { findMatchingSpy.mockResolvedValue([]); findDirectMainSpy.mockResolvedValue([321, 654]); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 777, - stdout, - stderr, unref, } as unknown as ReturnType); @@ -413,17 +383,13 @@ describe('NodemonService', () => { service as unknown as { isPidRunning: (pid: number) => Promise }, 'isPidRunning' ).mockResolvedValue(true); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 456, - stdout, - stderr, unref, } as unknown as ReturnType); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 57a1344214..9f9c95073b 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -223,11 +223,9 @@ export class NodemonService { cwd: UNRAID_API_CWD, env, detached: true, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', logStream, logStream], }); - nodemonProcess.stdout?.pipe(logStream); - nodemonProcess.stderr?.pipe(logStream); nodemonProcess.unref(); if (!nodemonProcess.pid) { From 3462e7688dac59a9b1ba69e703d628c1dd306241 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 25 Nov 2025 12:03:01 -0500 Subject: [PATCH 12/25] feat: Add integration tests for NodemonService with real nodemon execution - Introduced a new integration test file for NodemonService to validate the start and stop functionality of the real nodemon process. - Implemented setup and teardown logic to create temporary files and directories for testing. - Enhanced logging and error handling in the tests to ensure proper verification of nodemon's behavior during execution. --- .../cli/nodemon.service.integration.spec.ts | 122 ++++++++++++++++++ .../unraid-api/cli/nodemon.service.spec.ts | 102 +++++++++------ api/src/unraid-api/cli/nodemon.service.ts | 35 ++++- 3 files changed, 220 insertions(+), 39 deletions(-) create mode 100644 api/src/unraid-api/cli/nodemon.service.integration.spec.ts diff --git a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts new file mode 100644 index 0000000000..6826684eec --- /dev/null +++ b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts @@ -0,0 +1,122 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +const logger = { + trace: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + log: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +} as const; + +describe('NodemonService (real nodemon)', () => { + const tmpRoot = join(tmpdir(), 'nodemon-service-'); + let workdir: string; + let scriptPath: string; + let configPath: string; + let logPath: string; + let pidPath: string; + const nodemonPath = join(process.cwd(), 'node_modules', 'nodemon', 'bin', 'nodemon.js'); + + beforeAll(async () => { + workdir = await mkdtemp(tmpRoot); + scriptPath = join(workdir, 'app.js'); + configPath = join(workdir, 'nodemon.json'); + logPath = join(workdir, 'nodemon.log'); + pidPath = join(workdir, 'nodemon.pid'); + + await writeFile( + scriptPath, + ["console.log('nodemon-integration-start');", 'setInterval(() => {}, 1000);'].join('\n') + ); + + await writeFile( + configPath, + JSON.stringify( + { + watch: ['app.js'], + exec: 'node ./app.js', + signal: 'SIGTERM', + ext: 'js', + }, + null, + 2 + ) + ); + }); + + afterAll(async () => { + await rm(workdir, { recursive: true, force: true }); + }); + + it('starts and stops real nodemon and writes logs', async () => { + vi.resetModules(); + vi.doMock('@app/environment.js', () => ({ + LOG_LEVEL: 'INFO', + LOG_TYPE: 'pretty', + SUPPRESS_LOGS: false, + API_VERSION: 'test-version', + NODEMON_CONFIG_PATH: configPath, + NODEMON_PATH: nodemonPath, + NODEMON_PID_PATH: pidPath, + PATHS_LOGS_DIR: workdir, + PATHS_LOGS_FILE: logPath, + UNRAID_API_CWD: workdir, + })); + + const { NodemonService } = await import('./nodemon.service.js'); + const service = new NodemonService(logger); + + await service.start(); + + const pidText = (await readFile(pidPath, 'utf-8')).trim(); + const pid = Number.parseInt(pidText, 10); + expect(Number.isInteger(pid) && pid > 0).toBe(true); + + const logStats = await stat(logPath); + expect(logStats.isFile()).toBe(true); + await waitForLogEntry(logPath, 'nodemon-integration-start'); + + await service.stop(); + await waitForExit(pid); + await expect(stat(pidPath)).rejects.toThrow(); + }, 20_000); +}); + +async function waitForLogEntry(path: string, needle: string, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + + while (true) { + try { + const contents = await readFile(path, 'utf-8'); + if (contents.includes(needle)) return contents; + } catch { + // ignore until timeout + } + + if (Date.now() > deadline) { + throw new Error(`Log entry "${needle}" not found in ${path} within ${timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +async function waitForExit(pid: number, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + + while (true) { + try { + process.kill(pid, 0); + } catch { + return; + } + if (Date.now() > deadline) { + throw new Error(`Process ${pid} did not exit within ${timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index ba8bfc3bef..6ea92e4559 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -7,8 +7,37 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fileExists } from '@app/core/utils/files/file-exists.js'; import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; +const createLogStreamMock = (fd = 42, autoOpen = true) => { + const listeners: Record void>> = {}; + const stream: any = { + fd, + close: vi.fn(), + destroy: vi.fn(), + once: vi.fn(), + off: vi.fn(), + }; + + stream.once.mockImplementation((event: string, cb: (...args: any[]) => void) => { + listeners[event] = listeners[event] ?? []; + listeners[event].push(cb); + if (event === 'open' && autoOpen) cb(); + return stream; + }); + stream.off.mockImplementation((event: string, cb: (...args: any[]) => void) => { + listeners[event] = (listeners[event] ?? []).filter((fn) => fn !== cb); + return stream; + }); + stream.emit = (event: string, ...args: any[]) => { + (listeners[event] ?? []).forEach((fn) => fn(...args)); + }; + + return stream as ReturnType & { + emit: (event: string, ...args: any[]) => void; + }; +}; + vi.mock('node:fs', () => ({ - createWriteStream: vi.fn(() => ({ fd: 42, close: vi.fn() })), + createWriteStream: vi.fn(), })); vi.mock('node:fs/promises'); vi.mock('execa', () => ({ execa: vi.fn() })); @@ -59,6 +88,7 @@ describe('NodemonService', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(createWriteStream).mockImplementation(() => createLogStreamMock()); mockMkdir.mockResolvedValue(undefined); mockWriteFile.mockResolvedValue(undefined as unknown as void); mockRm.mockResolvedValue(undefined as unknown as void); @@ -76,6 +106,7 @@ describe('NodemonService', () => { await service.ensureNodemonDependencies(); expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true }); + expect(mockMkdir).toHaveBeenCalledWith('/var/log', { recursive: true }); expect(mockMkdir).toHaveBeenCalledWith('/var/run/unraid-api', { recursive: true }); }); @@ -90,10 +121,8 @@ describe('NodemonService', () => { it('starts nodemon and writes pid file', async () => { const service = new NodemonService(logger); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 123, @@ -112,6 +141,7 @@ describe('NodemonService', () => { cwd: '/usr/local/unraid-api', env: expect.objectContaining({ LOG_LEVEL: 'DEBUG' }), detached: true, + reject: false, stdio: ['ignore', logStream, logStream], } ); @@ -136,10 +166,8 @@ describe('NodemonService', () => { it('throws error and closes logStream when execa fails', async () => { const service = new NodemonService(logger); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const error = new Error('Command not found'); vi.mocked(execa).mockImplementation(() => { throw error; @@ -151,12 +179,24 @@ describe('NodemonService', () => { expect(logger.info).not.toHaveBeenCalled(); }); - it('throws error and closes logStream when pid is missing', async () => { + it('throws a clear error when the log file cannot be opened', async () => { const service = new NodemonService(logger); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType + const logStream = createLogStreamMock(99, false); + vi.mocked(createWriteStream).mockReturnValue(logStream); + const openError = new Error('EACCES: permission denied'); + setTimeout(() => logStream.emit('error', openError), 0); + + await expect(service.start()).rejects.toThrow( + 'Failed to start nodemon: EACCES: permission denied' ); + expect(logStream.destroy).toHaveBeenCalled(); + expect(execa).not.toHaveBeenCalled(); + }); + + it('throws error and closes logStream when pid is missing', async () => { + const service = new NodemonService(logger); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: undefined, @@ -173,10 +213,8 @@ describe('NodemonService', () => { it('throws when nodemon exits immediately after start', async () => { const service = new NodemonService(logger); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 456, @@ -209,10 +247,8 @@ describe('NodemonService', () => { 'isPidRunning' ).mockResolvedValue(true); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 456, @@ -231,10 +267,8 @@ describe('NodemonService', () => { it('removes stale pid file and starts when recorded pid is dead', async () => { const service = new NodemonService(logger); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 111, @@ -275,10 +309,8 @@ describe('NodemonService', () => { 'waitForNodemonExit' ).mockResolvedValue(); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 222, @@ -296,10 +328,8 @@ describe('NodemonService', () => { findMatchingSpy.mockResolvedValue([]); findDirectMainSpy.mockResolvedValue([321, 654]); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 777, @@ -383,10 +413,8 @@ describe('NodemonService', () => { service as unknown as { isPidRunning: (pid: number) => Promise }, 'isPidRunning' ).mockResolvedValue(true); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 456, diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 9f9c95073b..211984561f 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -33,6 +33,7 @@ export class NodemonService { async ensureNodemonDependencies() { await mkdir(PATHS_LOGS_DIR, { recursive: true }); + await mkdir(dirname(PATHS_LOGS_FILE), { recursive: true }); await mkdir(dirname(NODEMON_PID_PATH), { recursive: true }); } @@ -215,14 +216,17 @@ export class NodemonService { Object.entries(options.env ?? {}).filter(([, value]) => value !== undefined) ); const env = { ...process.env, ...overrides } as Record; - const logStream = createWriteStream(PATHS_LOGS_FILE, { flags: 'a' }); + let logStream: ReturnType | null = null; let nodemonProcess; try { + logStream = await this.createLogStream(); + nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], { cwd: UNRAID_API_CWD, env, detached: true, + reject: false, stdio: ['ignore', logStream, logStream], }); @@ -248,7 +252,7 @@ export class NodemonService { this.logger.info(`Started nodemon (pid ${nodemonProcess.pid})`); } catch (error) { - logStream.close(); + logStream?.close(); const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to start nodemon: ${errorMessage}`); } @@ -315,4 +319,31 @@ export class NodemonService { return ''; } } + + private async createLogStream() { + const logStream = createWriteStream(PATHS_LOGS_FILE, { flags: 'a' }); + + await new Promise((resolve, reject) => { + const cleanup = () => { + logStream.off('open', onOpen); + logStream.off('error', onError); + }; + + const onOpen = () => { + cleanup(); + resolve(); + }; + + const onError = (error: unknown) => { + cleanup(); + logStream.destroy(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + + logStream.once('open', onOpen); + logStream.once('error', onError); + }); + + return logStream; + } } From fa837db09fabe1540e52461448886233f64fc0dc Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 25 Nov 2025 12:28:28 -0500 Subject: [PATCH 13/25] feat: Add nodemon log file configuration and enhance logging in NodemonService - Introduced PATHS_NODEMON_LOG_FILE to configure the log file for nodemon, allowing for better log management. - Updated log stream handling in NodemonService to write to the specified nodemon log file. - Enhanced integration tests to validate logging behavior and ensure proper file creation for both application and nodemon logs. --- api/src/core/log.ts | 6 +++-- api/src/environment.ts | 2 ++ .../cli/nodemon.service.integration.spec.ts | 26 ++++++++++++++----- .../unraid-api/cli/nodemon.service.spec.ts | 6 ++++- api/src/unraid-api/cli/nodemon.service.ts | 19 +++++++++++--- 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/api/src/core/log.ts b/api/src/core/log.ts index 4d0311b35a..c9a112aa96 100644 --- a/api/src/core/log.ts +++ b/api/src/core/log.ts @@ -1,7 +1,7 @@ import pino from 'pino'; import pretty from 'pino-pretty'; -import { API_VERSION, LOG_LEVEL, LOG_TYPE, SUPPRESS_LOGS } from '@app/environment.js'; +import { API_VERSION, LOG_LEVEL, LOG_TYPE, PATHS_LOGS_FILE, SUPPRESS_LOGS } from '@app/environment.js'; export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const; @@ -16,7 +16,9 @@ const nullDestination = pino.destination({ }); export const logDestination = - process.env.SUPPRESS_LOGS === 'true' ? nullDestination : pino.destination(); + process.env.SUPPRESS_LOGS === 'true' + ? nullDestination + : pino.destination({ dest: PATHS_LOGS_FILE, mkdir: true }); // Since process output is piped directly to the log file, we should not colorize stdout // to avoid ANSI escape codes in the log file const stream = SUPPRESS_LOGS diff --git a/api/src/environment.ts b/api/src/environment.ts index cef6282071..94107eab18 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -102,6 +102,8 @@ export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK export const PATHS_LOGS_DIR = process.env.PATHS_LOGS_DIR ?? process.env.LOGS_DIR ?? '/var/log/unraid-api'; export const PATHS_LOGS_FILE = process.env.PATHS_LOGS_FILE ?? '/var/log/graphql-api.log'; +export const PATHS_NODEMON_LOG_FILE = + process.env.PATHS_NODEMON_LOG_FILE ?? join(PATHS_LOGS_DIR, 'nodemon.log'); export const NODEMON_PATH = join(UNRAID_API_ROOT, 'node_modules', 'nodemon', 'bin', 'nodemon.js'); export const NODEMON_CONFIG_PATH = join(UNRAID_API_ROOT, 'nodemon.json'); diff --git a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts index 6826684eec..9444faaa1f 100644 --- a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts @@ -18,7 +18,8 @@ describe('NodemonService (real nodemon)', () => { let workdir: string; let scriptPath: string; let configPath: string; - let logPath: string; + let appLogPath: string; + let nodemonLogPath: string; let pidPath: string; const nodemonPath = join(process.cwd(), 'node_modules', 'nodemon', 'bin', 'nodemon.js'); @@ -26,12 +27,21 @@ describe('NodemonService (real nodemon)', () => { workdir = await mkdtemp(tmpRoot); scriptPath = join(workdir, 'app.js'); configPath = join(workdir, 'nodemon.json'); - logPath = join(workdir, 'nodemon.log'); + appLogPath = join(workdir, 'app.log'); + nodemonLogPath = join(workdir, 'nodemon.log'); pidPath = join(workdir, 'nodemon.pid'); await writeFile( scriptPath, - ["console.log('nodemon-integration-start');", 'setInterval(() => {}, 1000);'].join('\n') + [ + "const { appendFileSync } = require('node:fs');", + "const appLog = process.env.PATHS_LOGS_FILE || './app.log';", + "const nodemonLog = process.env.PATHS_NODEMON_LOG_FILE || './nodemon.log';", + "appendFileSync(appLog, 'app-log-entry\\n');", + "appendFileSync(nodemonLog, 'nodemon-log-entry\\n');", + "console.log('nodemon-integration-start');", + 'setInterval(() => {}, 1000);', + ].join('\n') ); await writeFile( @@ -64,7 +74,8 @@ describe('NodemonService (real nodemon)', () => { NODEMON_PATH: nodemonPath, NODEMON_PID_PATH: pidPath, PATHS_LOGS_DIR: workdir, - PATHS_LOGS_FILE: logPath, + PATHS_LOGS_FILE: appLogPath, + PATHS_NODEMON_LOG_FILE: nodemonLogPath, UNRAID_API_CWD: workdir, })); @@ -77,9 +88,10 @@ describe('NodemonService (real nodemon)', () => { const pid = Number.parseInt(pidText, 10); expect(Number.isInteger(pid) && pid > 0).toBe(true); - const logStats = await stat(logPath); - expect(logStats.isFile()).toBe(true); - await waitForLogEntry(logPath, 'nodemon-integration-start'); + const nodemonLogStats = await stat(nodemonLogPath); + expect(nodemonLogStats.isFile()).toBe(true); + await waitForLogEntry(nodemonLogPath, 'Starting nodemon'); + await waitForLogEntry(appLogPath, 'app-log-entry'); await service.stop(); await waitForExit(pid); diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 6ea92e4559..8f2f0386e6 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -13,6 +13,7 @@ const createLogStreamMock = (fd = 42, autoOpen = true) => { fd, close: vi.fn(), destroy: vi.fn(), + write: vi.fn(), once: vi.fn(), off: vi.fn(), }; @@ -52,6 +53,7 @@ vi.mock('@app/environment.js', () => ({ NODEMON_PID_PATH: '/var/run/unraid-api/nodemon.pid', PATHS_LOGS_DIR: '/var/log/unraid-api', PATHS_LOGS_FILE: '/var/log/graphql-api.log', + PATHS_NODEMON_LOG_FILE: '/var/log/unraid-api/nodemon.log', UNRAID_API_CWD: '/usr/local/unraid-api', })); @@ -145,7 +147,9 @@ describe('NodemonService', () => { stdio: ['ignore', logStream, logStream], } ); - expect(createWriteStream).toHaveBeenCalledWith('/var/log/graphql-api.log', { flags: 'a' }); + expect(createWriteStream).toHaveBeenCalledWith('/var/log/unraid-api/nodemon.log', { + flags: 'a', + }); expect(unref).toHaveBeenCalled(); expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '123'); expect(logger.info).toHaveBeenCalledWith('Started nodemon (pid 123)'); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 211984561f..c609854368 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -12,6 +12,7 @@ import { NODEMON_PID_PATH, PATHS_LOGS_DIR, PATHS_LOGS_FILE, + PATHS_NODEMON_LOG_FILE, UNRAID_API_CWD, } from '@app/environment.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; @@ -34,6 +35,7 @@ export class NodemonService { async ensureNodemonDependencies() { await mkdir(PATHS_LOGS_DIR, { recursive: true }); await mkdir(dirname(PATHS_LOGS_FILE), { recursive: true }); + await mkdir(dirname(PATHS_NODEMON_LOG_FILE), { recursive: true }); await mkdir(dirname(NODEMON_PID_PATH), { recursive: true }); } @@ -215,12 +217,21 @@ export class NodemonService { const overrides = Object.fromEntries( Object.entries(options.env ?? {}).filter(([, value]) => value !== undefined) ); - const env = { ...process.env, ...overrides } as Record; + const env = { + ...process.env, + PATHS_LOGS_FILE, + PATHS_NODEMON_LOG_FILE, + NODEMON_CONFIG_PATH, + NODEMON_PID_PATH, + UNRAID_API_CWD, + ...overrides, + } as Record; let logStream: ReturnType | null = null; let nodemonProcess; try { - logStream = await this.createLogStream(); + logStream = await this.createLogStream(PATHS_NODEMON_LOG_FILE); + logStream.write('Starting nodemon...\n'); nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], { cwd: UNRAID_API_CWD, @@ -320,8 +331,8 @@ export class NodemonService { } } - private async createLogStream() { - const logStream = createWriteStream(PATHS_LOGS_FILE, { flags: 'a' }); + private async createLogStream(logPath: string) { + const logStream = createWriteStream(logPath, { flags: 'a' }); await new Promise((resolve, reject) => { const cleanup = () => { From 9ff64629cf6ec34ba339beab3375b128c841d685 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 25 Nov 2025 13:59:09 -0500 Subject: [PATCH 14/25] chore: Update API version and refactor pubsub channel references - Updated API version in api.json from 4.25.3 to 4.27.2. - Refactored pubsub channel references across multiple files to use GRAPHQL_PUBSUB_CHANNEL instead of PUBSUB_CHANNEL, enhancing consistency and clarity in the codebase. - Adjusted related tests to ensure they align with the new pubsub channel structure. --- api/dev/configs/api.json | 2 +- api/src/core/pubsub.ts | 2 - .../store/listeners/array-event-listener.ts | 7 +- .../cli/nodemon.service.integration.spec.ts | 8 +- .../graph/resolvers/array/array.resolver.ts | 5 +- .../graph/resolvers/array/parity.resolver.ts | 4 +- .../display/display.resolver.spec.ts | 8 +- .../resolvers/display/display.resolver.ts | 5 +- .../docker/docker-event.service.spec.ts | 8 +- .../resolvers/docker/docker-event.service.ts | 5 +- .../resolvers/docker/docker.service.spec.ts | 9 +- .../graph/resolvers/docker/docker.service.ts | 7 +- .../metrics.resolver.integration.spec.ts | 51 ++++--- .../resolvers/metrics/metrics.resolver.ts | 25 ++-- .../loadNotificationsFile.test.ts | 2 +- .../notifications/notifications.resolver.ts | 7 +- .../notifications/notifications.service.ts | 7 +- .../graph/resolvers/owner/owner.resolver.ts | 5 +- .../resolvers/servers/server.resolver.ts | 5 +- .../subscription-helper.service.spec.ts | 131 +++++++++++------- .../services/subscription-helper.service.ts | 10 +- 21 files changed, 184 insertions(+), 129 deletions(-) diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index acaf5daa92..e09b0f3f55 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,5 +1,5 @@ { - "version": "4.25.3", + "version": "4.27.2", "extraOrigins": [], "sandbox": true, "ssoSubIds": [], diff --git a/api/src/core/pubsub.ts b/api/src/core/pubsub.ts index e3b679b86c..280614e5a4 100644 --- a/api/src/core/pubsub.ts +++ b/api/src/core/pubsub.ts @@ -7,8 +7,6 @@ import { PubSub } from 'graphql-subscriptions'; const eventEmitter = new EventEmitter(); eventEmitter.setMaxListeners(30); -export { GRAPHQL_PUBSUB_CHANNEL as PUBSUB_CHANNEL }; - export const pubsub = new PubSub({ eventEmitter }); /** diff --git a/api/src/store/listeners/array-event-listener.ts b/api/src/store/listeners/array-event-listener.ts index 6291a09195..70da63e80b 100644 --- a/api/src/store/listeners/array-event-listener.ts +++ b/api/src/store/listeners/array-event-listener.ts @@ -1,9 +1,10 @@ import { isAnyOf } from '@reduxjs/toolkit'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { isEqual } from 'lodash-es'; import { logger } from '@app/core/log.js'; import { getArrayData } from '@app/core/modules/array/get-array-data.js'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { startAppListening } from '@app/store/listeners/listener-middleware.js'; import { loadSingleStateFile } from '@app/store/modules/emhttp.js'; import { StateFileKey } from '@app/store/types.js'; @@ -20,14 +21,14 @@ export const enableArrayEventListener = () => await delay(5_000); const array = getArrayData(getState); if (!isEqual(oldArrayData, array)) { - pubsub.publish(PUBSUB_CHANNEL.ARRAY, { array }); + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.ARRAY, { array }); logger.debug({ event: array }, 'Array was updated, publishing event'); } subscribe(); } else if (action.meta.arg === StateFileKey.var) { if (!isEqual(getOriginalState().emhttp.var?.name, getState().emhttp.var?.name)) { - await pubsub.publish(PUBSUB_CHANNEL.INFO, { + await pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.INFO, { info: { os: { hostname: getState().emhttp.var?.name, diff --git a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts index 9444faaa1f..1d75ef9a2b 100644 --- a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts @@ -4,14 +4,20 @@ import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { LogService } from '@app/unraid-api/cli/log.service.js'; + const logger = { + clear: vi.fn(), + shouldLog: vi.fn(() => true), + table: vi.fn(), trace: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn(), info: vi.fn(), debug: vi.fn(), -} as const; + always: vi.fn(), +} as unknown as LogService; describe('NodemonService (real nodemon)', () => { const tmpRoot = join(tmpdir(), 'nodemon-service-'); diff --git a/api/src/unraid-api/graph/resolvers/array/array.resolver.ts b/api/src/unraid-api/graph/resolvers/array/array.resolver.ts index 40734973ea..45ad31932f 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.resolver.ts @@ -1,9 +1,10 @@ import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { UnraidArray } from '@app/unraid-api/graph/resolvers/array/array.model.js'; import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; @@ -26,6 +27,6 @@ export class ArrayResolver { resource: Resource.ARRAY, }) public async arraySubscription() { - return createSubscription(PUBSUB_CHANNEL.ARRAY); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.ARRAY); } } diff --git a/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts b/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts index 07b304c3c7..8ed56ab906 100644 --- a/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts @@ -1,10 +1,10 @@ import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { PubSub } from 'graphql-subscriptions'; -import { PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; import { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js'; import { ParityService } from '@app/unraid-api/graph/resolvers/array/parity.service.js'; @@ -33,6 +33,6 @@ export class ParityResolver { }) @Subscription(() => ParityCheck) parityHistorySubscription() { - return pubSub.asyncIterableIterator(PUBSUB_CHANNEL.PARITY); + return pubSub.asyncIterableIterator(GRAPHQL_PUBSUB_CHANNEL.PARITY); } } diff --git a/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts index 884805059c..483a876237 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts @@ -1,6 +1,7 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js'; @@ -9,9 +10,6 @@ import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/dis // Mock the pubsub module vi.mock('@app/core/pubsub.js', () => ({ createSubscription: vi.fn().mockReturnValue('mock-subscription'), - PUBSUB_CHANNEL: { - DISPLAY: 'display', - }, })); describe('DisplayResolver', () => { @@ -80,11 +78,11 @@ describe('DisplayResolver', () => { describe('displaySubscription', () => { it('should create and return subscription', async () => { - const { createSubscription, PUBSUB_CHANNEL } = await import('@app/core/pubsub.js'); + const { createSubscription } = await import('@app/core/pubsub.js'); const result = await resolver.displaySubscription(); - expect(createSubscription).toHaveBeenCalledWith(PUBSUB_CHANNEL.DISPLAY); + expect(createSubscription).toHaveBeenCalledWith(GRAPHQL_PUBSUB_CHANNEL.DISPLAY); expect(result).toBe('mock-subscription'); }); }); diff --git a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts index 558c2b4be3..6f1e732763 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts @@ -1,9 +1,10 @@ import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { Display } from '@app/unraid-api/graph/resolvers/info/display/display.model.js'; import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js'; @@ -26,6 +27,6 @@ export class DisplayResolver { resource: Resource.DISPLAY, }) public async displaySubscription() { - return createSubscription(PUBSUB_CHANNEL.DISPLAY); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.DISPLAY); } } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts index 933100f1bf..ab8823e08e 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts @@ -2,11 +2,12 @@ import { Logger } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { PassThrough, Readable } from 'stream'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import Docker from 'dockerode'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Import pubsub for use in tests -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { DockerEventService } from '@app/unraid-api/graph/resolvers/docker/docker-event.service.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; @@ -46,9 +47,6 @@ vi.mock('@app/core/pubsub.js', () => ({ pubsub: { publish: vi.fn().mockResolvedValue(undefined), }, - PUBSUB_CHANNEL: { - INFO: 'info', - }, })); // Mock DockerService @@ -140,7 +138,7 @@ describe('DockerEventService', () => { expect(dockerService.clearContainerCache).toHaveBeenCalled(); expect(dockerService.getAppInfo).toHaveBeenCalled(); - expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, expect.any(Object)); + expect(pubsub.publish).toHaveBeenCalledWith(GRAPHQL_PUBSUB_CHANNEL.INFO, expect.any(Object)); }); it('should ignore non-watched actions', async () => { diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts index 8e34166b61..0be2febfcd 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts @@ -1,10 +1,11 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Readable } from 'stream'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { watch } from 'chokidar'; import Docker from 'dockerode'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { getters } from '@app/store/index.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; @@ -132,7 +133,7 @@ export class DockerEventService implements OnModuleDestroy, OnModuleInit { await this.dockerService.clearContainerCache(); // Get updated counts and publish const appInfo = await this.dockerService.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + await pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.INFO, appInfo); this.logger.debug(`Published app info update due to event: ${actionName}`); } } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts index ba7e974f22..39843d2a22 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts @@ -2,11 +2,12 @@ import type { TestingModule } from '@nestjs/testing'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Test } from '@nestjs/testing'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import Docker from 'dockerode'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // Import the mocked pubsub parts -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { ContainerState, DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; @@ -15,7 +16,7 @@ vi.mock('@app/core/pubsub.js', () => ({ pubsub: { publish: vi.fn().mockResolvedValue(undefined), }, - PUBSUB_CHANNEL: { + GRAPHQL_PUBSUB_CHANNEL: { INFO: 'info', }, })); @@ -274,7 +275,7 @@ describe('DockerService', () => { expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY); expect(mockListContainers).toHaveBeenCalled(); expect(mockCacheManager.set).toHaveBeenCalled(); - expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, { + expect(pubsub.publish).toHaveBeenCalledWith(GRAPHQL_PUBSUB_CHANNEL.INFO, { info: { apps: { installed: 1, running: 1 }, }, @@ -332,7 +333,7 @@ describe('DockerService', () => { expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY); expect(mockListContainers).toHaveBeenCalled(); expect(mockCacheManager.set).toHaveBeenCalled(); - expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, { + expect(pubsub.publish).toHaveBeenCalledWith(GRAPHQL_PUBSUB_CHANNEL.INFO, { info: { apps: { installed: 1, running: 0 }, }, diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts index 5b244773f6..54bc9c88d2 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts @@ -2,10 +2,11 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { readFile } from 'fs/promises'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { type Cache } from 'cache-manager'; import Docker from 'dockerode'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js'; import { sleep } from '@app/core/utils/misc/sleep.js'; import { getters } from '@app/store/index.js'; @@ -210,7 +211,7 @@ export class DockerService { throw new Error(`Container ${id} not found after starting`); } const appInfo = await this.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + await pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.INFO, appInfo); return updatedContainer; } @@ -240,7 +241,7 @@ export class DockerService { this.logger.warn(`Container ${id} did not reach EXITED state after stop command.`); } const appInfo = await this.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + await pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.INFO, appInfo); return updatedContainer; } } diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts index 12b899a094..0c7fe074ab 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts @@ -2,9 +2,10 @@ import type { TestingModule } from '@nestjs/testing'; import { ScheduleModule } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; @@ -107,7 +108,7 @@ describe('MetricsResolver Integration Tests', () => { }); // Trigger polling by simulating subscription - trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); // Wait a bit for potential multiple executions await new Promise((resolve) => setTimeout(resolve, 100)); @@ -141,7 +142,7 @@ describe('MetricsResolver Integration Tests', () => { }); // Trigger polling by simulating subscription - trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); // Wait a bit for potential multiple executions await new Promise((resolve) => setTimeout(resolve, 100)); @@ -155,13 +156,13 @@ describe('MetricsResolver Integration Tests', () => { const trackerService = module.get(SubscriptionTrackerService); // Trigger polling by starting subscription - trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); // Wait for the polling interval to trigger (1000ms for CPU) await new Promise((resolve) => setTimeout(resolve, 1100)); expect(publishSpy).toHaveBeenCalledWith( - PUBSUB_CHANNEL.CPU_UTILIZATION, + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, expect.objectContaining({ systemMetricsCpu: expect.objectContaining({ id: 'info/cpu-load', @@ -171,7 +172,7 @@ describe('MetricsResolver Integration Tests', () => { }) ); - trackerService.unsubscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.unsubscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); publishSpy.mockRestore(); }); @@ -180,13 +181,13 @@ describe('MetricsResolver Integration Tests', () => { const trackerService = module.get(SubscriptionTrackerService); // Trigger polling by starting subscription - trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); // Wait for the polling interval to trigger (2000ms for memory) await new Promise((resolve) => setTimeout(resolve, 2100)); expect(publishSpy).toHaveBeenCalledWith( - PUBSUB_CHANNEL.MEMORY_UTILIZATION, + GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION, expect.objectContaining({ systemMetricsMemory: expect.objectContaining({ id: 'memory-utilization', @@ -197,7 +198,7 @@ describe('MetricsResolver Integration Tests', () => { }) ); - trackerService.unsubscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.unsubscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); publishSpy.mockRestore(); }); @@ -214,7 +215,7 @@ describe('MetricsResolver Integration Tests', () => { vi.spyOn(service, 'generateCpuLoad').mockRejectedValueOnce(new Error('CPU error')); // Trigger polling - trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); // Wait for polling interval to trigger and handle error (1000ms for CPU) await new Promise((resolve) => setTimeout(resolve, 1100)); @@ -224,7 +225,7 @@ describe('MetricsResolver Integration Tests', () => { expect.any(Error) ); - trackerService.unsubscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.unsubscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); loggerSpy.mockRestore(); }); @@ -241,7 +242,7 @@ describe('MetricsResolver Integration Tests', () => { vi.spyOn(service, 'generateMemoryLoad').mockRejectedValueOnce(new Error('Memory error')); // Trigger polling - trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); // Wait for polling interval to trigger and handle error (2000ms for memory) await new Promise((resolve) => setTimeout(resolve, 2100)); @@ -251,7 +252,7 @@ describe('MetricsResolver Integration Tests', () => { expect.any(Error) ); - trackerService.unsubscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.unsubscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); loggerSpy.mockRestore(); }); }); @@ -263,26 +264,30 @@ describe('MetricsResolver Integration Tests', () => { module.get(SubscriptionManagerService); // Start polling - trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); - trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); // Wait a bit for subscriptions to be fully set up await new Promise((resolve) => setTimeout(resolve, 100)); // Verify subscriptions are active - expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(true); - expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.MEMORY_UTILIZATION)).toBe( - true - ); + expect( + subscriptionManager.isSubscriptionActive(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION) + ).toBe(true); + expect( + subscriptionManager.isSubscriptionActive(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION) + ).toBe(true); // Clean up the module await module.close(); // Subscriptions should be cleaned up - expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(false); - expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.MEMORY_UTILIZATION)).toBe( - false - ); + expect( + subscriptionManager.isSubscriptionActive(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION) + ).toBe(false); + expect( + subscriptionManager.isSubscriptionActive(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION) + ).toBe(false); }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts index cbd47e86ba..13c5f793fa 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -2,9 +2,10 @@ import { Logger, OnModuleInit } from '@nestjs/common'; import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; import { CpuPackages, CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; @@ -28,16 +29,16 @@ export class MetricsResolver implements OnModuleInit { onModuleInit() { // Register CPU polling with 1 second interval this.subscriptionTracker.registerTopic( - PUBSUB_CHANNEL.CPU_UTILIZATION, + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, async () => { const payload = await this.cpuService.generateCpuLoad(); - pubsub.publish(PUBSUB_CHANNEL.CPU_UTILIZATION, { systemMetricsCpu: payload }); + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, { systemMetricsCpu: payload }); }, 1000 ); this.subscriptionTracker.registerTopic( - PUBSUB_CHANNEL.CPU_TELEMETRY, + GRAPHQL_PUBSUB_CHANNEL.CPU_TELEMETRY, async () => { const packageList = (await this.cpuTopologyService.generateTelemetry()) ?? []; @@ -59,7 +60,7 @@ export class MetricsResolver implements OnModuleInit { this.logger.debug(`CPU_TELEMETRY payload: ${JSON.stringify(packages)}`); // Publish the payload - pubsub.publish(PUBSUB_CHANNEL.CPU_TELEMETRY, { + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.CPU_TELEMETRY, { systemMetricsCpuTelemetry: packages, }); @@ -70,10 +71,12 @@ export class MetricsResolver implements OnModuleInit { // Register memory polling with 2 second interval this.subscriptionTracker.registerTopic( - PUBSUB_CHANNEL.MEMORY_UTILIZATION, + GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION, async () => { const payload = await this.memoryService.generateMemoryLoad(); - pubsub.publish(PUBSUB_CHANNEL.MEMORY_UTILIZATION, { systemMetricsMemory: payload }); + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION, { + systemMetricsMemory: payload, + }); }, 2000 ); @@ -109,7 +112,7 @@ export class MetricsResolver implements OnModuleInit { resource: Resource.INFO, }) public async systemMetricsCpuSubscription() { - return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + return this.subscriptionHelper.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); } @Subscription(() => CpuPackages, { @@ -121,7 +124,7 @@ export class MetricsResolver implements OnModuleInit { resource: Resource.INFO, }) public async systemMetricsCpuTelemetrySubscription() { - return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_TELEMETRY); + return this.subscriptionHelper.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.CPU_TELEMETRY); } @Subscription(() => MemoryUtilization, { @@ -133,6 +136,8 @@ export class MetricsResolver implements OnModuleInit { resource: Resource.INFO, }) public async systemMetricsMemorySubscription() { - return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + return this.subscriptionHelper.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION + ); } } diff --git a/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts b/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts index 1c582ddd33..87a1218884 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts @@ -46,7 +46,7 @@ vi.mock('@app/core/pubsub.js', () => ({ pubsub: { publish: vi.fn(), }, - PUBSUB_CHANNEL: { + GRAPHQL_PUBSUB_CHANNEL: { NOTIFICATION_OVERVIEW: 'notification_overview', NOTIFICATION_ADDED: 'notification_added', }, diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts index fe6e56ad6b..de7335f4a1 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts @@ -2,10 +2,11 @@ import { Args, Mutation, Query, ResolveField, Resolver, Subscription } from '@ne import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { AppError } from '@app/core/errors/app-error.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { Notification, NotificationData, @@ -152,7 +153,7 @@ export class NotificationsResolver { resource: Resource.NOTIFICATIONS, }) async notificationAdded() { - return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_ADDED); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.NOTIFICATION_ADDED); } @Subscription(() => NotificationOverview) @@ -161,6 +162,6 @@ export class NotificationsResolver { resource: Resource.NOTIFICATIONS, }) async notificationsOverview() { - return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW); } } diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts index 6ec780d666..c2cfdaf99b 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts @@ -3,6 +3,7 @@ import { readdir, readFile, rename, stat, unlink, writeFile } from 'fs/promises' import { basename, join } from 'path'; import type { Stats } from 'fs'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { FSWatcher, watch } from 'chokidar'; import { ValidationError } from 'class-validator'; import { execa } from 'execa'; @@ -12,7 +13,7 @@ import { encode as encodeIni } from 'ini'; import { v7 as uuidv7 } from 'uuid'; import { AppError } from '@app/core/errors/app-error.js'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { NotificationIni } from '@app/core/types/states/notification.js'; import { fileExists } from '@app/core/utils/files/file-exists.js'; import { parseConfig } from '@app/core/utils/misc/parse-config.js'; @@ -118,7 +119,7 @@ export class NotificationsService { if (type === NotificationType.UNREAD) { this.publishOverview(); - pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_ADDED, { + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.NOTIFICATION_ADDED, { notificationAdded: notification, }); } @@ -137,7 +138,7 @@ export class NotificationsService { } private publishOverview(overview = NotificationsService.overview) { - return pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW, { + return pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW, { notificationsOverview: overview, }); } diff --git a/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts b/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts index c4f20ca5d2..1dd550a735 100644 --- a/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts @@ -2,9 +2,10 @@ import { ConfigService } from '@nestjs/config'; import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { Owner } from '@app/unraid-api/graph/resolvers/owner/owner.model.js'; // Question: should we move this into the connect plugin, or should this always be available? @@ -39,6 +40,6 @@ export class OwnerResolver { resource: Resource.OWNER, }) public ownerSubscription() { - return createSubscription(PUBSUB_CHANNEL.OWNER); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.OWNER); } } diff --git a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts index 8bcc2e9e3f..980e966c66 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts @@ -3,9 +3,10 @@ import { ConfigService } from '@nestjs/config'; import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { getters } from '@app/store/index.js'; import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js'; import { @@ -42,7 +43,7 @@ export class ServerResolver { resource: Resource.SERVERS, }) public async serversSubscription() { - return createSubscription(PUBSUB_CHANNEL.SERVERS); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.SERVERS); } private getLocalServer(): ServerModel { diff --git a/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts b/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts index 42ec4815cd..c6c7d3e2d1 100644 --- a/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts +++ b/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts @@ -1,9 +1,10 @@ import { Logger } from '@nestjs/common'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { PubSub } from 'graphql-subscriptions'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; @@ -28,7 +29,9 @@ describe('SubscriptionHelperService', () => { describe('createTrackedSubscription', () => { it('should create an async iterator that tracks subscriptions', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); expect(iterator).toBeDefined(); expect(iterator.next).toBeDefined(); @@ -37,29 +40,35 @@ describe('SubscriptionHelperService', () => { expect(iterator[Symbol.asyncIterator]).toBeDefined(); // Should have subscribed - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); }); it('should return itself when Symbol.asyncIterator is called', () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); expect(iterator[Symbol.asyncIterator]()).toBe(iterator); }); it('should unsubscribe when return() is called', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); }); it('should unsubscribe when throw() is called', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); try { await iterator.throw?.(new Error('Test error')); @@ -67,14 +76,14 @@ describe('SubscriptionHelperService', () => { // Expected to throw } - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); }); }); describe('integration with pubsub', () => { it('should receive published messages', async () => { const iterator = helperService.createTrackedSubscription<{ cpuUtilization: any }>( - PUBSUB_CHANNEL.CPU_UTILIZATION + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION ); const testData = { @@ -92,7 +101,7 @@ describe('SubscriptionHelperService', () => { await new Promise((resolve) => setTimeout(resolve, 10)); // Publish a message - await (pubsub as PubSub).publish(PUBSUB_CHANNEL.CPU_UTILIZATION, testData); + await (pubsub as PubSub).publish(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, testData); // Wait for the message const result = await consumePromise; @@ -107,21 +116,27 @@ describe('SubscriptionHelperService', () => { // Register handlers to verify start/stop behavior const onStart = vi.fn(); const onStop = vi.fn(); - trackerService.registerTopic(PUBSUB_CHANNEL.CPU_UTILIZATION, onStart, onStop); + trackerService.registerTopic(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, onStart, onStop); // Create first subscriber - const iterator1 = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + const iterator1 = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); expect(onStart).toHaveBeenCalledTimes(1); // Create second subscriber - const iterator2 = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2); + const iterator2 = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2); expect(onStart).toHaveBeenCalledTimes(1); // Should not call again // Create third subscriber - const iterator3 = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(3); + const iterator3 = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(3); // Set up consumption promises first const consume1 = iterator1.next(); @@ -133,7 +148,7 @@ describe('SubscriptionHelperService', () => { // Publish a message - all should receive it const testData = { cpuUtilization: { id: 'test', load: 75, cpus: [] } }; - await (pubsub as PubSub).publish(PUBSUB_CHANNEL.CPU_UTILIZATION, testData); + await (pubsub as PubSub).publish(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, testData); const [result1, result2, result3] = await Promise.all([consume1, consume2, consume3]); @@ -143,17 +158,17 @@ describe('SubscriptionHelperService', () => { // Clean up first subscriber await iterator1.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2); expect(onStop).not.toHaveBeenCalled(); // Clean up second subscriber await iterator2.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); expect(onStop).not.toHaveBeenCalled(); // Clean up last subscriber - should trigger onStop await iterator3.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); expect(onStop).toHaveBeenCalledTimes(1); }); @@ -161,18 +176,26 @@ describe('SubscriptionHelperService', () => { const iterations = 10; for (let i = 0; i < iterations; i++) { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe( + 1 + ); await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe( + 0 + ); } }); it('should properly clean up on error', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); const testError = new Error('Test error'); @@ -183,13 +206,15 @@ describe('SubscriptionHelperService', () => { expect(error).toBe(testError); } - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); }); it('should log debug messages for subscription lifecycle', async () => { vi.clearAllMocks(); - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); expect(loggerSpy).toHaveBeenCalledWith( expect.stringContaining('Subscription added for topic') @@ -205,9 +230,9 @@ describe('SubscriptionHelperService', () => { describe('different topic types', () => { it('should handle INFO channel subscriptions', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.INFO); + const iterator = helperService.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.INFO); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(1); // Set up consumption promise first const consumePromise = iterator.next(); @@ -216,47 +241,51 @@ describe('SubscriptionHelperService', () => { await new Promise((resolve) => setTimeout(resolve, 10)); const testData = { info: { id: 'test-info' } }; - await (pubsub as PubSub).publish(PUBSUB_CHANNEL.INFO, testData); + await (pubsub as PubSub).publish(GRAPHQL_PUBSUB_CHANNEL.INFO, testData); const result = await consumePromise; expect(result.value).toEqual(testData); await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(0); }); it('should track multiple different topics independently', async () => { - const cpuIterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - const infoIterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.INFO); + const cpuIterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + const infoIterator = helperService.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.INFO); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(1); const allCounts = trackerService.getAllSubscriberCounts(); - expect(allCounts.get(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); - expect(allCounts.get(PUBSUB_CHANNEL.INFO)).toBe(1); + expect(allCounts.get(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(allCounts.get(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(1); await cpuIterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(1); await infoIterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(0); }); }); describe('edge cases', () => { it('should handle return() called multiple times', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); // Second return should be idempotent await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); // Check that idempotent message was logged expect(loggerSpy).toHaveBeenCalledWith( @@ -265,7 +294,9 @@ describe('SubscriptionHelperService', () => { }); it('should handle async iterator protocol correctly', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); // Test that it works in for-await loop (would use Symbol.asyncIterator) const receivedMessages: any[] = []; @@ -285,7 +316,7 @@ describe('SubscriptionHelperService', () => { // Publish messages for (let i = 0; i < maxMessages; i++) { - await (pubsub as PubSub).publish(PUBSUB_CHANNEL.CPU_UTILIZATION, { + await (pubsub as PubSub).publish(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, { cpuUtilization: { id: `test-${i}`, load: i * 10, cpus: [] }, }); } @@ -300,7 +331,7 @@ describe('SubscriptionHelperService', () => { // Clean up await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); }); }); }); diff --git a/api/src/unraid-api/graph/services/subscription-helper.service.ts b/api/src/unraid-api/graph/services/subscription-helper.service.ts index 07adef005d..8ab3d94f28 100644 --- a/api/src/unraid-api/graph/services/subscription-helper.service.ts +++ b/api/src/unraid-api/graph/services/subscription-helper.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; + +import { createSubscription } from '@app/core/pubsub.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; /** @@ -21,7 +23,7 @@ import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subsc * \@Subscription(() => MetricsUpdate) * async metricsSubscription() { * // Topic must be registered first via SubscriptionTrackerService - * return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.METRICS); + * return this.subscriptionHelper.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.METRICS); * } */ @Injectable() @@ -33,7 +35,9 @@ export class SubscriptionHelperService { * @param topic The subscription topic/channel to subscribe to * @returns A proxy async iterator with automatic cleanup */ - public createTrackedSubscription(topic: PUBSUB_CHANNEL | string): AsyncIterableIterator { + public createTrackedSubscription( + topic: GRAPHQL_PUBSUB_CHANNEL | string + ): AsyncIterableIterator { const innerIterator = createSubscription(topic); // Subscribe when the subscription starts From 071efeac45917c3f51ac3d9da5e6bdec49bae59c Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 25 Nov 2025 14:29:54 -0500 Subject: [PATCH 15/25] feat: make casbin not always log --- api/src/environment.ts | 1 + api/src/unraid-api/auth/casbin/casbin.service.ts | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/environment.ts b/api/src/environment.ts index 94107eab18..da29db922b 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -92,6 +92,7 @@ export const LOG_LEVEL = process.env.LOG_LEVEL : process.env.ENVIRONMENT === 'production' ? 'INFO' : 'DEBUG'; +export const LOG_CASBIN = process.env.LOG_CASBIN === 'true'; export const SUPPRESS_LOGS = process.env.SUPPRESS_LOGS === 'true'; export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK ? process.env.MOTHERSHIP_GRAPHQL_LINK diff --git a/api/src/unraid-api/auth/casbin/casbin.service.ts b/api/src/unraid-api/auth/casbin/casbin.service.ts index 632d0ff8f7..be4441baa9 100644 --- a/api/src/unraid-api/auth/casbin/casbin.service.ts +++ b/api/src/unraid-api/auth/casbin/casbin.service.ts @@ -2,7 +2,7 @@ import { Injectable, InternalServerErrorException, Logger, OnModuleInit } from ' import { Model as CasbinModel, Enforcer, newEnforcer, StringAdapter } from 'casbin'; -import { LOG_LEVEL } from '@app/environment.js'; +import { LOG_CASBIN, LOG_LEVEL } from '@app/environment.js'; @Injectable() export class CasbinService { @@ -20,9 +20,8 @@ export class CasbinService { const casbinPolicy = new StringAdapter(policy); try { const enforcer = await newEnforcer(casbinModel, casbinPolicy); - if (LOG_LEVEL === 'TRACE') { - enforcer.enableLog(true); - } + // Casbin request logging is extremely verbose; keep it off unless explicitly enabled. + enforcer.enableLog(LOG_CASBIN && LOG_LEVEL === 'TRACE'); return enforcer; } catch (error: unknown) { From 9ae3f3cec31c20ba1881294e62902d6e31349a88 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 26 Nov 2025 15:36:13 -0500 Subject: [PATCH 16/25] feat: Enhance UserProfile component to conditionally render banner gradient based on CSS variable - Updated UserProfile component to load banner gradient from a CSS variable, allowing for dynamic styling. - Added tests to verify banner rendering behavior based on the presence of the CSS variable, ensuring correct functionality regardless of theme store settings. - Removed outdated test cases that relied solely on theme store flags for banner gradient rendering. --- web/__test__/components/UserProfile.test.ts | 65 ++++++++++++++----- web/src/components/UserProfile.standalone.vue | 15 ++++- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/web/__test__/components/UserProfile.test.ts b/web/__test__/components/UserProfile.test.ts index ef4d2a53e6..f252d000e9 100644 --- a/web/__test__/components/UserProfile.test.ts +++ b/web/__test__/components/UserProfile.test.ts @@ -358,8 +358,53 @@ describe('UserProfile.standalone.vue', () => { expect(wrapper.find('[data-testid="notifications-sidebar"]').exists()).toBe(true); }); - it('conditionally renders banner based on theme store', async () => { - const bannerSelector = 'div.absolute.z-0'; + it('renders banner gradient when CSS variable is set (even if theme store has banner disabled)', async () => { + const gradientValue = 'linear-gradient(to right, #111111, #222222)'; + document.documentElement.style.setProperty('--banner-gradient', gradientValue); + + const localPinia = createTestingPinia({ + createSpy: vi.fn, + initialState: { + server: { ...initialServerData }, + theme: { + theme: { + name: 'white', + banner: false, + bannerGradient: false, + descriptionShow: true, + textColor: '', + metaColor: '', + bgColor: '', + }, + }, + }, + stubActions: false, + }); + setActivePinia(localPinia); + + const localWrapper = mount(UserProfile, { + props: { + server: JSON.stringify(initialServerData), + }, + global: { + plugins: [localPinia], + stubs, + }, + }); + + await localWrapper.vm.$nextTick(); + + const bannerEl = localWrapper.find('div.absolute.z-0'); + expect(bannerEl.exists()).toBe(true); + expect(bannerEl.attributes('style')).toContain(gradientValue); + + localWrapper.unmount(); + document.documentElement.style.removeProperty('--banner-gradient'); + setActivePinia(pinia); + }); + + it('does not render banner gradient when CSS variable is absent, regardless of theme store flags', async () => { + document.documentElement.style.removeProperty('--banner-gradient'); themeStore.theme = { ...themeStore.theme!, @@ -368,19 +413,7 @@ describe('UserProfile.standalone.vue', () => { }; await wrapper.vm.$nextTick(); - expect(themeStore.bannerGradient).toContain('background-image: linear-gradient'); - expect(wrapper.find(bannerSelector).exists()).toBe(true); - - themeStore.theme!.bannerGradient = false; - await wrapper.vm.$nextTick(); - - expect(themeStore.bannerGradient).toBeUndefined(); - expect(wrapper.find(bannerSelector).exists()).toBe(false); - - themeStore.theme!.bannerGradient = true; - await wrapper.vm.$nextTick(); - - expect(themeStore.bannerGradient).toContain('background-image: linear-gradient'); - expect(wrapper.find(bannerSelector).exists()).toBe(true); + const bannerEl = wrapper.find('div.absolute.z-0'); + expect(bannerEl.exists()).toBe(false); }); }); diff --git a/web/src/components/UserProfile.standalone.vue b/web/src/components/UserProfile.standalone.vue index 609ab906cd..7337c9e71a 100644 --- a/web/src/components/UserProfile.standalone.vue +++ b/web/src/components/UserProfile.standalone.vue @@ -35,7 +35,18 @@ const description = computed(() => serverStore.description); const guid = computed(() => serverStore.guid); const keyfile = computed(() => serverStore.keyfile); const lanIp = computed(() => serverStore.lanIp); -const bannerGradient = computed(() => themeStore.bannerGradient); +const bannerGradient = ref(); + +const loadBannerGradientFromCss = () => { + if (typeof window === 'undefined') return; + + const rawGradient = getComputedStyle(document.documentElement) + .getPropertyValue('--banner-gradient') + .trim(); + + bannerGradient.value = rawGradient ? `background-image: ${rawGradient};` : undefined; +}; + const theme = computed(() => themeStore.theme); // Control dropdown open state @@ -85,6 +96,8 @@ onBeforeMount(() => { }); onMounted(() => { + loadBannerGradientFromCss(); + if (devConfig.VITE_MOCK_USER_SESSION && devConfig.NODE_ENV === 'development') { document.cookie = 'unraid_session_cookie=mockusersession'; } From 4c2e212a0346ae5ab9d45774126b13e359b9372a Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 9 Dec 2025 11:54:19 -0500 Subject: [PATCH 17/25] tmp: add boot diagnistics --- api/src/cli.ts | 22 +++ .../unraid-api/cli/nodemon.service.spec.ts | 158 ++++++++--------- api/src/unraid-api/cli/nodemon.service.ts | 163 ++++++++++++------ .../dynamix.unraid.net/etc/rc.d/rc.unraid-api | 43 ++++- 4 files changed, 232 insertions(+), 154 deletions(-) diff --git a/api/src/cli.ts b/api/src/cli.ts index e12776216b..d1d6928611 100644 --- a/api/src/cli.ts +++ b/api/src/cli.ts @@ -1,12 +1,25 @@ import '@app/dotenv.js'; import { Logger } from '@nestjs/common'; +import { appendFileSync } from 'node:fs'; import { CommandFactory } from 'nest-commander'; import { LOG_LEVEL, SUPPRESS_LOGS } from '@app/environment.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; +const BOOT_LOG_PATH = '/var/log/unraid-api/boot.log'; + +const logToBootFile = (message: string): void => { + const timestamp = new Date().toISOString(); + const line = `[${timestamp}] [cli] ${message}\n`; + try { + appendFileSync(BOOT_LOG_PATH, line); + } catch { + // Silently fail if we can't write to boot log + } +}; + const getUnraidApiLocation = async () => { const { execa } = await import('execa'); try { @@ -26,6 +39,8 @@ const getLogger = () => { const logger = getLogger(); try { + logToBootFile(`CLI started with args: ${process.argv.slice(2).join(' ')}`); + await import('json-bigint-patch'); const { CliModule } = await import('@app/unraid-api/cli/cli.module.js'); @@ -38,10 +53,17 @@ try { nativeShell: { executablePath: await getUnraidApiLocation() }, }, }); + logToBootFile('CLI completed successfully'); process.exit(0); } catch (error) { + // Always log errors to boot file for boot-time debugging + const errorMessage = error instanceof Error ? error.stack || error.message : String(error); + logToBootFile(`CLI ERROR: ${errorMessage}`); + if (logger) { logger.error('ERROR:', error); + } else { + console.error('ERROR:', error); } process.exit(1); } diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 8f2f0386e6..a65981bab4 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -1,10 +1,11 @@ -import { createWriteStream } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { createWriteStream, openSync } from 'node:fs'; import * as fs from 'node:fs/promises'; import { execa } from 'execa'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { fileExists, fileExistsSync } from '@app/core/utils/files/file-exists.js'; import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; const createLogStreamMock = (fd = 42, autoOpen = true) => { @@ -37,13 +38,37 @@ const createLogStreamMock = (fd = 42, autoOpen = true) => { }; }; +const createSpawnMock = (pid?: number) => { + const unref = vi.fn(); + return { + pid, + unref, + } as unknown as ReturnType; +}; + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); vi.mock('node:fs', () => ({ createWriteStream: vi.fn(), + openSync: vi.fn().mockReturnValue(42), + writeSync: vi.fn(), })); -vi.mock('node:fs/promises'); +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + mkdir: vi.fn(), + writeFile: vi.fn(), + rm: vi.fn(), + readFile: vi.fn(), + appendFile: vi.fn(), + }; +}); vi.mock('execa', () => ({ execa: vi.fn() })); vi.mock('@app/core/utils/files/file-exists.js', () => ({ fileExists: vi.fn().mockResolvedValue(false), + fileExistsSync: vi.fn().mockReturnValue(true), })); vi.mock('@app/environment.js', () => ({ LOG_LEVEL: 'INFO', @@ -91,10 +116,13 @@ describe('NodemonService', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(createWriteStream).mockImplementation(() => createLogStreamMock()); + vi.mocked(openSync).mockReturnValue(42); + vi.mocked(spawn).mockReturnValue(createSpawnMock(123)); mockMkdir.mockResolvedValue(undefined); mockWriteFile.mockResolvedValue(undefined as unknown as void); mockRm.mockResolvedValue(undefined as unknown as void); vi.mocked(fileExists).mockResolvedValue(false); + vi.mocked(fileExistsSync).mockReturnValue(true); killSpy.mockReturnValue(true); findMatchingSpy.mockResolvedValue([]); findDirectMainSpy.mockResolvedValue([]); @@ -123,37 +151,28 @@ describe('NodemonService', () => { it('starts nodemon and writes pid file', async () => { const service = new NodemonService(logger); - const logStream = createLogStreamMock(99); - vi.mocked(createWriteStream).mockReturnValue(logStream); - const unref = vi.fn(); - vi.mocked(execa).mockReturnValue({ - pid: 123, - unref, - } as unknown as ReturnType); + const spawnMock = createSpawnMock(123); + vi.mocked(spawn).mockReturnValue(spawnMock); killSpy.mockReturnValue(true); findMatchingSpy.mockResolvedValue([]); await service.start({ env: { LOG_LEVEL: 'DEBUG' } }); expect(stopPm2Spy).toHaveBeenCalled(); - expect(execa).toHaveBeenCalledWith( - '/usr/bin/nodemon', - ['--config', '/etc/unraid-api/nodemon.json', '--quiet'], + expect(spawn).toHaveBeenCalledWith( + process.execPath, + ['/usr/bin/nodemon', '--config', '/etc/unraid-api/nodemon.json', '--quiet'], { cwd: '/usr/local/unraid-api', env: expect.objectContaining({ LOG_LEVEL: 'DEBUG' }), detached: true, - reject: false, - stdio: ['ignore', logStream, logStream], + stdio: ['ignore', 42, 42], } ); - expect(createWriteStream).toHaveBeenCalledWith('/var/log/unraid-api/nodemon.log', { - flags: 'a', - }); - expect(unref).toHaveBeenCalled(); + expect(openSync).toHaveBeenCalledWith('/var/log/unraid-api/nodemon.log', 'a'); + expect(spawnMock.unref).toHaveBeenCalled(); expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '123'); expect(logger.info).toHaveBeenCalledWith('Started nodemon (pid 123)'); - expect(logStream.close).not.toHaveBeenCalled(); }); it('throws error and aborts start when directory creation fails', async () => { @@ -165,72 +184,56 @@ describe('NodemonService', () => { expect(logger.error).toHaveBeenCalledWith( 'Failed to ensure nodemon dependencies: Permission denied' ); - expect(execa).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); }); - it('throws error and closes logStream when execa fails', async () => { + it('throws error when spawn fails', async () => { const service = new NodemonService(logger); - const logStream = createLogStreamMock(99); - vi.mocked(createWriteStream).mockReturnValue(logStream); const error = new Error('Command not found'); - vi.mocked(execa).mockImplementation(() => { + vi.mocked(spawn).mockImplementation(() => { throw error; }); await expect(service.start()).rejects.toThrow('Failed to start nodemon: Command not found'); - expect(logStream.close).toHaveBeenCalled(); expect(mockWriteFile).not.toHaveBeenCalled(); expect(logger.info).not.toHaveBeenCalled(); }); it('throws a clear error when the log file cannot be opened', async () => { const service = new NodemonService(logger); - const logStream = createLogStreamMock(99, false); - vi.mocked(createWriteStream).mockReturnValue(logStream); const openError = new Error('EACCES: permission denied'); - setTimeout(() => logStream.emit('error', openError), 0); + vi.mocked(openSync).mockImplementation(() => { + throw openError; + }); await expect(service.start()).rejects.toThrow( 'Failed to start nodemon: EACCES: permission denied' ); - expect(logStream.destroy).toHaveBeenCalled(); - expect(execa).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); }); - it('throws error and closes logStream when pid is missing', async () => { + it('throws error when pid is missing', async () => { const service = new NodemonService(logger); - const logStream = createLogStreamMock(99); - vi.mocked(createWriteStream).mockReturnValue(logStream); - const unref = vi.fn(); - vi.mocked(execa).mockReturnValue({ - pid: undefined, - unref, - } as unknown as ReturnType); + const spawnMock = createSpawnMock(undefined); + vi.mocked(spawn).mockReturnValue(spawnMock); await expect(service.start()).rejects.toThrow( 'Failed to start nodemon: process spawned but no PID was assigned' ); - expect(logStream.close).toHaveBeenCalled(); expect(mockWriteFile).not.toHaveBeenCalled(); expect(logger.info).not.toHaveBeenCalled(); }); it('throws when nodemon exits immediately after start', async () => { const service = new NodemonService(logger); - const logStream = createLogStreamMock(99); - vi.mocked(createWriteStream).mockReturnValue(logStream); - const unref = vi.fn(); - vi.mocked(execa).mockReturnValue({ - pid: 456, - unref, - } as unknown as ReturnType); + const spawnMock = createSpawnMock(456); + vi.mocked(spawn).mockReturnValue(spawnMock); killSpy.mockImplementation(() => { throw new Error('not running'); }); const logsSpy = vi.spyOn(service, 'logs').mockResolvedValue('recent log lines'); await expect(service.start()).rejects.toThrow(/Nodemon exited immediately/); - expect(logStream.close).toHaveBeenCalled(); expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); expect(logsSpy).toHaveBeenCalledWith(50); }); @@ -251,19 +254,14 @@ describe('NodemonService', () => { 'isPidRunning' ).mockResolvedValue(true); - const logStream = createLogStreamMock(99); - vi.mocked(createWriteStream).mockReturnValue(logStream); - const unref = vi.fn(); - vi.mocked(execa).mockReturnValue({ - pid: 456, - unref, - } as unknown as ReturnType); + const spawnMock = createSpawnMock(456); + vi.mocked(spawn).mockReturnValue(spawnMock); await service.start(); expect(stopSpy).toHaveBeenCalledWith({ quiet: true }); expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); - expect(execa).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith( 'unraid-api already running under nodemon (pid 999); restarting for a fresh start.' ); @@ -271,13 +269,8 @@ describe('NodemonService', () => { it('removes stale pid file and starts when recorded pid is dead', async () => { const service = new NodemonService(logger); - const logStream = createLogStreamMock(99); - vi.mocked(createWriteStream).mockReturnValue(logStream); - const unref = vi.fn(); - vi.mocked(execa).mockReturnValue({ - pid: 111, - unref, - } as unknown as ReturnType); + const spawnMock = createSpawnMock(111); + vi.mocked(spawn).mockReturnValue(spawnMock); vi.spyOn( service as unknown as { getStoredPid: () => Promise }, 'getStoredPid' @@ -294,7 +287,7 @@ describe('NodemonService', () => { await service.start(); expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); - expect(execa).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalled(); expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '111'); expect(logger.warn).toHaveBeenCalledWith( 'Found nodemon pid file (555) but the process is not running. Cleaning up.' @@ -313,18 +306,13 @@ describe('NodemonService', () => { 'waitForNodemonExit' ).mockResolvedValue(); - const logStream = createLogStreamMock(99); - vi.mocked(createWriteStream).mockReturnValue(logStream); - const unref = vi.fn(); - vi.mocked(execa).mockReturnValue({ - pid: 222, - unref, - } as unknown as ReturnType); + const spawnMock = createSpawnMock(222); + vi.mocked(spawn).mockReturnValue(spawnMock); await service.start(); expect(terminateSpy).toHaveBeenCalledWith([888]); - expect(execa).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalled(); }); it('terminates direct main.js processes before starting nodemon', async () => { @@ -332,20 +320,15 @@ describe('NodemonService', () => { findMatchingSpy.mockResolvedValue([]); findDirectMainSpy.mockResolvedValue([321, 654]); - const logStream = createLogStreamMock(99); - vi.mocked(createWriteStream).mockReturnValue(logStream); - const unref = vi.fn(); - vi.mocked(execa).mockReturnValue({ - pid: 777, - unref, - } as unknown as ReturnType); + const spawnMock = createSpawnMock(777); + vi.mocked(spawn).mockReturnValue(spawnMock); await service.start(); expect(terminateSpy).toHaveBeenCalledWith([321, 654]); - expect(execa).toHaveBeenCalledWith( - '/usr/bin/nodemon', - ['--config', '/etc/unraid-api/nodemon.json', '--quiet'], + expect(spawn).toHaveBeenCalledWith( + process.execPath, + ['/usr/bin/nodemon', '--config', '/etc/unraid-api/nodemon.json', '--quiet'], expect.objectContaining({ cwd: '/usr/local/unraid-api' }) ); }); @@ -417,19 +400,14 @@ describe('NodemonService', () => { service as unknown as { isPidRunning: (pid: number) => Promise }, 'isPidRunning' ).mockResolvedValue(true); - const logStream = createLogStreamMock(99); - vi.mocked(createWriteStream).mockReturnValue(logStream); - const unref = vi.fn(); - vi.mocked(execa).mockReturnValue({ - pid: 456, - unref, - } as unknown as ReturnType); + const spawnMock = createSpawnMock(456); + vi.mocked(spawn).mockReturnValue(spawnMock); await service.restart({ env: { LOG_LEVEL: 'DEBUG' } }); expect(stopSpy).toHaveBeenCalledWith({ quiet: true }); expect(waitSpy).toHaveBeenCalled(); - expect(execa).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalled(); }); it('performs clean start on restart when nodemon is not running', async () => { diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index c609854368..598479b47b 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -1,11 +1,12 @@ import { Injectable } from '@nestjs/common'; -import { createWriteStream } from 'node:fs'; -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { openSync, writeSync } from 'node:fs'; +import { appendFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { execa } from 'execa'; -import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { fileExists, fileExistsSync } from '@app/core/utils/files/file-exists.js'; import { NODEMON_CONFIG_PATH, NODEMON_PATH, @@ -28,10 +29,38 @@ type StopOptions = { quiet?: boolean; }; +const BOOT_LOG_PATH = '/var/log/unraid-api/boot.log'; + @Injectable() export class NodemonService { constructor(private readonly logger: LogService) {} + private async logToBootFile(message: string): Promise { + const timestamp = new Date().toISOString(); + const line = `[${timestamp}] [nodemon-service] ${message}\n`; + try { + await appendFile(BOOT_LOG_PATH, line); + } catch { + // Fallback to console if file write fails (e.g., directory doesn't exist yet) + } + } + + private validatePaths(): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!fileExistsSync(NODEMON_PATH)) { + errors.push(`NODEMON_PATH does not exist: ${NODEMON_PATH}`); + } + if (!fileExistsSync(NODEMON_CONFIG_PATH)) { + errors.push(`NODEMON_CONFIG_PATH does not exist: ${NODEMON_CONFIG_PATH}`); + } + if (!fileExistsSync(UNRAID_API_CWD)) { + errors.push(`UNRAID_API_CWD does not exist: ${UNRAID_API_CWD}`); + } + + return { valid: errors.length === 0, errors }; + } + async ensureNodemonDependencies() { await mkdir(PATHS_LOGS_DIR, { recursive: true }); await mkdir(dirname(PATHS_LOGS_FILE), { recursive: true }); @@ -165,21 +194,45 @@ export class NodemonService { } async start(options: StartOptions = {}) { + // Log boot attempt with diagnostic info + await this.logToBootFile('=== Starting unraid-api via nodemon ==='); + await this.logToBootFile(`NODEMON_PATH: ${NODEMON_PATH}`); + await this.logToBootFile(`NODEMON_CONFIG_PATH: ${NODEMON_CONFIG_PATH}`); + await this.logToBootFile(`UNRAID_API_CWD: ${UNRAID_API_CWD}`); + await this.logToBootFile(`NODEMON_PID_PATH: ${NODEMON_PID_PATH}`); + await this.logToBootFile(`process.cwd(): ${process.cwd()}`); + await this.logToBootFile(`process.execPath: ${process.execPath}`); + await this.logToBootFile(`PATH: ${process.env.PATH}`); + + // Validate paths before proceeding + const { valid, errors } = this.validatePaths(); + if (!valid) { + for (const error of errors) { + await this.logToBootFile(`ERROR: ${error}`); + this.logger.error(error); + } + throw new Error(`Path validation failed: ${errors.join('; ')}`); + } + await this.logToBootFile('Path validation passed'); + try { await this.ensureNodemonDependencies(); + await this.logToBootFile('Dependencies ensured'); } catch (error) { - this.logger.error( - `Failed to ensure nodemon dependencies: ${error instanceof Error ? error.message : error}` - ); + const msg = `Failed to ensure nodemon dependencies: ${error instanceof Error ? error.message : error}`; + await this.logToBootFile(`ERROR: ${msg}`); + this.logger.error(msg); throw error; } await this.stopPm2IfRunning(); + await this.logToBootFile('PM2 cleanup complete'); const existingPid = await this.getStoredPid(); if (existingPid) { const running = await this.isPidRunning(existingPid); if (running) { + await this.logToBootFile(`Found running nodemon (pid ${existingPid}), restarting`); this.logger.info( `unraid-api already running under nodemon (pid ${existingPid}); restarting for a fresh start.` ); @@ -187,6 +240,7 @@ export class NodemonService { await this.waitForNodemonExit(); await rm(NODEMON_PID_PATH, { force: true }); } else { + await this.logToBootFile(`Found stale pid file (${existingPid}), cleaning up`); this.logger.warn( `Found nodemon pid file (${existingPid}) but the process is not running. Cleaning up.` ); @@ -199,6 +253,7 @@ export class NodemonService { discoveredPids.map(async (pid) => ((await this.isPidRunning(pid)) ? pid : null)) ).then((pids) => pids.filter((pid): pid is number => pid !== null)); if (liveDiscoveredPids.length > 0) { + await this.logToBootFile(`Found orphan nodemon processes: ${liveDiscoveredPids.join(', ')}`); this.logger.info( `Found nodemon process(es) (${liveDiscoveredPids.join(', ')}) without a pid file; restarting for a fresh start.` ); @@ -208,6 +263,7 @@ export class NodemonService { const directMainPids = await this.findDirectMainPids(); if (directMainPids.length > 0) { + await this.logToBootFile(`Found direct main.js processes: ${directMainPids.join(', ')}`); this.logger.warn( `Found existing unraid-api process(es) running directly: ${directMainPids.join(', ')}. Stopping them before starting nodemon.` ); @@ -219,6 +275,9 @@ export class NodemonService { ); const env = { ...process.env, + // Ensure PATH includes standard locations for boot-time reliability + PATH: `/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}`, + NODE_ENV: 'production', PATHS_LOGS_FILE, PATHS_NODEMON_LOG_FILE, NODEMON_CONFIG_PATH, @@ -226,45 +285,62 @@ export class NodemonService { UNRAID_API_CWD, ...overrides, } as Record; - let logStream: ReturnType | null = null; - let nodemonProcess; - try { - logStream = await this.createLogStream(PATHS_NODEMON_LOG_FILE); - logStream.write('Starting nodemon...\n'); + await this.logToBootFile( + `Spawning: ${process.execPath} ${NODEMON_PATH} --config ${NODEMON_CONFIG_PATH}` + ); - nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], { - cwd: UNRAID_API_CWD, - env, - detached: true, - reject: false, - stdio: ['ignore', logStream, logStream], - }); + let logFd: number | null = null; + try { + // Use file descriptor for stdio - more reliable for detached processes at boot + logFd = openSync(PATHS_NODEMON_LOG_FILE, 'a'); + + // Write initial message to nodemon log + writeSync(logFd, 'Starting nodemon...\n'); + + // Use native spawn instead of execa for more reliable detached process handling + const nodemonProcess = spawn( + process.execPath, // Use current node executable path + [NODEMON_PATH, '--config', NODEMON_CONFIG_PATH, '--quiet'], + { + cwd: UNRAID_API_CWD, + env, + detached: true, + stdio: ['ignore', logFd, logFd], + } + ); nodemonProcess.unref(); if (!nodemonProcess.pid) { - logStream.close(); + await this.logToBootFile('ERROR: Failed to spawn nodemon - no PID assigned'); throw new Error('Failed to start nodemon: process spawned but no PID was assigned'); } await writeFile(NODEMON_PID_PATH, `${nodemonProcess.pid}`); - - // Give nodemon a brief moment to boot, then verify it is still alive. - await new Promise((resolve) => setTimeout(resolve, 200)); - const stillRunning = await this.isPidRunning(nodemonProcess.pid); - if (!stillRunning) { - const recentLogs = await this.logs(50); - await rm(NODEMON_PID_PATH, { force: true }); - logStream.close(); - const logMessage = recentLogs ? ` Recent logs:\n${recentLogs}` : ''; - throw new Error(`Nodemon exited immediately after start.${logMessage}`); + await this.logToBootFile(`Spawned nodemon with PID: ${nodemonProcess.pid}`); + + // Multiple verification checks with increasing delays for boot-time reliability + const verificationDelays = [200, 500, 1000]; + for (const delay of verificationDelays) { + await new Promise((resolve) => setTimeout(resolve, delay)); + const stillRunning = await this.isPidRunning(nodemonProcess.pid); + if (!stillRunning) { + const recentLogs = await this.logs(50); + await rm(NODEMON_PID_PATH, { force: true }); + const logMessage = recentLogs ? ` Recent logs:\n${recentLogs}` : ''; + await this.logToBootFile(`ERROR: Nodemon exited after ${delay}ms`); + await this.logToBootFile(`Recent logs: ${recentLogs}`); + throw new Error(`Nodemon exited immediately after start.${logMessage}`); + } + await this.logToBootFile(`Verification passed after ${delay}ms`); } + await this.logToBootFile(`Successfully started nodemon (pid ${nodemonProcess.pid})`); this.logger.info(`Started nodemon (pid ${nodemonProcess.pid})`); } catch (error) { - logStream?.close(); const errorMessage = error instanceof Error ? error.message : String(error); + await this.logToBootFile(`ERROR: ${errorMessage}`); throw new Error(`Failed to start nodemon: ${errorMessage}`); } } @@ -330,31 +406,4 @@ export class NodemonService { return ''; } } - - private async createLogStream(logPath: string) { - const logStream = createWriteStream(logPath, { flags: 'a' }); - - await new Promise((resolve, reject) => { - const cleanup = () => { - logStream.off('open', onOpen); - logStream.off('error', onError); - }; - - const onOpen = () => { - cleanup(); - resolve(); - }; - - const onError = (error: unknown) => { - cleanup(); - logStream.destroy(); - reject(error instanceof Error ? error : new Error(String(error))); - }; - - logStream.once('open', onOpen); - logStream.once('error', onError); - }); - - return logStream; - } } diff --git a/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid-api b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid-api index 0d14d33a93..d653bab3c5 100755 --- a/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid-api +++ b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid-api @@ -19,25 +19,47 @@ uninstall() { true } +# Boot log location for debugging startup issues +boot_log="/var/log/unraid-api/boot.log" + +# Helper to log boot messages with timestamp +log_boot() { + echo "[$(date -Iseconds)] [rc.unraid-api] $1" >> "$boot_log" 2>/dev/null || true +} + # Service control functions start() { echo "Starting Unraid API service..." + # Ensure PATH includes standard locations for boot-time reliability + export PATH="/usr/local/bin:/usr/bin:/bin:$PATH" + + # Create log directory if it doesn't exist (must be done before logging) + mkdir -p /var/log/unraid-api + + log_boot "start() called" + log_boot "PATH: $PATH" + log_boot "api_base_dir: $api_base_dir" + log_boot "unraid_binary_path: $unraid_binary_path" + # Restore vendored API plugins if they were installed if [ -x "$scripts_dir/dependencies.sh" ]; then - "$scripts_dir/dependencies.sh" restore || { + log_boot "Running dependencies.sh restore" + if "$scripts_dir/dependencies.sh" restore; then + log_boot "dependencies.sh restore succeeded" + else + log_boot "dependencies.sh restore FAILED" echo "Failed to restore API plugin dependencies! Continuing with start, but API plugins may be unavailable." - } + fi else + log_boot "Warning: dependencies.sh not found at $scripts_dir/dependencies.sh" echo "Warning: dependencies.sh script not found or not executable" fi - # Create log directory if it doesn't exist - mkdir -p /var/log/unraid-api - # Copy env file if needed if [ -f "${api_base_dir}/.env.production" ] && [ ! -f "${api_base_dir}/.env" ]; then cp "${api_base_dir}/.env.production" "${api_base_dir}/.env" + log_boot "Copied .env.production to .env" fi # Start the flash backup service if available and connect plugin is enabled @@ -51,9 +73,16 @@ start() { # Start the API service if [ -x "${unraid_binary_path}" ]; then - "${unraid_binary_path}" start - return $? + log_boot "Calling ${unraid_binary_path} start" + # Capture output and return code for boot debugging + output=$("${unraid_binary_path}" start 2>&1) + result=$? + echo "$output" + log_boot "unraid-api start output: $output" + log_boot "unraid-api start exit code: $result" + return $result else + log_boot "ERROR: Binary not found or not executable at ${unraid_binary_path}" echo "Error: Unraid API binary not found or not executable at ${unraid_binary_path}" return 1 fi From 719795647cbeca4f91759b8e4ac86d0987dc70ec Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 9 Dec 2025 15:40:34 -0500 Subject: [PATCH 18/25] feat: improve determinism --- .../unraid-api/cli/nodemon.service.spec.ts | 126 +++++++++++++++++- api/src/unraid-api/cli/nodemon.service.ts | 112 ++++++++++++++-- 2 files changed, 227 insertions(+), 11 deletions(-) diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index a65981bab4..70eb856152 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -333,9 +333,11 @@ describe('NodemonService', () => { ); }); - it('returns not running when pid file is missing', async () => { + it('returns not running when pid file is missing and no orphans', async () => { const service = new NodemonService(logger); vi.mocked(fileExists).mockResolvedValue(false); + findMatchingSpy.mockResolvedValue([]); + findDirectMainSpy.mockResolvedValue([]); const result = await service.status(); @@ -343,6 +345,128 @@ describe('NodemonService', () => { expect(logger.info).toHaveBeenCalledWith('unraid-api is not running (no pid file).'); }); + it('returns running and warns when orphan processes found without pid file', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(false); + findMatchingSpy.mockResolvedValue([]); + findDirectMainSpy.mockResolvedValue([123, 456]); + + const result = await service.status(); + + expect(result).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + 'No PID file, but found orphaned processes: nodemon=none, main.js=123,456' + ); + }); + + it('returns running and warns when orphan nodemon found without pid file', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(false); + findMatchingSpy.mockResolvedValue([789]); + findDirectMainSpy.mockResolvedValue([]); + + const result = await service.status(); + + expect(result).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + 'No PID file, but found orphaned processes: nodemon=789, main.js=none' + ); + }); + + it('stop: sends SIGTERM to nodemon and waits for exit', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(true); + vi.mocked(fs.readFile).mockResolvedValue('100'); + findDirectMainSpy.mockResolvedValue([200]); + const waitForPidsToExitSpy = vi + .spyOn( + service as unknown as { + waitForPidsToExit: (pids: number[], timeoutMs?: number) => Promise; + }, + 'waitForPidsToExit' + ) + .mockResolvedValue([]); + + await service.stop(); + + expect(killSpy).toHaveBeenCalledWith(100, 'SIGTERM'); + expect(waitForPidsToExitSpy).toHaveBeenCalledWith([100, 200], 5000); + expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); + }); + + it('stop: force kills remaining processes after timeout', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(true); + vi.mocked(fs.readFile).mockResolvedValue('100'); + findDirectMainSpy.mockResolvedValue([200]); + vi.spyOn( + service as unknown as { + waitForPidsToExit: (pids: number[], timeoutMs?: number) => Promise; + }, + 'waitForPidsToExit' + ).mockResolvedValue([100, 200]); + const terminatePidsWithForceSpy = vi + .spyOn( + service as unknown as { + terminatePidsWithForce: (pids: number[], gracePeriodMs?: number) => Promise; + }, + 'terminatePidsWithForce' + ) + .mockResolvedValue(); + + await service.stop(); + + expect(logger.warn).toHaveBeenCalledWith('Force killing remaining processes: 100, 200'); + expect(terminatePidsWithForceSpy).toHaveBeenCalledWith([100, 200]); + }); + + it('stop: cleans up orphaned main.js when no pid file exists', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(false); + findDirectMainSpy.mockResolvedValue([300, 400]); + const terminatePidsWithForceSpy = vi + .spyOn( + service as unknown as { + terminatePidsWithForce: (pids: number[], gracePeriodMs?: number) => Promise; + }, + 'terminatePidsWithForce' + ) + .mockResolvedValue(); + + await service.stop(); + + expect(logger.warn).toHaveBeenCalledWith('No nodemon pid file found.'); + expect(logger.warn).toHaveBeenCalledWith( + 'Found orphaned main.js processes: 300, 400. Terminating.' + ); + expect(terminatePidsWithForceSpy).toHaveBeenCalledWith([300, 400]); + }); + + it('stop --force: skips graceful wait', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(true); + vi.mocked(fs.readFile).mockResolvedValue('100'); + findDirectMainSpy.mockResolvedValue([]); + const waitForPidsToExitSpy = vi + .spyOn( + service as unknown as { + waitForPidsToExit: (pids: number[], timeoutMs?: number) => Promise; + }, + 'waitForPidsToExit' + ) + .mockResolvedValue([100]); + vi.spyOn( + service as unknown as { + terminatePidsWithForce: (pids: number[], gracePeriodMs?: number) => Promise; + }, + 'terminatePidsWithForce' + ).mockResolvedValue(); + + await service.stop({ force: true }); + + expect(waitForPidsToExitSpy).toHaveBeenCalledWith([100], 0); + }); + it('logs stdout when tail succeeds', async () => { const service = new NodemonService(logger); vi.mocked(execa).mockResolvedValue({ diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 598479b47b..b2fc5bed33 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -147,6 +147,8 @@ export class NodemonService { private async findDirectMainPids(): Promise { try { + // Note: ps may show relative path "node ./dist/main.js" instead of absolute path + // So we check for both patterns: the absolute path and the relative "dist/main.js" const mainPath = join(UNRAID_API_CWD, 'dist', 'main.js'); const { stdout } = await execa('ps', ['-eo', 'pid,args']); return stdout @@ -155,7 +157,7 @@ export class NodemonService { .map((line) => line.match(/^(\d+)\s+(.*)$/)) .filter((match): match is RegExpMatchArray => Boolean(match)) .map(([, pid, cmd]) => ({ pid: Number.parseInt(pid, 10), cmd })) - .filter(({ cmd }) => cmd.includes(mainPath)) + .filter(({ cmd }) => cmd.includes(mainPath) || /node.*dist\/main\.js/.test(cmd)) .map(({ pid }) => pid) .filter((pid) => Number.isInteger(pid)); } catch { @@ -193,6 +195,61 @@ export class NodemonService { this.logger.debug?.('Timed out waiting for nodemon to exit; continuing restart anyway.'); } + /** + * Wait for processes to exit, returns array of PIDs that didn't exit in time + */ + private async waitForPidsToExit(pids: number[], timeoutMs = 5000): Promise { + if (timeoutMs <= 0) return pids.filter((pid) => pid > 0); + + const deadline = Date.now() + timeoutMs; + const remaining = new Set(pids.filter((pid) => pid > 0)); + + while (remaining.size > 0 && Date.now() < deadline) { + for (const pid of remaining) { + if (!(await this.isPidRunning(pid))) { + remaining.delete(pid); + } + } + if (remaining.size > 0) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + return [...remaining]; + } + + /** + * Terminate PIDs with SIGTERM, then SIGKILL after timeout + */ + private async terminatePidsWithForce(pids: number[], gracePeriodMs = 2000): Promise { + // Send SIGTERM to all + for (const pid of pids) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // Process may have already exited + } + } + + // Wait for graceful exit + const remaining = await this.waitForPidsToExit(pids, gracePeriodMs); + + // Force kill any that didn't exit + for (const pid of remaining) { + try { + process.kill(pid, 'SIGKILL'); + this.logger.debug?.(`Sent SIGKILL to pid ${pid}`); + } catch { + // Process may have already exited + } + } + + // Brief wait for SIGKILL to take effect + if (remaining.length > 0) { + await this.waitForPidsToExit(remaining, 1000); + } + } + async start(options: StartOptions = {}) { // Log boot attempt with diagnostic info await this.logToBootFile('=== Starting unraid-api via nodemon ==='); @@ -346,23 +403,47 @@ export class NodemonService { } async stop(options: StopOptions = {}) { - const pid = await this.getStoredPid(); - if (!pid) { + const nodemonPid = await this.getStoredPid(); + + // Find child processes BEFORE sending any signals + const childPids = await this.findDirectMainPids(); + + if (!nodemonPid) { if (!options.quiet) { - this.logger.warn('No nodemon pid file found. Nothing to stop.'); + this.logger.warn('No nodemon pid file found.'); + } + // Clean up orphaned children if any exist + if (childPids.length > 0) { + this.logger.warn( + `Found orphaned main.js processes: ${childPids.join(', ')}. Terminating.` + ); + await this.terminatePidsWithForce(childPids); } return; } - const signal: NodeJS.Signals = options.force ? 'SIGKILL' : 'SIGTERM'; + // Step 1: SIGTERM to nodemon (will forward to child) try { - process.kill(pid, signal); - this.logger.trace(`Sent ${signal} to nodemon (pid ${pid})`); + process.kill(nodemonPid, 'SIGTERM'); + this.logger.trace(`Sent SIGTERM to nodemon (pid ${nodemonPid})`); } catch (error) { - this.logger.error(`Failed to stop nodemon (pid ${pid}): ${error}`); - } finally { - await rm(NODEMON_PID_PATH, { force: true }); + // Process may have already exited + this.logger.debug?.(`nodemon (pid ${nodemonPid}) already gone: ${error}`); } + + // Step 2: Wait for both nodemon and children to exit + const allPids = [nodemonPid, ...childPids]; + const gracefulTimeout = options.force ? 0 : 5000; + const remainingPids = await this.waitForPidsToExit(allPids, gracefulTimeout); + + // Step 3: Force kill any remaining processes + if (remainingPids.length > 0) { + this.logger.warn(`Force killing remaining processes: ${remainingPids.join(', ')}`); + await this.terminatePidsWithForce(remainingPids); + } + + // Step 4: Clean up PID file + await rm(NODEMON_PID_PATH, { force: true }); } async restart(options: StartOptions = {}) { @@ -372,7 +453,18 @@ export class NodemonService { async status(): Promise { const pid = await this.getStoredPid(); + + // Check for orphaned processes even without PID file + const orphanNodemonPids = await this.findMatchingNodemonPids(); + const orphanMainPids = await this.findDirectMainPids(); + if (!pid) { + if (orphanNodemonPids.length > 0 || orphanMainPids.length > 0) { + this.logger.warn( + `No PID file, but found orphaned processes: nodemon=${orphanNodemonPids.join(',') || 'none'}, main.js=${orphanMainPids.join(',') || 'none'}` + ); + return true; // Processes ARE running, just not tracked + } this.logger.info('unraid-api is not running (no pid file).'); return false; } From ca5e84f91613cacb4eeb3cb5d2711c3e045c8cb4 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 10 Dec 2025 11:07:24 -0500 Subject: [PATCH 19/25] fix: narrow api exec command --- api/nodemon.json | 2 +- api/src/environment.ts | 1 + .../unraid-api/cli/nodemon.service.integration.spec.ts | 1 + api/src/unraid-api/cli/nodemon.service.spec.ts | 1 + api/src/unraid-api/cli/nodemon.service.ts | 9 ++++----- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/api/nodemon.json b/api/nodemon.json index a97e2430a9..6a22df6b5b 100644 --- a/api/nodemon.json +++ b/api/nodemon.json @@ -7,7 +7,7 @@ "src", ".env.*" ], - "exec": "node ./dist/main.js", + "exec": "node $UNRAID_API_SERVER_ENTRYPOINT", "signal": "SIGTERM", "ext": "js,json", "restartable": "rs", diff --git a/api/src/environment.ts b/api/src/environment.ts index da29db922b..2f3da72611 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -110,6 +110,7 @@ export const NODEMON_PATH = join(UNRAID_API_ROOT, 'node_modules', 'nodemon', 'bi export const NODEMON_CONFIG_PATH = join(UNRAID_API_ROOT, 'nodemon.json'); export const NODEMON_PID_PATH = process.env.NODEMON_PID_PATH ?? '/var/run/unraid-api/nodemon.pid'; export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? UNRAID_API_ROOT; +export const UNRAID_API_SERVER_ENTRYPOINT = join(UNRAID_API_CWD, 'dist', 'main.js'); export const PATHS_CONFIG_MODULES = process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs'; diff --git a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts index 1d75ef9a2b..3491078df1 100644 --- a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts @@ -83,6 +83,7 @@ describe('NodemonService (real nodemon)', () => { PATHS_LOGS_FILE: appLogPath, PATHS_NODEMON_LOG_FILE: nodemonLogPath, UNRAID_API_CWD: workdir, + UNRAID_API_SERVER_ENTRYPOINT: join(workdir, 'app.js'), })); const { NodemonService } = await import('./nodemon.service.js'); diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 70eb856152..339bc98045 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -80,6 +80,7 @@ vi.mock('@app/environment.js', () => ({ PATHS_LOGS_FILE: '/var/log/graphql-api.log', PATHS_NODEMON_LOG_FILE: '/var/log/unraid-api/nodemon.log', UNRAID_API_CWD: '/usr/local/unraid-api', + UNRAID_API_SERVER_ENTRYPOINT: '/usr/local/unraid-api/dist/main.js', })); describe('NodemonService', () => { diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index b2fc5bed33..c2e192e9a1 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { spawn } from 'node:child_process'; import { openSync, writeSync } from 'node:fs'; import { appendFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { dirname } from 'node:path'; import { execa } from 'execa'; @@ -15,6 +15,7 @@ import { PATHS_LOGS_FILE, PATHS_NODEMON_LOG_FILE, UNRAID_API_CWD, + UNRAID_API_SERVER_ENTRYPOINT, } from '@app/environment.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; @@ -147,9 +148,6 @@ export class NodemonService { private async findDirectMainPids(): Promise { try { - // Note: ps may show relative path "node ./dist/main.js" instead of absolute path - // So we check for both patterns: the absolute path and the relative "dist/main.js" - const mainPath = join(UNRAID_API_CWD, 'dist', 'main.js'); const { stdout } = await execa('ps', ['-eo', 'pid,args']); return stdout .split('\n') @@ -157,7 +155,7 @@ export class NodemonService { .map((line) => line.match(/^(\d+)\s+(.*)$/)) .filter((match): match is RegExpMatchArray => Boolean(match)) .map(([, pid, cmd]) => ({ pid: Number.parseInt(pid, 10), cmd })) - .filter(({ cmd }) => cmd.includes(mainPath) || /node.*dist\/main\.js/.test(cmd)) + .filter(({ cmd }) => cmd.includes(UNRAID_API_SERVER_ENTRYPOINT)) .map(({ pid }) => pid) .filter((pid) => Number.isInteger(pid)); } catch { @@ -340,6 +338,7 @@ export class NodemonService { NODEMON_CONFIG_PATH, NODEMON_PID_PATH, UNRAID_API_CWD, + UNRAID_API_SERVER_ENTRYPOINT, ...overrides, } as Record; From 6d9796a9812472ee3c82fb378816b7bb2146a5e4 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 10 Dec 2025 12:21:02 -0500 Subject: [PATCH 20/25] fix: process lock --- api/package.json | 4 ++- api/src/environment.ts | 1 + .../cli/nodemon.service.integration.spec.ts | 1 + .../unraid-api/cli/nodemon.service.spec.ts | 14 +++++++-- api/src/unraid-api/cli/nodemon.service.ts | 30 +++++++++++++++++++ pnpm-lock.yaml | 26 +++++++++++++--- 6 files changed, 69 insertions(+), 7 deletions(-) diff --git a/api/package.json b/api/package.json index a09f9500a4..b40e6ec197 100644 --- a/api/package.json +++ b/api/package.json @@ -129,6 +129,7 @@ "nestjs-pino": "4.4.0", "node-cache": "5.1.2", "node-window-polyfill": "1.0.4", + "nodemon": "3.1.10", "openid-client": "6.6.4", "p-retry": "7.0.0", "passport-custom": "1.1.1", @@ -137,7 +138,7 @@ "pino": "9.9.0", "pino-http": "10.5.0", "pino-pretty": "13.1.1", - "nodemon": "3.1.10", + "proper-lockfile": "^4.1.2", "reflect-metadata": "^0.1.14", "rxjs": "7.8.2", "semver": "7.7.2", @@ -188,6 +189,7 @@ "@types/mustache": "4.2.6", "@types/node": "22.18.0", "@types/pify": "6.1.0", + "@types/proper-lockfile": "^4.1.4", "@types/semver": "7.7.0", "@types/sendmail": "1.4.7", "@types/stoppable": "1.1.3", diff --git a/api/src/environment.ts b/api/src/environment.ts index 2f3da72611..be2c63cfb9 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -109,6 +109,7 @@ export const PATHS_NODEMON_LOG_FILE = export const NODEMON_PATH = join(UNRAID_API_ROOT, 'node_modules', 'nodemon', 'bin', 'nodemon.js'); export const NODEMON_CONFIG_PATH = join(UNRAID_API_ROOT, 'nodemon.json'); export const NODEMON_PID_PATH = process.env.NODEMON_PID_PATH ?? '/var/run/unraid-api/nodemon.pid'; +export const NODEMON_LOCK_PATH = process.env.NODEMON_LOCK_PATH ?? '/var/run/unraid-api/nodemon.lock'; export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? UNRAID_API_ROOT; export const UNRAID_API_SERVER_ENTRYPOINT = join(UNRAID_API_CWD, 'dist', 'main.js'); diff --git a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts index 3491078df1..bb649f2b2f 100644 --- a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts @@ -77,6 +77,7 @@ describe('NodemonService (real nodemon)', () => { SUPPRESS_LOGS: false, API_VERSION: 'test-version', NODEMON_CONFIG_PATH: configPath, + NODEMON_LOCK_PATH: join(workdir, 'nodemon.lock'), NODEMON_PATH: nodemonPath, NODEMON_PID_PATH: pidPath, PATHS_LOGS_DIR: workdir, diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 339bc98045..fdf52632b0 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -66,6 +66,9 @@ vi.mock('node:fs/promises', async (importOriginal) => { }; }); vi.mock('execa', () => ({ execa: vi.fn() })); +vi.mock('proper-lockfile', () => ({ + lock: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(undefined)), +})); vi.mock('@app/core/utils/files/file-exists.js', () => ({ fileExists: vi.fn().mockResolvedValue(false), fileExistsSync: vi.fn().mockReturnValue(true), @@ -74,6 +77,7 @@ vi.mock('@app/environment.js', () => ({ LOG_LEVEL: 'INFO', SUPPRESS_LOGS: false, NODEMON_CONFIG_PATH: '/etc/unraid-api/nodemon.json', + NODEMON_LOCK_PATH: '/var/run/unraid-api/nodemon.lock', NODEMON_PATH: '/usr/bin/nodemon', NODEMON_PID_PATH: '/var/run/unraid-api/nodemon.pid', PATHS_LOGS_DIR: '/var/log/unraid-api', @@ -196,7 +200,10 @@ describe('NodemonService', () => { }); await expect(service.start()).rejects.toThrow('Failed to start nodemon: Command not found'); - expect(mockWriteFile).not.toHaveBeenCalled(); + expect(mockWriteFile).not.toHaveBeenCalledWith( + '/var/run/unraid-api/nodemon.pid', + expect.anything() + ); expect(logger.info).not.toHaveBeenCalled(); }); @@ -221,7 +228,10 @@ describe('NodemonService', () => { await expect(service.start()).rejects.toThrow( 'Failed to start nodemon: process spawned but no PID was assigned' ); - expect(mockWriteFile).not.toHaveBeenCalled(); + expect(mockWriteFile).not.toHaveBeenCalledWith( + '/var/run/unraid-api/nodemon.pid', + expect.anything() + ); expect(logger.info).not.toHaveBeenCalled(); }); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index c2e192e9a1..89554a373d 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -5,10 +5,12 @@ import { appendFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { execa } from 'execa'; +import { lock } from 'proper-lockfile'; import { fileExists, fileExistsSync } from '@app/core/utils/files/file-exists.js'; import { NODEMON_CONFIG_PATH, + NODEMON_LOCK_PATH, NODEMON_PATH, NODEMON_PID_PATH, PATHS_LOGS_DIR, @@ -19,6 +21,8 @@ import { } from '@app/environment.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; +const LOCK_TIMEOUT_SECONDS = 30; + type StartOptions = { env?: Record; }; @@ -67,6 +71,28 @@ export class NodemonService { await mkdir(dirname(PATHS_LOGS_FILE), { recursive: true }); await mkdir(dirname(PATHS_NODEMON_LOG_FILE), { recursive: true }); await mkdir(dirname(NODEMON_PID_PATH), { recursive: true }); + await mkdir(dirname(NODEMON_LOCK_PATH), { recursive: true }); + await writeFile(NODEMON_LOCK_PATH, '', { flag: 'a' }); + } + + private async withLock(fn: () => Promise): Promise { + let release: (() => Promise) | null = null; + try { + release = await lock(NODEMON_LOCK_PATH, { + stale: LOCK_TIMEOUT_SECONDS * 1000, + retries: { + retries: Math.floor(LOCK_TIMEOUT_SECONDS * 10), + factor: 1, + minTimeout: 100, + maxTimeout: 100, + }, + }); + return await fn(); + } finally { + if (release) { + await release().catch(() => {}); + } + } } private async stopPm2IfRunning() { @@ -280,6 +306,10 @@ export class NodemonService { throw error; } + await this.withLock(() => this.startInternal(options)); + } + + private async startInternal(options: StartOptions = {}) { await this.stopPm2IfRunning(); await this.logToBootFile('PM2 cleanup complete'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9a59a9b9c..7742208b4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -289,6 +289,9 @@ importers: pino-pretty: specifier: 13.1.1 version: 13.1.1 + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 reflect-metadata: specifier: ^0.1.14 version: 0.1.14 @@ -413,6 +416,9 @@ importers: '@types/pify': specifier: 6.1.0 version: 6.1.0 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 '@types/semver': specifier: 7.7.0 version: 7.7.0 @@ -4912,6 +4918,9 @@ packages: resolution: {integrity: sha512-HCVIdzNiVAi7OxWTAZagTBNzylgNhImtx442pMcu8QZHzDHElS3ccgqaYIuHskpaeG7rIbYlN5XP5tcOAf8F2w==} deprecated: This is a stub types definition. pify provides its own type definitions, so you do not need this installed. + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/qs@6.9.18': resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==} @@ -4924,6 +4933,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/semver@7.7.0': resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==} @@ -12187,8 +12199,8 @@ packages: vue-component-type-helpers@3.0.6: resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==} - vue-component-type-helpers@3.1.4: - resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==} + vue-component-type-helpers@3.1.8: + resolution: {integrity: sha512-oaowlmEM6BaYY+8o+9D9cuzxpWQWHqHTMKakMxXu0E+UCIOMTljyIPO15jcnaCwJtZu/zWDotK7mOIHvWD9mcw==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -16203,7 +16215,7 @@ snapshots: storybook: 9.1.3(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)) type-fest: 2.19.0 vue: 3.5.20(typescript@5.9.2) - vue-component-type-helpers: 3.1.4 + vue-component-type-helpers: 3.1.8 '@swc/core-darwin-arm64@1.13.5': optional: true @@ -16623,6 +16635,10 @@ snapshots: dependencies: pify: 3.0.0 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + '@types/qs@6.9.18': {} '@types/range-parser@1.2.7': {} @@ -16633,6 +16649,8 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/retry@0.12.5': {} + '@types/semver@7.7.0': {} '@types/send@0.17.4': @@ -24752,7 +24770,7 @@ snapshots: vue-component-type-helpers@3.0.6: {} - vue-component-type-helpers@3.1.4: {} + vue-component-type-helpers@3.1.8: {} vue-demi@0.14.10(vue@3.5.20(typescript@5.9.2)): dependencies: From f0cfdfc0b5d392221724d85e275df40ce8e6ee8b Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Thu, 11 Dec 2025 12:23:32 -0500 Subject: [PATCH 21/25] fix: ignore errors from /var/log/.pm2 removal --- api/src/unraid-api/cli/nodemon.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 89554a373d..a01f82fa5c 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -136,7 +136,11 @@ export class NodemonService { } catch { // ignore } - await rm('/var/log/.pm2', { recursive: true, force: true }); + try { + await rm('/var/log/.pm2', { recursive: true, force: true }); + } catch { + // Ignore errors when removing pm2 state - shouldn't block API startup + } } private async getStoredPid(): Promise { From 7c0d42a5cb94326b206c7077286f7d323a5530ae Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 9 Dec 2025 11:17:14 -0500 Subject: [PATCH 22/25] test: unraid-api daemonization --- tests/integration/run-singleton-tests.sh | 156 +++++++++++ tests/integration/singleton.bats | 317 +++++++++++++++++++++++ tests/integration/test_helper.bash | 246 ++++++++++++++++++ 3 files changed, 719 insertions(+) create mode 100755 tests/integration/run-singleton-tests.sh create mode 100755 tests/integration/singleton.bats create mode 100644 tests/integration/test_helper.bash diff --git a/tests/integration/run-singleton-tests.sh b/tests/integration/run-singleton-tests.sh new file mode 100755 index 0000000000..39cefd9aae --- /dev/null +++ b/tests/integration/run-singleton-tests.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# +# run-singleton-tests.sh - Run singleton process management tests against a remote Unraid server +# +# Usage: ./run-singleton-tests.sh +# +# Arguments: +# server_name SSH server name or IP address (required) +# +# Requirements: +# - BATS (Bash Automated Testing System) installed +# - SSH access to the target server as root +# - unraid-api already deployed on the target server +# +# See: https://bats-core.readthedocs.io/en/stable/ + +set -euo pipefail + +# Colors for output +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +print_error() { + echo -e "${RED}Error: $1${NC}" >&2 +} + +print_success() { + echo -e "${GREEN}$1${NC}" +} + +print_info() { + echo "$1" +} + +usage() { + cat << EOF +Usage: $(basename "$0") + +Run singleton process management tests against a remote Unraid server. + +Arguments: + server_name SSH server name or IP address (required) + +Options: + -h, --help Show this help message + +Examples: + $(basename "$0") tower + $(basename "$0") 192.168.1.100 + $(basename "$0") unraid.local + +Requirements: + - BATS (Bash Automated Testing System) installed + Install: brew install bats-core (macOS) or apt install bats (Ubuntu) + - SSH access to the target server as root + - unraid-api already deployed on the target server +EOF +} + +check_bats() { + if ! command -v bats &> /dev/null; then + print_error "BATS is not installed." + echo "" + echo "Install BATS using one of these methods:" + echo " macOS: brew install bats-core" + echo " Ubuntu: apt-get install bats" + echo " npm: npm install -g bats" + echo "" + echo "Or visit: https://github.com/bats-core/bats-core" + exit 1 + fi + + print_success "BATS is installed: $(bats --version)" +} + +check_ssh() { + local server="$1" + + print_info "Checking SSH connectivity to ${server}..." + + if ! ssh -o ConnectTimeout=10 -o BatchMode=yes "root@${server}" 'echo "SSH OK"' &> /dev/null; then + print_error "Cannot connect to ${server} via SSH." + echo "" + echo "Please ensure:" + echo " 1. The server name/IP is correct" + echo " 2. SSH is running on the server" + echo " 3. You have SSH key access as root" + echo "" + echo "Test manually with: ssh root@${server}" + exit 1 + fi + + print_success "SSH connection successful" +} + +check_unraid_api() { + local server="$1" + + print_info "Checking if unraid-api is installed on ${server}..." + + if ! ssh -o ConnectTimeout=10 "root@${server}" 'command -v unraid-api' &> /dev/null; then + print_error "unraid-api is not installed on ${server}." + echo "" + echo "Please deploy unraid-api first using:" + echo " cd api && ./scripts/deploy-dev.sh ${server}" + exit 1 + fi + + print_success "unraid-api is installed" +} + +main() { + # Parse arguments + if [[ $# -eq 0 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then + usage + exit 0 + fi + + local server="$1" + + echo "" + echo "========================================" + echo "Unraid API Singleton Process Tests" + echo "========================================" + echo "" + echo "Target server: ${server}" + echo "" + + # Pre-flight checks + check_bats + check_ssh "$server" + check_unraid_api "$server" + + echo "" + print_info "Running tests..." + echo "" + + # Run BATS tests with SERVER environment variable + export SERVER="$server" + + if bats "${SCRIPT_DIR}/singleton.bats"; then + echo "" + print_success "All tests passed!" + exit 0 + else + echo "" + print_error "Some tests failed." + exit 1 + fi +} + +main "$@" diff --git a/tests/integration/singleton.bats b/tests/integration/singleton.bats new file mode 100755 index 0000000000..d41eb39ffd --- /dev/null +++ b/tests/integration/singleton.bats @@ -0,0 +1,317 @@ +#!/usr/bin/env bats +# singleton.bats - Tests for unraid-api singleton process management +# +# Usage: SERVER= bats singleton.bats +# +# These tests verify that the unraid-api is properly daemonized as a singleton process. +# See: https://bats-core.readthedocs.io/en/stable/writing-tests.html + +# ----------------------------------------------------------------------------- +# Test Setup +# ----------------------------------------------------------------------------- + +# setup_file runs once before all tests in this file +# Load helpers here since they're needed for all tests +# See: https://bats-core.readthedocs.io/en/stable/writing-tests.html#setup-and-teardown-pre-and-post-test-hooks +setup_file() { + load 'test_helper' +} + +# Setup runs before each test - ensure clean state +setup() { + load 'test_helper' + cleanup +} + +# Teardown runs after each test - clean up +teardown() { + cleanup +} + +# ----------------------------------------------------------------------------- +# Start Command Tests +# ----------------------------------------------------------------------------- + +@test "start: creates a single process with PID file" { + # Start the API + run start_api + [ "$status" -eq 0 ] + + # Verify PID file exists + run pid_file_exists + [ "$status" -eq 0 ] + + # Verify PID is valid (non-empty and numeric) + pid=$(get_remote_pid) + [ -n "$pid" ] + [[ "$pid" =~ ^[0-9]+$ ]] + + # Verify process is running + run is_process_running "$pid" + [ "$status" -eq 0 ] + + # Verify exactly ONE nodemon AND ONE main.js + run assert_single_api_instance + [ "$status" -eq 0 ] +} + +@test "start: second start does not create duplicate process" { + # Start the API first + run start_api + [ "$status" -eq 0 ] + + # Get the initial PID + initial_pid=$(get_remote_pid) + [ -n "$initial_pid" ] + + # Verify single instance initially + run assert_single_api_instance + [ "$status" -eq 0 ] + + # Try to start again + run remote_exec "unraid-api start" + # Should succeed (either no-op or restart) + + # Wait a moment for any process changes + sleep 2 + + # Verify still exactly one nodemon AND one main.js (singleton enforcement) + run assert_single_api_instance + [ "$status" -eq 0 ] + + # Verify process is still running (either same or new PID after restart) + run pid_file_exists + [ "$status" -eq 0 ] + + final_pid=$(get_remote_pid) + [ -n "$final_pid" ] + + run is_process_running "$final_pid" + [ "$status" -eq 0 ] +} + +@test "start: cleans up stale PID file" { + # Create a stale PID file with a non-existent PID + run remote_exec "mkdir -p /var/run/unraid-api && echo '99999' > '${REMOTE_PID_PATH}'" + [ "$status" -eq 0 ] + + # Start should clean up and proceed + run start_api + [ "$status" -eq 0 ] + + # Verify new valid PID (not the stale one) + pid=$(get_remote_pid) + [ -n "$pid" ] + [ "$pid" != "99999" ] + + # Verify process is actually running + run is_process_running "$pid" + [ "$status" -eq 0 ] +} + +@test "start: cleans up orphaned nodemon process" { + # Start API normally + run start_api + [ "$status" -eq 0 ] + + # Remove PID file but leave process running (simulate orphan) + run remote_exec "rm -f '${REMOTE_PID_PATH}'" + [ "$status" -eq 0 ] + + # Verify orphaned process still running + count=$(count_nodemon_processes) + [ "$count" -eq 1 ] + + # Start should detect orphan and clean it up + run start_api + [ "$status" -eq 0 ] + + # Should still have exactly one process + count=$(count_nodemon_processes) + [ "$count" -eq 1 ] + + # PID file should exist again + run pid_file_exists + [ "$status" -eq 0 ] +} + +# ----------------------------------------------------------------------------- +# Status Command Tests +# ----------------------------------------------------------------------------- + +@test "status: reports running when API is active" { + # Start the API + run start_api + [ "$status" -eq 0 ] + + # Check status - should contain "running" + output=$(get_status) + [[ "$output" == *"running"* ]] +} + +@test "status: reports not running when API is stopped" { + # Ensure API is stopped (cleanup already called in setup) + + # Check status - should indicate not running + output=$(get_status) + [[ "$output" == *"not running"* ]] +} + +# ----------------------------------------------------------------------------- +# Stop Command Tests +# ----------------------------------------------------------------------------- + +@test "stop: cleanly terminates all processes" { + # Start the API first + run start_api + [ "$status" -eq 0 ] + + # Verify it's running + pid=$(get_remote_pid) + [ -n "$pid" ] + + # Verify single instance before stop + run assert_single_api_instance + [ "$status" -eq 0 ] + + # Stop the API + run stop_api + [ "$status" -eq 0 ] + + # Verify PID file is removed + run pid_file_exists + [ "$status" -ne 0 ] + + # Verify NO nodemon AND NO main.js processes remain + run assert_no_api_processes + [ "$status" -eq 0 ] +} + +@test "stop --force: terminates all processes immediately" { + # Start the API + run start_api + [ "$status" -eq 0 ] + + # Get the PID + pid=$(get_remote_pid) + [ -n "$pid" ] + + # Verify single instance before stop + run assert_single_api_instance + [ "$status" -eq 0 ] + + # Force stop + run stop_api --force + [ "$status" -eq 0 ] + + # Verify PID file is removed + run pid_file_exists + [ "$status" -ne 0 ] + + # Verify NO processes remain (nodemon AND main.js) + run assert_no_api_processes + [ "$status" -eq 0 ] +} + +# ----------------------------------------------------------------------------- +# Restart Command Tests +# ----------------------------------------------------------------------------- + +@test "restart: creates new process when already running" { + # Start the API + run start_api + [ "$status" -eq 0 ] + + # Get the initial PID + initial_pid=$(get_remote_pid) + [ -n "$initial_pid" ] + + # Verify single instance initially + run assert_single_api_instance + [ "$status" -eq 0 ] + + # Restart the API + run remote_exec "unraid-api restart" + [ "$status" -eq 0 ] + + # Wait for restart to complete + sleep 3 + run wait_for_start 10 + [ "$status" -eq 0 ] + + # Get new PID + new_pid=$(get_remote_pid) + [ -n "$new_pid" ] + + # PIDs should be different (process was actually restarted) + [ "$initial_pid" != "$new_pid" ] + + # Verify exactly one nodemon AND one main.js after restart + run assert_single_api_instance + [ "$status" -eq 0 ] +} + +@test "restart: works when API is not running" { + # Ensure API is stopped (cleanup already called in setup) + + # Restart should start the API + run remote_exec "unraid-api restart" + [ "$status" -eq 0 ] + + # Wait for start + run wait_for_start 10 + [ "$status" -eq 0 ] + + # Verify process is running + pid=$(get_remote_pid) + [ -n "$pid" ] + + run is_process_running "$pid" + [ "$status" -eq 0 ] +} + +# ----------------------------------------------------------------------------- +# Edge Case Tests +# ----------------------------------------------------------------------------- + +@test "concurrent starts: result in single process" { + # Launch multiple starts concurrently + run remote_exec "unraid-api start & unraid-api start & wait" + + # Wait for things to settle + sleep 3 + + # Must have exactly one nodemon AND one main.js, not just "one nodemon" + run assert_single_api_instance + [ "$status" -eq 0 ] + + # PID file should exist + run pid_file_exists + [ "$status" -eq 0 ] +} + +@test "recovery: API recovers after process is killed externally" { + # Start the API + run start_api + [ "$status" -eq 0 ] + + pid=$(get_remote_pid) + [ -n "$pid" ] + + # Kill the process directly (simulate crash) + run remote_exec "kill -9 '$pid'" + + # Wait for process to die + sleep 1 + + # Start should recover + run start_api + [ "$status" -eq 0 ] + + # Verify new process running + new_pid=$(get_remote_pid) + [ -n "$new_pid" ] + + run is_process_running "$new_pid" + [ "$status" -eq 0 ] +} diff --git a/tests/integration/test_helper.bash b/tests/integration/test_helper.bash new file mode 100644 index 0000000000..a423bd2d4a --- /dev/null +++ b/tests/integration/test_helper.bash @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +# test_helper.bash - Shared helper functions for BATS integration tests +# +# This file is loaded by singleton.bats using BATS' load command. +# See: https://bats-core.readthedocs.io/en/stable/writing-tests.html + +# Remote server (must be set via environment variable) +: "${SERVER:?SERVER environment variable must be set}" + +# Remote paths - exported so they're available in test functions +export REMOTE_PID_PATH="/var/run/unraid-api/nodemon.pid" + +# Timeouts (seconds) +export DEFAULT_TIMEOUT=10 + +# ----------------------------------------------------------------------------- +# SSH Execution Helpers +# ----------------------------------------------------------------------------- + +# Execute a command on the remote server +remote_exec() { + ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${SERVER}" "$@" +} + +# Execute a command on the remote server, ignoring failures (for cleanup) +remote_exec_safe() { + ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${SERVER}" "$@" 2>/dev/null || true +} + +# ----------------------------------------------------------------------------- +# Process Query Helpers +# ----------------------------------------------------------------------------- + +# Get the PID from the remote PID file, returns empty if not found +get_remote_pid() { + remote_exec "cat '${REMOTE_PID_PATH}' 2>/dev/null || true" | tr -d '[:space:]' +} + +# Check if the PID file exists on the remote server (returns 0 if exists, 1 if not) +pid_file_exists() { + remote_exec "test -f '${REMOTE_PID_PATH}'" 2>/dev/null +} + +# Check if a process is running on the remote server +is_process_running() { + local pid="$1" + [[ -n "$pid" ]] && remote_exec "kill -0 '${pid}' 2>/dev/null" +} + +# Count nodemon processes matching our config on remote server +count_nodemon_processes() { + local result + result=$(remote_exec "ps -eo pid,args 2>/dev/null | grep -E 'nodemon.*nodemon.json' | grep -v grep | wc -l" 2>/dev/null || echo "0") + echo "${result}" | tr -d '[:space:]' +} + +# Count main.js worker processes (children of nodemon) +# Note: ps shows relative path "node ./dist/main.js" not full path +count_main_processes() { + local result + result=$(remote_exec "ps -eo args 2>/dev/null | grep -E 'node.*dist/main\.js' | grep -v grep | wc -l" 2>/dev/null || echo "0") + echo "${result}" | tr -d '[:space:]' +} + +# Count all unraid-api related processes (nodemon + main.js) +count_unraid_api_processes() { + local nodemon_count main_count + nodemon_count=$(count_nodemon_processes) + main_count=$(count_main_processes) + echo $((nodemon_count + main_count)) +} + +# Assert exactly one nodemon and one main.js process +assert_single_api_instance() { + local nodemon_count main_count + nodemon_count=$(count_nodemon_processes) + main_count=$(count_main_processes) + + if [[ "$nodemon_count" -ne 1 ]]; then + echo "Expected 1 nodemon process, found $nodemon_count" >&2 + remote_exec "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" >&2 + return 1 + fi + + if [[ "$main_count" -ne 1 ]]; then + echo "Expected 1 main.js process, found $main_count" >&2 + remote_exec "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" >&2 + return 1 + fi + + return 0 +} + +# Assert no API processes running +assert_no_api_processes() { + local nodemon_count main_count + nodemon_count=$(count_nodemon_processes) + main_count=$(count_main_processes) + + if [[ "$nodemon_count" -ne 0 ]] || [[ "$main_count" -ne 0 ]]; then + echo "Expected 0 processes, found nodemon=$nodemon_count main.js=$main_count" >&2 + remote_exec "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" >&2 + return 1 + fi + + return 0 +} + +# ----------------------------------------------------------------------------- +# Wait Helpers +# ----------------------------------------------------------------------------- + +# Wait for a process to start (PID file to exist and process running) +wait_for_start() { + local timeout="${1:-$DEFAULT_TIMEOUT}" + local deadline=$((SECONDS + timeout)) + + while [[ $SECONDS -lt $deadline ]]; do + local pid + pid=$(get_remote_pid) + if [[ -n "$pid" ]] && is_process_running "$pid"; then + return 0 + fi + sleep 1 + done + + return 1 +} + +# Wait for a process to stop (PID file removed or process not running) +wait_for_stop() { + local timeout="${1:-$DEFAULT_TIMEOUT}" + local deadline=$((SECONDS + timeout)) + + while [[ $SECONDS -lt $deadline ]]; do + local pid + pid=$(get_remote_pid) + if [[ -z "$pid" ]]; then + return 0 + fi + if ! is_process_running "$pid"; then + return 0 + fi + sleep 1 + done + + return 1 +} + +# Wait for all unraid-api processes to stop +wait_for_all_processes_stop() { + local timeout="${1:-$DEFAULT_TIMEOUT}" + local deadline=$((SECONDS + timeout)) + + while [[ $SECONDS -lt $deadline ]]; do + local count + count=$(count_unraid_api_processes) + if [[ "$count" -eq 0 ]]; then + return 0 + fi + sleep 1 + done + + return 1 +} + +# ----------------------------------------------------------------------------- +# API Lifecycle Helpers +# ----------------------------------------------------------------------------- + +# Clean up: stop any running unraid-api processes +cleanup() { + # Step 1: Try graceful stop via unraid-api + remote_exec_safe "unraid-api stop 2>/dev/null; true" + sleep 1 + + # Step 2: Check if processes remain + local count + count=$(count_unraid_api_processes) + if [[ "$count" -eq 0 ]]; then + remote_exec_safe "rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true" + return 0 + fi + + # Step 3: Force kill - nodemon FIRST (prevents restart of child) + remote_exec_safe "pkill -KILL -f 'nodemon.*nodemon.json' 2>/dev/null; true" + sleep 0.5 + + # Step 4: Force kill - then main.js children + # Note: Use simpler pattern since ps shows relative path "./dist/main.js" + remote_exec_safe "pkill -KILL -f 'node.*dist/main.js' 2>/dev/null; true" + sleep 1 + + # Step 5: Clean up PID file + remote_exec_safe "rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true" + + # Step 6: Verify - if still running, try harder with explicit PIDs + count=$(count_unraid_api_processes) + if [[ "$count" -ne 0 ]]; then + # Get specific PIDs and kill them directly + local pids + pids=$(remote_exec_safe "ps -eo pid,args | grep -E 'nodemon.*nodemon.json|node.*dist/main.js' | grep -v grep | awk '{print \$1}'" 2>/dev/null || true) + for pid in $pids; do + remote_exec_safe "kill -9 $pid 2>/dev/null; true" + done + sleep 1 + fi + + # Final check + count=$(count_unraid_api_processes) + if [[ "$count" -ne 0 ]]; then + echo "WARNING: Cleanup incomplete, remaining processes:" >&2 + remote_exec_safe "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" >&2 + fi + + return 0 +} + +# Start the API and wait for it to be ready +start_api() { + remote_exec "unraid-api start" + wait_for_start +} + +# Stop the API using unraid-api stop command +# NOTE: This does NOT force kill remaining processes - tests should verify +# that unraid-api stop properly cleans up. Use cleanup() for test isolation. +stop_api() { + local force="${1:-}" + if [[ "$force" == "--force" ]]; then + remote_exec "unraid-api stop --force" + else + remote_exec "unraid-api stop" + fi + + # Wait for PID file to be removed + wait_for_stop + + # Wait for processes to exit (but don't force kill - let test verify) + wait_for_all_processes_stop 10 +} + +# Get status output from remote +get_status() { + remote_exec "unraid-api status 2>&1" || true +} From 22bb5488338323e6e9de78a24a9f570a1263c6e2 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 10 Dec 2025 14:04:18 -0500 Subject: [PATCH 23/25] test: add bats & equivalent vitest system tests setup --- .gitignore | 4 + CLAUDE.md | 3 + package.json | 3 + pnpm-lock.yaml | 50 ++++ pnpm-workspace.yaml | 2 + tests/bats/README.md | 115 ++++++++ tests/bats/examples/example.bats | 154 ++++++++++ .../integration/singleton_daemon.bats} | 130 +++++---- tests/bats/package.json | 16 ++ tests/bats/setup.sh | 24 ++ tests/bats/test_helper/common.bash | 8 + tests/bats/test_helper/ssh.bash | 21 ++ .../test_helper/unraid-api.bash} | 39 +-- tests/integration/run-singleton-tests.sh | 156 ---------- tests/system-integration/README.md | 214 ++++++++++++++ tests/system-integration/package.json | 16 ++ .../src/helpers/api-lifecycle.ts | 271 ++++++++++++++++++ .../system-integration/src/helpers/process.ts | 203 +++++++++++++ tests/system-integration/src/helpers/ssh.ts | 127 ++++++++ .../src/tests/singleton-daemon.test.ts | 211 ++++++++++++++ tests/system-integration/tsconfig.json | 18 ++ tests/system-integration/vitest.config.ts | 19 ++ 22 files changed, 1557 insertions(+), 247 deletions(-) create mode 100644 tests/bats/README.md create mode 100644 tests/bats/examples/example.bats rename tests/{integration/singleton.bats => bats/integration/singleton_daemon.bats} (79%) mode change 100755 => 100644 create mode 100644 tests/bats/package.json create mode 100755 tests/bats/setup.sh create mode 100644 tests/bats/test_helper/common.bash create mode 100644 tests/bats/test_helper/ssh.bash rename tests/{integration/test_helper.bash => bats/test_helper/unraid-api.bash} (83%) delete mode 100755 tests/integration/run-singleton-tests.sh create mode 100644 tests/system-integration/README.md create mode 100644 tests/system-integration/package.json create mode 100644 tests/system-integration/src/helpers/api-lifecycle.ts create mode 100644 tests/system-integration/src/helpers/process.ts create mode 100644 tests/system-integration/src/helpers/ssh.ts create mode 100644 tests/system-integration/src/tests/singleton-daemon.test.ts create mode 100644 tests/system-integration/tsconfig.json create mode 100644 tests/system-integration/vitest.config.ts diff --git a/.gitignore b/.gitignore index 8449fad731..4ab0014236 100644 --- a/.gitignore +++ b/.gitignore @@ -120,6 +120,10 @@ api/dev/Unraid.net/myservers.cfg # Claude local settings .claude/settings.local.json +# BATS library symlinks (created by tests/bats/setup.sh) +tests/bats/test_helper/bats-support +tests/bats/test_helper/bats-assert + # local Mise settings .mise.toml diff --git a/CLAUDE.md b/CLAUDE.md index 640f920e65..c2a4b81f66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,9 @@ This is the Unraid API monorepo containing multiple packages that provide API fu ## Essential Commands +pnpm does not use `--` to pass additional arguments. +For example, to target a specific test file, `pnpm test ` is sufficient. + ### Development ```bash diff --git a/package.json b/package.json index 9cd390f0a2..4f6dfd26b9 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "dev": "pnpm -r dev", "unraid:deploy": "pnpm -r unraid:deploy", "test": "pnpm -r test", + "test:bats": "pnpm --filter @unraid/bats-tests test", + "test:bats:integration": "pnpm --filter @unraid/bats-tests test:integration", + "test:system": "pnpm --filter @unraid/system-integration-tests test", "test:watch": "pnpm -r --parallel test:watch", "lint": "pnpm -r lint", "lint:fix": "pnpm -r lint:fix", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7742208b4c..038491027f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -853,6 +853,30 @@ importers: specifier: 3.2.4 version: 3.2.4(@types/node@22.18.0)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) + tests/bats: + devDependencies: + bats: + specifier: ^1.13.0 + version: 1.13.0 + bats-assert: + specifier: ^2.2.4 + version: 2.2.4(bats-support@0.3.0(bats@1.13.0))(bats@1.13.0) + bats-support: + specifier: ^0.3.0 + version: 0.3.0(bats@1.13.0) + + tests/system-integration: + devDependencies: + execa: + specifier: ^9.6.0 + version: 9.6.0 + typescript: + specifier: ^5.9.2 + version: 5.9.2 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.18.0)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) + unraid-ui: dependencies: '@headlessui/vue': @@ -6026,6 +6050,21 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + bats-assert@2.2.4: + resolution: {integrity: sha512-EcaY4Z+Tbz1c7pnC1SrVSq0epr7tLwFpz6qt7KUW9K8uSw8V12DTfH9d2HxZWvBEATaCuMsZ7KoZMFiSQPRoXw==} + peerDependencies: + bats: 0.4 || ^1 + bats-support: ^0.3 + + bats-support@0.3.0: + resolution: {integrity: sha512-z+2WzXbI4OZgLnynydqH8GpI3+DcOtepO66PlK47SfEzTkiuV9hxn9eIQX+uLVFbt2Oqoc7Ky3TJ/N83lqD+cg==} + peerDependencies: + bats: 0.4 || ^1 + + bats@1.13.0: + resolution: {integrity: sha512-giSYKGTOcPZyJDbfbTtzAedLcNWdjCLbXYU3/MwPnjyvDXzu6Dgw8d2M+8jHhZXSmsCMSQqCp+YBsJ603UO4vQ==} + hasBin: true + bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} @@ -17904,6 +17943,17 @@ snapshots: dependencies: safe-buffer: 5.1.2 + bats-assert@2.2.4(bats-support@0.3.0(bats@1.13.0))(bats@1.13.0): + dependencies: + bats: 1.13.0 + bats-support: 0.3.0(bats@1.13.0) + + bats-support@0.3.0(bats@1.13.0): + dependencies: + bats: 1.13.0 + + bats@1.13.0: {} + bcrypt-pbkdf@1.0.2: dependencies: tweetnacl: 0.14.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c032cc1e14..4197de546b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,5 @@ packages: - "./unraid-ui" - "./web" - "./packages/*" + - "./tests/bats" + - "./tests/system-integration" diff --git a/tests/bats/README.md b/tests/bats/README.md new file mode 100644 index 0000000000..8a870e85ab --- /dev/null +++ b/tests/bats/README.md @@ -0,0 +1,115 @@ +# BATS Integration Tests + +This directory contains BATS (Bash Automated Testing System) integration tests for the unraid-api. + +## Prerequisites + +- BATS installed (via `pnpm install`) +- SSH access to target Unraid server as root +- unraid-api deployed on target server + +## Running Tests + +```bash +# Run all tests +SERVER=tower pnpm test:bats + +# Run integration tests only +SERVER=tower pnpm test:bats:integration + +# Run a specific test file +SERVER=tower pnpm exec bats tests/bats/integration/singleton.bats + +# Run tests matching a pattern +SERVER=tower pnpm exec bats tests/bats/ --filter "start:" +``` + +## Adding New Tests + +1. Create a new `.bats` file in the appropriate directory: + - `integration/` - Tests requiring remote server + +2. Load the common helpers in setup: + ```bash + setup_file() { + load '../test_helper/common' + } + + setup() { + load '../test_helper/common' + # your per-test setup + } + ``` + +3. Write tests using bats-assert functions: + ```bash + @test "example test" { + run my_command + assert_success + assert_output --partial "expected text" + } + ``` + +## Available Assertions (bats-assert) + +| Assertion | Description | +|-----------|-------------| +| `assert_success` | Command exited with status 0 | +| `assert_failure` | Command exited with non-zero status | +| `assert_output "text"` | Check exact output | +| `assert_output --partial "text"` | Check output contains text | +| `assert_output --regexp "pattern"` | Check output matches regex | +| `assert_line "text"` | Check specific line in output | +| `assert_line --index 0 "text"` | Check line at index | +| `refute_output "text"` | Assert output does NOT contain text | +| `assert_regex "$var" "pattern"` | Assert variable matches regex | + +## Helper Functions (common.bash) + +### SSH Execution +| Function | Description | +|----------|-------------| +| `remote_exec ` | Execute command on remote server | +| `remote_exec_safe ` | Execute, ignoring failures | + +### Process Management +| Function | Description | +|----------|-------------| +| `start_api` | Start the API and wait for ready | +| `stop_api [--force]` | Stop the API | +| `cleanup` | Kill all API processes | +| `get_remote_pid` | Get PID from remote PID file | +| `pid_file_exists` | Check if PID file exists | +| `is_process_running ` | Check if process is running | + +### Assertions +| Function | Description | +|----------|-------------| +| `assert_single_api_instance` | Verify exactly one API running | +| `assert_no_api_processes` | Verify no API processes | + +### Wait Helpers +| Function | Description | +|----------|-------------| +| `wait_for_start [timeout]` | Wait for API to start | +| `wait_for_stop [timeout]` | Wait for API to stop | + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `SERVER` | Yes | SSH server name or IP address | +| `DEFAULT_TIMEOUT` | No | Test timeout in seconds (default: 10) | + +## Directory Structure + +``` +tests/bats/ +├── test_helper/ +│ ├── common.bash # Shared helper functions +│ ├── bats-support/ # Symlink to node_modules +│ └── bats-assert/ # Symlink to node_modules +├── integration/ +│ └── singleton.bats # Singleton process tests +└── README.md # This file +``` diff --git a/tests/bats/examples/example.bats b/tests/bats/examples/example.bats new file mode 100644 index 0000000000..3c1b5f30af --- /dev/null +++ b/tests/bats/examples/example.bats @@ -0,0 +1,154 @@ +#!/usr/bin/env bats +# example.bats - Example test file demonstrating bats-assert and bats-support +# +# Run with: pnpm test:bats +# Or directly: bats tests/bats/examples/example.bats +# +# This file demonstrates common BATS patterns and assertions. +# Delete or modify this file as needed. + +# ----------------------------------------------------------------------------- +# Setup +# ----------------------------------------------------------------------------- + +setup() { + # Load assertion libraries + load '../test_helper/bats-support/load' + load '../test_helper/bats-assert/load' +} + +# ----------------------------------------------------------------------------- +# Basic Assertions +# ----------------------------------------------------------------------------- + +@test "assert_success: command exits with status 0" { + run echo "hello" + assert_success +} + +@test "assert_failure: command exits with non-zero status" { + run false + assert_failure +} + +@test "assert_failure with specific exit code" { + run bash -c "exit 42" + assert_failure 42 +} + +# ----------------------------------------------------------------------------- +# Output Assertions +# ----------------------------------------------------------------------------- + +@test "assert_output: exact match" { + run echo "hello world" + assert_output "hello world" +} + +@test "assert_output --partial: contains substring" { + run echo "hello world" + assert_output --partial "world" +} + +@test "assert_output --regexp: matches regex" { + run echo "file_2024_01_15.txt" + assert_output --regexp "file_[0-9]{4}_[0-9]{2}_[0-9]{2}\.txt" +} + +@test "refute_output: output does NOT contain" { + run echo "success" + refute_output --partial "error" +} + +# ----------------------------------------------------------------------------- +# Line Assertions +# ----------------------------------------------------------------------------- + +@test "assert_line: output contains line" { + run bash -c "echo -e 'line1\nline2\nline3'" + assert_line "line2" +} + +@test "assert_line --index: specific line number" { + run bash -c "echo -e 'first\nsecond\nthird'" + assert_line --index 0 "first" + assert_line --index 1 "second" + assert_line --index 2 "third" +} + +@test "assert_line --partial: line contains substring" { + run bash -c "echo -e 'error: something failed\nwarning: check this'" + assert_line --partial "something failed" +} + +@test "refute_line: output does NOT contain line" { + run bash -c "echo -e 'info: ok\ninfo: done'" + refute_line --partial "error" +} + +# ----------------------------------------------------------------------------- +# Variable Assertions +# ----------------------------------------------------------------------------- + +@test "assert: test expression" { + result="hello" + assert [ -n "$result" ] + assert [ "$result" = "hello" ] +} + +@test "assert_equal: two values are equal" { + expected="42" + actual="42" + assert_equal "$expected" "$actual" +} + +@test "assert_not_equal: two values differ" { + value1="foo" + value2="bar" + assert_not_equal "$value1" "$value2" +} + +@test "assert_regex: variable matches pattern" { + version="v1.2.3" + assert_regex "$version" "^v[0-9]+\.[0-9]+\.[0-9]+$" +} + +# ----------------------------------------------------------------------------- +# Working with Commands +# ----------------------------------------------------------------------------- + +@test "capture stdout and stderr separately" { + run bash -c "echo 'stdout message'; echo 'stderr message' >&2" + # $output contains both stdout and stderr by default + assert_output --partial "stdout message" + assert_output --partial "stderr message" +} + +@test "check command exists" { + run command -v bash + assert_success +} + +@test "working with JSON output (using grep)" { + run echo '{"status": "ok", "count": 5}' + assert_output --partial '"status": "ok"' +} + +# ----------------------------------------------------------------------------- +# Skipping Tests +# ----------------------------------------------------------------------------- + +@test "skip: conditionally skip a test" { + if [[ -z "${RUN_SLOW_TESTS:-}" ]]; then + skip "RUN_SLOW_TESTS not set" + fi + # This would be a slow test... + run sleep 0.1 + assert_success +} + +@test "skip based on environment" { + [[ -n "${CI:-}" ]] || skip "Only runs in CI" + run echo "running in CI" + assert_success +} diff --git a/tests/integration/singleton.bats b/tests/bats/integration/singleton_daemon.bats old mode 100755 new mode 100644 similarity index 79% rename from tests/integration/singleton.bats rename to tests/bats/integration/singleton_daemon.bats index d41eb39ffd..9b65aab068 --- a/tests/integration/singleton.bats +++ b/tests/bats/integration/singleton_daemon.bats @@ -1,7 +1,8 @@ #!/usr/bin/env bats -# singleton.bats - Tests for unraid-api singleton process management +# singleton_daemon.bats - Tests for unraid-api singleton daemon process management # # Usage: SERVER= bats singleton.bats +# SERVER= pnpm test:bats:integration # # These tests verify that the unraid-api is properly daemonized as a singleton process. # See: https://bats-core.readthedocs.io/en/stable/writing-tests.html @@ -11,15 +12,13 @@ # ----------------------------------------------------------------------------- # setup_file runs once before all tests in this file -# Load helpers here since they're needed for all tests -# See: https://bats-core.readthedocs.io/en/stable/writing-tests.html#setup-and-teardown-pre-and-post-test-hooks setup_file() { - load 'test_helper' + load '../test_helper/unraid-api' } # Setup runs before each test - ensure clean state setup() { - load 'test_helper' + load '../test_helper/unraid-api' cleanup } @@ -28,6 +27,11 @@ teardown() { cleanup } +teardown_file() { + load '../test_helper/unraid-api' + start_api +} + # ----------------------------------------------------------------------------- # Start Command Tests # ----------------------------------------------------------------------------- @@ -35,38 +39,38 @@ teardown() { @test "start: creates a single process with PID file" { # Start the API run start_api - [ "$status" -eq 0 ] + assert_success # Verify PID file exists run pid_file_exists - [ "$status" -eq 0 ] + assert_success # Verify PID is valid (non-empty and numeric) pid=$(get_remote_pid) - [ -n "$pid" ] - [[ "$pid" =~ ^[0-9]+$ ]] + assert [ -n "$pid" ] + assert_regex "$pid" '^[0-9]+$' # Verify process is running run is_process_running "$pid" - [ "$status" -eq 0 ] + assert_success # Verify exactly ONE nodemon AND ONE main.js run assert_single_api_instance - [ "$status" -eq 0 ] + assert_success } @test "start: second start does not create duplicate process" { # Start the API first run start_api - [ "$status" -eq 0 ] + assert_success # Get the initial PID initial_pid=$(get_remote_pid) - [ -n "$initial_pid" ] + assert [ -n "$initial_pid" ] # Verify single instance initially run assert_single_api_instance - [ "$status" -eq 0 ] + assert_success # Try to start again run remote_exec "unraid-api start" @@ -77,62 +81,62 @@ teardown() { # Verify still exactly one nodemon AND one main.js (singleton enforcement) run assert_single_api_instance - [ "$status" -eq 0 ] + assert_success # Verify process is still running (either same or new PID after restart) run pid_file_exists - [ "$status" -eq 0 ] + assert_success final_pid=$(get_remote_pid) - [ -n "$final_pid" ] + assert [ -n "$final_pid" ] run is_process_running "$final_pid" - [ "$status" -eq 0 ] + assert_success } @test "start: cleans up stale PID file" { # Create a stale PID file with a non-existent PID run remote_exec "mkdir -p /var/run/unraid-api && echo '99999' > '${REMOTE_PID_PATH}'" - [ "$status" -eq 0 ] + assert_success # Start should clean up and proceed run start_api - [ "$status" -eq 0 ] + assert_success # Verify new valid PID (not the stale one) pid=$(get_remote_pid) - [ -n "$pid" ] - [ "$pid" != "99999" ] + assert [ -n "$pid" ] + assert [ "$pid" != "99999" ] # Verify process is actually running run is_process_running "$pid" - [ "$status" -eq 0 ] + assert_success } @test "start: cleans up orphaned nodemon process" { # Start API normally run start_api - [ "$status" -eq 0 ] + assert_success # Remove PID file but leave process running (simulate orphan) run remote_exec "rm -f '${REMOTE_PID_PATH}'" - [ "$status" -eq 0 ] + assert_success # Verify orphaned process still running count=$(count_nodemon_processes) - [ "$count" -eq 1 ] + assert [ "$count" -eq 1 ] # Start should detect orphan and clean it up run start_api - [ "$status" -eq 0 ] + assert_success # Should still have exactly one process count=$(count_nodemon_processes) - [ "$count" -eq 1 ] + assert [ "$count" -eq 1 ] # PID file should exist again run pid_file_exists - [ "$status" -eq 0 ] + assert_success } # ----------------------------------------------------------------------------- @@ -142,11 +146,11 @@ teardown() { @test "status: reports running when API is active" { # Start the API run start_api - [ "$status" -eq 0 ] + assert_success # Check status - should contain "running" output=$(get_status) - [[ "$output" == *"running"* ]] + assert_regex "$output" "running" } @test "status: reports not running when API is stopped" { @@ -154,7 +158,7 @@ teardown() { # Check status - should indicate not running output=$(get_status) - [[ "$output" == *"not running"* ]] + assert_regex "$output" "not running" } # ----------------------------------------------------------------------------- @@ -164,53 +168,53 @@ teardown() { @test "stop: cleanly terminates all processes" { # Start the API first run start_api - [ "$status" -eq 0 ] + assert_success # Verify it's running pid=$(get_remote_pid) - [ -n "$pid" ] + assert [ -n "$pid" ] # Verify single instance before stop run assert_single_api_instance - [ "$status" -eq 0 ] + assert_success # Stop the API run stop_api - [ "$status" -eq 0 ] + assert_success # Verify PID file is removed run pid_file_exists - [ "$status" -ne 0 ] + assert_failure # Verify NO nodemon AND NO main.js processes remain run assert_no_api_processes - [ "$status" -eq 0 ] + assert_success } @test "stop --force: terminates all processes immediately" { # Start the API run start_api - [ "$status" -eq 0 ] + assert_success # Get the PID pid=$(get_remote_pid) - [ -n "$pid" ] + assert [ -n "$pid" ] # Verify single instance before stop run assert_single_api_instance - [ "$status" -eq 0 ] + assert_success # Force stop run stop_api --force - [ "$status" -eq 0 ] + assert_success # Verify PID file is removed run pid_file_exists - [ "$status" -ne 0 ] + assert_failure # Verify NO processes remain (nodemon AND main.js) run assert_no_api_processes - [ "$status" -eq 0 ] + assert_success } # ----------------------------------------------------------------------------- @@ -220,35 +224,35 @@ teardown() { @test "restart: creates new process when already running" { # Start the API run start_api - [ "$status" -eq 0 ] + assert_success # Get the initial PID initial_pid=$(get_remote_pid) - [ -n "$initial_pid" ] + assert [ -n "$initial_pid" ] # Verify single instance initially run assert_single_api_instance - [ "$status" -eq 0 ] + assert_success # Restart the API run remote_exec "unraid-api restart" - [ "$status" -eq 0 ] + assert_success # Wait for restart to complete sleep 3 run wait_for_start 10 - [ "$status" -eq 0 ] + assert_success # Get new PID new_pid=$(get_remote_pid) - [ -n "$new_pid" ] + assert [ -n "$new_pid" ] # PIDs should be different (process was actually restarted) - [ "$initial_pid" != "$new_pid" ] + assert [ "$initial_pid" != "$new_pid" ] # Verify exactly one nodemon AND one main.js after restart run assert_single_api_instance - [ "$status" -eq 0 ] + assert_success } @test "restart: works when API is not running" { @@ -256,18 +260,18 @@ teardown() { # Restart should start the API run remote_exec "unraid-api restart" - [ "$status" -eq 0 ] + assert_success # Wait for start run wait_for_start 10 - [ "$status" -eq 0 ] + assert_success # Verify process is running pid=$(get_remote_pid) - [ -n "$pid" ] + assert [ -n "$pid" ] run is_process_running "$pid" - [ "$status" -eq 0 ] + assert_success } # ----------------------------------------------------------------------------- @@ -283,20 +287,20 @@ teardown() { # Must have exactly one nodemon AND one main.js, not just "one nodemon" run assert_single_api_instance - [ "$status" -eq 0 ] + assert_success # PID file should exist run pid_file_exists - [ "$status" -eq 0 ] + assert_success } @test "recovery: API recovers after process is killed externally" { # Start the API run start_api - [ "$status" -eq 0 ] + assert_success pid=$(get_remote_pid) - [ -n "$pid" ] + assert [ -n "$pid" ] # Kill the process directly (simulate crash) run remote_exec "kill -9 '$pid'" @@ -306,12 +310,12 @@ teardown() { # Start should recover run start_api - [ "$status" -eq 0 ] + assert_success # Verify new process running new_pid=$(get_remote_pid) - [ -n "$new_pid" ] + assert [ -n "$new_pid" ] run is_process_running "$new_pid" - [ "$status" -eq 0 ] + assert_success } diff --git a/tests/bats/package.json b/tests/bats/package.json new file mode 100644 index 0000000000..fe88c7f3a1 --- /dev/null +++ b/tests/bats/package.json @@ -0,0 +1,16 @@ +{ + "name": "@unraid/bats-tests", + "version": "0.0.0", + "private": true, + "description": "BATS integration tests for unraid-api", + "scripts": { + "postinstall": "./setup.sh", + "test": "bats --recursive .", + "test:integration": "bats --recursive integration/" + }, + "devDependencies": { + "bats": "^1.13.0", + "bats-assert": "^2.2.4", + "bats-support": "^0.3.0" + } +} diff --git a/tests/bats/setup.sh b/tests/bats/setup.sh new file mode 100755 index 0000000000..202ae23d60 --- /dev/null +++ b/tests/bats/setup.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# setup.sh - Setup symlinks for BATS libraries from node_modules +# +# This script is called by postinstall to link bats-support and bats-assert +# from node_modules into test_helper/ where BATS can load them. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NODE_MODULES="$SCRIPT_DIR/node_modules" + +# Create test_helper directory if needed +mkdir -p "$SCRIPT_DIR/test_helper" + +# Create symlinks (use -f to overwrite existing) +if [[ -d "$NODE_MODULES/bats-support" ]]; then + ln -sfn "$NODE_MODULES/bats-support" "$SCRIPT_DIR/test_helper/bats-support" +fi + +if [[ -d "$NODE_MODULES/bats-assert" ]]; then + ln -sfn "$NODE_MODULES/bats-assert" "$SCRIPT_DIR/test_helper/bats-assert" +fi + +echo "BATS libraries linked successfully" diff --git a/tests/bats/test_helper/common.bash b/tests/bats/test_helper/common.bash new file mode 100644 index 0000000000..ceb42ed40b --- /dev/null +++ b/tests/bats/test_helper/common.bash @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# common.bash - Base helper that loads BATS assertion libraries +# +# Usage in tests: +# load '../test_helper/common' + +load 'bats-support/load' +load 'bats-assert/load' diff --git a/tests/bats/test_helper/ssh.bash b/tests/bats/test_helper/ssh.bash new file mode 100644 index 0000000000..1b77b074ad --- /dev/null +++ b/tests/bats/test_helper/ssh.bash @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# ssh.bash - SSH execution helpers for remote server testing +# +# Requires: SERVER environment variable +# +# Usage in tests: +# load '../test_helper/ssh' + +load 'common' + +: "${SERVER:?SERVER environment variable must be set}" + +# Execute a command on the remote server +remote_exec() { + ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${SERVER}" "$@" +} + +# Execute a command on the remote server, ignoring failures (for cleanup) +remote_exec_safe() { + ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${SERVER}" "$@" 2>/dev/null || true +} diff --git a/tests/integration/test_helper.bash b/tests/bats/test_helper/unraid-api.bash similarity index 83% rename from tests/integration/test_helper.bash rename to tests/bats/test_helper/unraid-api.bash index a423bd2d4a..a55e4e4c52 100644 --- a/tests/integration/test_helper.bash +++ b/tests/bats/test_helper/unraid-api.bash @@ -1,32 +1,19 @@ #!/usr/bin/env bash -# test_helper.bash - Shared helper functions for BATS integration tests +# unraid-api.bash - Helpers for testing unraid-api daemon process management # -# This file is loaded by singleton.bats using BATS' load command. -# See: https://bats-core.readthedocs.io/en/stable/writing-tests.html +# Requires: SERVER environment variable +# +# Usage in tests: +# load '../test_helper/unraid-api' -# Remote server (must be set via environment variable) -: "${SERVER:?SERVER environment variable must be set}" +load 'ssh' -# Remote paths - exported so they're available in test functions +# Remote paths export REMOTE_PID_PATH="/var/run/unraid-api/nodemon.pid" # Timeouts (seconds) export DEFAULT_TIMEOUT=10 -# ----------------------------------------------------------------------------- -# SSH Execution Helpers -# ----------------------------------------------------------------------------- - -# Execute a command on the remote server -remote_exec() { - ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${SERVER}" "$@" -} - -# Execute a command on the remote server, ignoring failures (for cleanup) -remote_exec_safe() { - ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${SERVER}" "$@" 2>/dev/null || true -} - # ----------------------------------------------------------------------------- # Process Query Helpers # ----------------------------------------------------------------------------- @@ -55,7 +42,6 @@ count_nodemon_processes() { } # Count main.js worker processes (children of nodemon) -# Note: ps shows relative path "node ./dist/main.js" not full path count_main_processes() { local result result=$(remote_exec "ps -eo args 2>/dev/null | grep -E 'node.*dist/main\.js' | grep -v grep | wc -l" 2>/dev/null || echo "0") @@ -70,6 +56,10 @@ count_unraid_api_processes() { echo $((nodemon_count + main_count)) } +# ----------------------------------------------------------------------------- +# Process Assertions +# ----------------------------------------------------------------------------- + # Assert exactly one nodemon and one main.js process assert_single_api_instance() { local nodemon_count main_count @@ -187,7 +177,6 @@ cleanup() { sleep 0.5 # Step 4: Force kill - then main.js children - # Note: Use simpler pattern since ps shows relative path "./dist/main.js" remote_exec_safe "pkill -KILL -f 'node.*dist/main.js' 2>/dev/null; true" sleep 1 @@ -197,7 +186,6 @@ cleanup() { # Step 6: Verify - if still running, try harder with explicit PIDs count=$(count_unraid_api_processes) if [[ "$count" -ne 0 ]]; then - # Get specific PIDs and kill them directly local pids pids=$(remote_exec_safe "ps -eo pid,args | grep -E 'nodemon.*nodemon.json|node.*dist/main.js' | grep -v grep | awk '{print \$1}'" 2>/dev/null || true) for pid in $pids; do @@ -223,8 +211,6 @@ start_api() { } # Stop the API using unraid-api stop command -# NOTE: This does NOT force kill remaining processes - tests should verify -# that unraid-api stop properly cleans up. Use cleanup() for test isolation. stop_api() { local force="${1:-}" if [[ "$force" == "--force" ]]; then @@ -233,10 +219,7 @@ stop_api() { remote_exec "unraid-api stop" fi - # Wait for PID file to be removed wait_for_stop - - # Wait for processes to exit (but don't force kill - let test verify) wait_for_all_processes_stop 10 } diff --git a/tests/integration/run-singleton-tests.sh b/tests/integration/run-singleton-tests.sh deleted file mode 100755 index 39cefd9aae..0000000000 --- a/tests/integration/run-singleton-tests.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env bash -# -# run-singleton-tests.sh - Run singleton process management tests against a remote Unraid server -# -# Usage: ./run-singleton-tests.sh -# -# Arguments: -# server_name SSH server name or IP address (required) -# -# Requirements: -# - BATS (Bash Automated Testing System) installed -# - SSH access to the target server as root -# - unraid-api already deployed on the target server -# -# See: https://bats-core.readthedocs.io/en/stable/ - -set -euo pipefail - -# Colors for output -readonly RED='\033[0;31m' -readonly GREEN='\033[0;32m' -readonly NC='\033[0m' # No Color - -# Script directory -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -print_error() { - echo -e "${RED}Error: $1${NC}" >&2 -} - -print_success() { - echo -e "${GREEN}$1${NC}" -} - -print_info() { - echo "$1" -} - -usage() { - cat << EOF -Usage: $(basename "$0") - -Run singleton process management tests against a remote Unraid server. - -Arguments: - server_name SSH server name or IP address (required) - -Options: - -h, --help Show this help message - -Examples: - $(basename "$0") tower - $(basename "$0") 192.168.1.100 - $(basename "$0") unraid.local - -Requirements: - - BATS (Bash Automated Testing System) installed - Install: brew install bats-core (macOS) or apt install bats (Ubuntu) - - SSH access to the target server as root - - unraid-api already deployed on the target server -EOF -} - -check_bats() { - if ! command -v bats &> /dev/null; then - print_error "BATS is not installed." - echo "" - echo "Install BATS using one of these methods:" - echo " macOS: brew install bats-core" - echo " Ubuntu: apt-get install bats" - echo " npm: npm install -g bats" - echo "" - echo "Or visit: https://github.com/bats-core/bats-core" - exit 1 - fi - - print_success "BATS is installed: $(bats --version)" -} - -check_ssh() { - local server="$1" - - print_info "Checking SSH connectivity to ${server}..." - - if ! ssh -o ConnectTimeout=10 -o BatchMode=yes "root@${server}" 'echo "SSH OK"' &> /dev/null; then - print_error "Cannot connect to ${server} via SSH." - echo "" - echo "Please ensure:" - echo " 1. The server name/IP is correct" - echo " 2. SSH is running on the server" - echo " 3. You have SSH key access as root" - echo "" - echo "Test manually with: ssh root@${server}" - exit 1 - fi - - print_success "SSH connection successful" -} - -check_unraid_api() { - local server="$1" - - print_info "Checking if unraid-api is installed on ${server}..." - - if ! ssh -o ConnectTimeout=10 "root@${server}" 'command -v unraid-api' &> /dev/null; then - print_error "unraid-api is not installed on ${server}." - echo "" - echo "Please deploy unraid-api first using:" - echo " cd api && ./scripts/deploy-dev.sh ${server}" - exit 1 - fi - - print_success "unraid-api is installed" -} - -main() { - # Parse arguments - if [[ $# -eq 0 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then - usage - exit 0 - fi - - local server="$1" - - echo "" - echo "========================================" - echo "Unraid API Singleton Process Tests" - echo "========================================" - echo "" - echo "Target server: ${server}" - echo "" - - # Pre-flight checks - check_bats - check_ssh "$server" - check_unraid_api "$server" - - echo "" - print_info "Running tests..." - echo "" - - # Run BATS tests with SERVER environment variable - export SERVER="$server" - - if bats "${SCRIPT_DIR}/singleton.bats"; then - echo "" - print_success "All tests passed!" - exit 0 - else - echo "" - print_error "Some tests failed." - exit 1 - fi -} - -main "$@" diff --git a/tests/system-integration/README.md b/tests/system-integration/README.md new file mode 100644 index 0000000000..bef4df1b6e --- /dev/null +++ b/tests/system-integration/README.md @@ -0,0 +1,214 @@ +# System Integration Tests + +TypeScript + Vitest integration tests for the unraid-api daemon process management. These tests validate singleton daemon behavior by executing commands on a remote Unraid server via SSH. + +## Prerequisites + +- Node.js 22+ +- pnpm +- SSH key-based authentication to the target Unraid server +- The target server must have `unraid-api` installed and accessible + +## Installation + +```bash +cd tests/system-integration +pnpm install +``` + +Or from the monorepo root: + +```bash +pnpm install +``` + +## Usage + +### Running Tests + +Tests require the `SERVER` environment variable to specify the target Unraid server: + +```bash +# Run all tests +SERVER=tower pnpm test + +# Run tests in watch mode +SERVER=tower pnpm test:watch + +# From monorepo root +SERVER=tower pnpm test:system +``` + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `SERVER` | Yes | Hostname or IP address of the target Unraid server | + +## Test Coverage + +The test suite validates daemon singleton process management: + +### Start Command Tests +- Creates a single process with PID file +- Second start does not create duplicate process +- Cleans up stale PID file +- Cleans up orphaned nodemon process + +### Status Command Tests +- Reports running when API is active +- Reports not running when API is stopped + +### Stop Command Tests +- Cleanly terminates all processes +- Force stop terminates all processes immediately + +### Restart Command Tests +- Creates new process when already running +- Works when API is not running + +### Edge Case Tests +- Concurrent starts result in single process +- API recovers after process is killed externally + +## Architecture + +### Process Model + +The unraid-api runs as a singleton daemon with two processes: + +``` +nodemon (supervisor) +└── node dist/main.js (worker) +``` + +- **nodemon**: Process supervisor that monitors and restarts the main process +- **main.js**: The actual API server + +### PID File + +The daemon tracks its process via a PID file: +``` +/var/run/unraid-api/nodemon.pid +``` + +## Project Structure + +``` +tests/system-integration/ +├── package.json +├── tsconfig.json +├── vitest.config.ts +├── README.md +└── src/ + ├── helpers/ + │ ├── ssh.ts # SSH execution via execa + │ ├── process.ts # Process query/assertion helpers + │ └── api-lifecycle.ts # Start/stop/cleanup helpers + └── tests/ + └── singleton-daemon.test.ts +``` + +### Helper Modules + +#### `ssh.ts` +Remote command execution via SSH: +- `remoteExec(cmd)` - Execute command, return result +- `remoteExecSafe(cmd)` - Execute command, ignore failures (for cleanup) + +#### `process.ts` +Process inspection and assertions: +- `getRemotePid()` - Read PID from file +- `pidFileExists()` - Check PID file existence +- `isProcessRunning(pid)` - Verify process is alive +- `countNodemonProcesses()` - Count nodemon instances +- `countMainProcesses()` - Count main.js workers +- `assertSingleApiInstance()` - Assert exactly 1 nodemon + 1 main.js +- `assertNoApiProcesses()` - Assert all processes stopped + +#### `api-lifecycle.ts` +High-level daemon management: +- `startApi()` - Start and wait for ready +- `stopApi(force?)` - Stop with optional force flag +- `cleanup()` - Multi-step process cleanup +- `waitForStart(timeout)` - Poll until started +- `waitForStop(timeout)` - Poll until stopped +- `getStatus()` - Get status output + +## Configuration + +### Vitest Configuration + +The tests run sequentially (not in parallel) since they interact with shared server state: + +```typescript +// vitest.config.ts +export default defineConfig({ + test: { + globals: true, + testTimeout: 60000, // SSH operations can be slow + hookTimeout: 60000, + sequence: { + concurrent: false, // Run tests sequentially + }, + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, // Single process for all tests + }, + }, + }, +}); +``` + +### SSH Configuration + +SSH connections use these options: +- `ConnectTimeout=10` - 10 second connection timeout +- `BatchMode=yes` - Disable password prompts (requires key auth) +- `StrictHostKeyChecking=accept-new` - Auto-accept new host keys + +## Comparison with BATS Tests + +This package is a TypeScript port of the BATS test suite in `tests/bats/`. Key differences: + +| Feature | BATS | TypeScript/Vitest | +|---------|------|-------------------| +| Language | Bash | TypeScript | +| Test Runner | bats-core | Vitest | +| Assertions | bats-assert | Vitest expect() | +| SSH Execution | Raw ssh command | execa | +| Async Model | Synchronous shell | Async/await | +| Type Safety | None | Full TypeScript types | + +## Troubleshooting + +### SSH Connection Fails + +Ensure SSH key authentication is configured: + +```bash +# Test SSH connection +ssh root@tower echo "Connected" + +# If prompted for password, set up key auth: +ssh-copy-id root@tower +``` + +### Tests Time Out + +Increase timeouts in `vitest.config.ts` or individual tests: + +```typescript +it('slow test', async () => { + // ... +}, 120000); // 2 minute timeout +``` + +### Processes Not Cleaned Up + +If tests fail and leave processes running, manually clean up: + +```bash +ssh root@tower 'unraid-api stop --force; pkill -f nodemon; pkill -f main.js' +``` diff --git a/tests/system-integration/package.json b/tests/system-integration/package.json new file mode 100644 index 0000000000..e42d450e9f --- /dev/null +++ b/tests/system-integration/package.json @@ -0,0 +1,16 @@ +{ + "name": "@unraid/system-integration-tests", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "System integration tests for unraid-api", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "execa": "^9.6.0", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } +} diff --git a/tests/system-integration/src/helpers/api-lifecycle.ts b/tests/system-integration/src/helpers/api-lifecycle.ts new file mode 100644 index 0000000000..a5f54aefdf --- /dev/null +++ b/tests/system-integration/src/helpers/api-lifecycle.ts @@ -0,0 +1,271 @@ +/** + * @fileoverview API lifecycle management helpers for testing unraid-api daemon operations. + * Provides high-level functions for starting, stopping, and managing the API daemon state. + * + * These helpers wrap the `unraid-api` CLI commands and provide proper wait/polling + * logic to ensure operations complete before returning. + * + * @example + * ```typescript + * // Test setup + * beforeEach(async () => { + * await cleanup(); // Ensure clean state + * }); + * + * // Start and verify + * await startApi(); + * const status = await getStatus(); + * expect(status).toMatch(/running/i); + * + * // Stop and cleanup + * await stopApi(); + * ``` + */ + +import { remoteExec, remoteExecSafe } from './ssh.js'; +import { + getRemotePid, + isProcessRunning, + countUnraidApiProcesses, + REMOTE_PID_PATH, +} from './process.js'; + +/** + * Default timeout for wait operations in milliseconds. + */ +const DEFAULT_TIMEOUT = 10000; + +/** + * Utility function to pause execution. + * @param ms - Duration to sleep in milliseconds + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Waits for the API to start by polling for PID file existence and process running state. + * + * @param timeout - Maximum time to wait in milliseconds (default: 10000) + * @returns `true` if the API started within the timeout, `false` otherwise + * + * @example + * ```typescript + * await remoteExec('unraid-api start'); + * const started = await waitForStart(15000); + * if (!started) { + * throw new Error('API failed to start'); + * } + * ``` + */ +export async function waitForStart(timeout = DEFAULT_TIMEOUT): Promise { + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + const pid = await getRemotePid(); + if (pid && (await isProcessRunning(pid))) { + return true; + } + await sleep(1000); + } + + return false; +} + +/** + * Waits for the API to stop by polling until PID file is removed or process is not running. + * + * @param timeout - Maximum time to wait in milliseconds (default: 10000) + * @returns `true` if the API stopped within the timeout, `false` otherwise + * + * @example + * ```typescript + * await remoteExec('unraid-api stop'); + * const stopped = await waitForStop(); + * expect(stopped).toBe(true); + * ``` + */ +export async function waitForStop(timeout = DEFAULT_TIMEOUT): Promise { + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + const pid = await getRemotePid(); + if (!pid) { + return true; + } + if (!(await isProcessRunning(pid))) { + return true; + } + await sleep(1000); + } + + return false; +} + +/** + * Waits for all API-related processes (nodemon and main.js) to terminate. + * + * @param timeout - Maximum time to wait in milliseconds (default: 10000) + * @returns `true` if all processes stopped within the timeout, `false` otherwise + * + * @example + * ```typescript + * await stopApi(); + * const allStopped = await waitForAllProcessesStop(15000); + * expect(allStopped).toBe(true); + * ``` + */ +export async function waitForAllProcessesStop( + timeout = DEFAULT_TIMEOUT +): Promise { + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + const count = await countUnraidApiProcesses(); + if (count === 0) { + return true; + } + await sleep(1000); + } + + return false; +} + +/** + * Comprehensive cleanup function that ensures all API processes are terminated. + * + * Performs a multi-step cleanup process: + * 1. Attempts graceful stop via `unraid-api stop` + * 2. If processes remain, force kills nodemon first (prevents respawning) + * 3. Then force kills any remaining main.js processes + * 4. Removes the PID file + * 5. As a last resort, kills processes by explicit PID + * + * This function is designed to be called in test setup/teardown hooks to ensure + * a clean state between tests. + * + * @example + * ```typescript + * beforeEach(async () => { + * await cleanup(); + * }); + * + * afterEach(async () => { + * await cleanup(); + * }); + * ``` + */ +export async function cleanup(): Promise { + // Step 1: Try graceful stop via unraid-api + await remoteExecSafe('unraid-api stop 2>/dev/null; true'); + await sleep(1000); + + // Step 2: Check if processes remain + let count = await countUnraidApiProcesses(); + if (count === 0) { + await remoteExecSafe(`rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true`); + return; + } + + // Step 3: Force kill - nodemon FIRST (prevents restart of child) + await remoteExecSafe("pkill -KILL -f 'nodemon.*nodemon.json' 2>/dev/null; true"); + await sleep(500); + + // Step 4: Force kill - then main.js children + await remoteExecSafe("pkill -KILL -f 'node.*dist/main.js' 2>/dev/null; true"); + await sleep(1000); + + // Step 5: Clean up PID file + await remoteExecSafe(`rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true`); + + // Step 6: Verify - if still running, try harder with explicit PIDs + count = await countUnraidApiProcesses(); + if (count !== 0) { + const pidsResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon.*nodemon.json|node.*dist/main.js' | grep -v grep | awk '{print $1}'" + ); + const pids = pidsResult.stdout.trim().split('\n').filter(Boolean); + for (const pid of pids) { + await remoteExecSafe(`kill -9 ${pid} 2>/dev/null; true`); + } + await sleep(1000); + } + + // Final check + count = await countUnraidApiProcesses(); + if (count !== 0) { + const psResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" + ); + console.warn(`WARNING: Cleanup incomplete, remaining processes:\n${psResult.stdout}`); + } +} + +/** + * Starts the unraid-api daemon and waits for it to be ready. + * + * @throws {Error} If the start command fails or the API doesn't start within the timeout + * + * @example + * ```typescript + * await startApi(); + * // API is now running and ready + * await assertSingleApiInstance(); + * ``` + */ +export async function startApi(): Promise { + const result = await remoteExec('unraid-api start'); + if (result.exitCode !== 0) { + throw new Error(`Failed to start API: ${result.stderr}`); + } + const started = await waitForStart(); + if (!started) { + throw new Error('API did not start within timeout'); + } +} + +/** + * Stops the unraid-api daemon and waits for termination. + * + * @param force - If `true`, uses `--force` flag for immediate termination (SIGKILL). + * If `false` (default), uses graceful shutdown (SIGTERM). + * @throws {Error} If the stop command fails + * + * @example + * ```typescript + * // Graceful stop + * await stopApi(); + * + * // Force stop (immediate) + * await stopApi(true); + * ``` + */ +export async function stopApi(force = false): Promise { + const cmd = force ? 'unraid-api stop --force' : 'unraid-api stop'; + const result = await remoteExec(cmd); + if (result.exitCode !== 0) { + throw new Error(`Failed to stop API: ${result.stderr}`); + } + await waitForStop(); + await waitForAllProcessesStop(10000); +} + +/** + * Retrieves the current status of the unraid-api daemon. + * + * @returns The status output from `unraid-api status` command + * + * @example + * ```typescript + * const status = await getStatus(); + * if (status.includes('running')) { + * console.log('API is active'); + * } else { + * console.log('API is stopped'); + * } + * ``` + */ +export async function getStatus(): Promise { + const result = await remoteExec('unraid-api status 2>&1'); + return result.stdout; +} diff --git a/tests/system-integration/src/helpers/process.ts b/tests/system-integration/src/helpers/process.ts new file mode 100644 index 0000000000..a2c1d0f3cf --- /dev/null +++ b/tests/system-integration/src/helpers/process.ts @@ -0,0 +1,203 @@ +/** + * @fileoverview Process query and assertion helpers for unraid-api daemon testing. + * Provides utilities to inspect and validate process state on a remote Unraid server. + * + * The unraid-api runs as a singleton daemon with two processes: + * - **nodemon**: Process supervisor that monitors and restarts the main process + * - **main.js**: The actual API server (Node.js application) + * + * @example + * ```typescript + * // Check if the API is running + * const pid = await getRemotePid(); + * if (pid && await isProcessRunning(pid)) { + * console.log('API is running with PID:', pid); + * } + * + * // Verify singleton enforcement + * await assertSingleApiInstance(); // Throws if not exactly 1 nodemon + 1 main.js + * ``` + */ + +import { remoteExec, remoteExecSafe } from './ssh.js'; + +/** + * Path to the PID file on the remote server. + * This file contains the PID of the nodemon supervisor process. + */ +export const REMOTE_PID_PATH = '/var/run/unraid-api/nodemon.pid'; + +/** + * Retrieves the PID from the remote PID file. + * + * @returns The PID as a string, or empty string if the file doesn't exist or is empty + * + * @example + * ```typescript + * const pid = await getRemotePid(); + * if (pid) { + * console.log('Found PID:', pid); + * } + * ``` + */ +export async function getRemotePid(): Promise { + const result = await remoteExec(`cat '${REMOTE_PID_PATH}' 2>/dev/null || true`); + return result.stdout.trim(); +} + +/** + * Checks if the PID file exists on the remote server. + * + * @returns `true` if the PID file exists, `false` otherwise + * + * @example + * ```typescript + * if (await pidFileExists()) { + * console.log('PID file exists'); + * } + * ``` + */ +export async function pidFileExists(): Promise { + const result = await remoteExec(`test -f '${REMOTE_PID_PATH}'`); + return result.exitCode === 0; +} + +/** + * Checks if a process with the given PID is currently running. + * Uses `kill -0` which checks process existence without sending a signal. + * + * @param pid - The process ID to check + * @returns `true` if the process is running, `false` if not running or PID is empty + * + * @example + * ```typescript + * const pid = await getRemotePid(); + * if (await isProcessRunning(pid)) { + * console.log('Process is alive'); + * } + * ``` + */ +export async function isProcessRunning(pid: string): Promise { + if (!pid) return false; + const result = await remoteExec(`kill -0 '${pid}' 2>/dev/null`); + return result.exitCode === 0; +} + +/** + * Counts the number of nodemon supervisor processes running. + * Looks for processes matching the pattern `nodemon.*nodemon.json`. + * + * @returns The number of nodemon processes (should be 0 or 1 in normal operation) + * + * @example + * ```typescript + * const count = await countNodemonProcesses(); + * expect(count).toBe(1); // Exactly one supervisor should run + * ``` + */ +export async function countNodemonProcesses(): Promise { + const result = await remoteExecSafe( + "ps -eo pid,args 2>/dev/null | grep -E 'nodemon.*nodemon.json' | grep -v grep | wc -l" + ); + const count = parseInt(result.stdout.trim(), 10); + return isNaN(count) ? 0 : count; +} + +/** + * Counts the number of main.js worker processes running. + * Looks for processes matching the pattern `node.*dist/main.js`. + * + * @returns The number of main.js processes (should be 0 or 1 in normal operation) + * + * @example + * ```typescript + * const count = await countMainProcesses(); + * expect(count).toBe(1); // Exactly one worker should run + * ``` + */ +export async function countMainProcesses(): Promise { + const result = await remoteExecSafe( + "ps -eo args 2>/dev/null | grep -E 'node.*dist/main\\.js' | grep -v grep | wc -l" + ); + const count = parseInt(result.stdout.trim(), 10); + return isNaN(count) ? 0 : count; +} + +/** + * Counts all unraid-api related processes (nodemon + main.js combined). + * + * @returns Total count of all API-related processes + * + * @example + * ```typescript + * const total = await countUnraidApiProcesses(); + * // Should be 2 when running (1 nodemon + 1 main.js) + * // Should be 0 when stopped + * ``` + */ +export async function countUnraidApiProcesses(): Promise { + const nodemonCount = await countNodemonProcesses(); + const mainCount = await countMainProcesses(); + return nodemonCount + mainCount; +} + +/** + * Asserts that exactly one nodemon and one main.js process are running. + * This validates proper singleton daemon enforcement. + * + * @throws {Error} If the process counts don't match expected values (1 each) + * + * @example + * ```typescript + * await startApi(); + * await assertSingleApiInstance(); // Passes if singleton is enforced + * ``` + */ +export async function assertSingleApiInstance(): Promise { + const nodemonCount = await countNodemonProcesses(); + const mainCount = await countMainProcesses(); + + if (nodemonCount !== 1) { + const psResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" + ); + throw new Error( + `Expected 1 nodemon process, found ${nodemonCount}\n${psResult.stdout}` + ); + } + + if (mainCount !== 1) { + const psResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" + ); + throw new Error( + `Expected 1 main.js process, found ${mainCount}\n${psResult.stdout}` + ); + } +} + +/** + * Asserts that no unraid-api processes are running. + * Used to verify clean shutdown. + * + * @throws {Error} If any nodemon or main.js processes are found + * + * @example + * ```typescript + * await stopApi(); + * await assertNoApiProcesses(); // Passes if all processes terminated + * ``` + */ +export async function assertNoApiProcesses(): Promise { + const nodemonCount = await countNodemonProcesses(); + const mainCount = await countMainProcesses(); + + if (nodemonCount !== 0 || mainCount !== 0) { + const psResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" + ); + throw new Error( + `Expected 0 processes, found nodemon=${nodemonCount} main.js=${mainCount}\n${psResult.stdout}` + ); + } +} diff --git a/tests/system-integration/src/helpers/ssh.ts b/tests/system-integration/src/helpers/ssh.ts new file mode 100644 index 0000000000..e4b11c6e21 --- /dev/null +++ b/tests/system-integration/src/helpers/ssh.ts @@ -0,0 +1,127 @@ +/** + * @fileoverview SSH execution helpers for remote server testing. + * Provides utilities to execute commands on a remote Unraid server via SSH. + * + * @requires SERVER environment variable to be set with the target server hostname/IP + * + * @example + * ```typescript + * // Execute a command and check the result + * const result = await remoteExec('unraid-api status'); + * if (result.exitCode === 0) { + * console.log(result.stdout); + * } + * + * // Execute a cleanup command, ignoring failures + * await remoteExecSafe('rm -f /tmp/test-file'); + * ``` + */ + +import { execa } from 'execa'; + +/** + * Result of a remote command execution. + */ +export interface ExecResult { + /** Standard output from the command */ + stdout: string; + /** Standard error from the command */ + stderr: string; + /** Exit code of the command (0 indicates success) */ + exitCode: number; +} + +/** + * Retrieves the target server from the SERVER environment variable. + * @throws {Error} If SERVER environment variable is not set + * @returns The server hostname or IP address + */ +function getServer(): string { + const server = process.env.SERVER; + if (!server) { + throw new Error('SERVER environment variable must be set'); + } + return server; +} + +/** + * SSH connection options used for all remote executions. + * - ConnectTimeout: 10 seconds + * - BatchMode: Disables password prompts (requires key-based auth) + * - StrictHostKeyChecking: Automatically accepts new host keys + */ +const SSH_OPTIONS = [ + '-o', 'ConnectTimeout=10', + '-o', 'BatchMode=yes', + '-o', 'StrictHostKeyChecking=accept-new', +]; + +/** + * Executes a command on the remote server via SSH. + * + * @param cmd - The shell command to execute on the remote server + * @returns Promise resolving to the execution result with stdout, stderr, and exit code + * + * @example + * ```typescript + * const result = await remoteExec('unraid-api start'); + * if (result.exitCode !== 0) { + * throw new Error(`Failed: ${result.stderr}`); + * } + * ``` + */ +export async function remoteExec(cmd: string): Promise { + const server = getServer(); + const result = await execa('ssh', [...SSH_OPTIONS, `root@${server}`, cmd], { + reject: false, + }); + + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode ?? 0, + }; +} + +/** + * Executes a command on the remote server, suppressing any errors. + * Useful for cleanup operations where failures should be ignored. + * + * @param cmd - The shell command to execute on the remote server + * @returns Promise resolving to the execution result, or empty result on error + * + * @example + * ```typescript + * // Remove a file, ignoring if it doesn't exist + * await remoteExecSafe('rm -f /var/run/unraid-api/nodemon.pid'); + * ``` + */ +export async function remoteExecSafe(cmd: string): Promise { + const server = getServer(); + try { + const result = await execa('ssh', [...SSH_OPTIONS, `root@${server}`, cmd], { + reject: false, + }); + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode ?? 0, + }; + } catch { + return { + stdout: '', + stderr: '', + exitCode: 0, + }; + } +} + +/** + * Returns the configured server name from the SERVER environment variable. + * + * @throws {Error} If SERVER environment variable is not set + * @returns The server hostname or IP address + */ +export function getServerName(): string { + return getServer(); +} diff --git a/tests/system-integration/src/tests/singleton-daemon.test.ts b/tests/system-integration/src/tests/singleton-daemon.test.ts new file mode 100644 index 0000000000..f4a428d841 --- /dev/null +++ b/tests/system-integration/src/tests/singleton-daemon.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { remoteExec } from '../helpers/ssh.js'; +import { + getRemotePid, + pidFileExists, + isProcessRunning, + countNodemonProcesses, + assertSingleApiInstance, + assertNoApiProcesses, + REMOTE_PID_PATH, +} from '../helpers/process.js'; +import { + cleanup, + startApi, + stopApi, + getStatus, + waitForStart, +} from '../helpers/api-lifecycle.js'; + +describe('singleton daemon', () => { + beforeAll(async () => { + if (!process.env.SERVER) { + throw new Error('SERVER environment variable must be set'); + } + }); + + afterAll(async () => { + await cleanup(); + await startApi(); + }); + + beforeEach(async () => { + await cleanup(); + }); + + describe('start command', () => { + it('creates a single process with PID file', async () => { + await startApi(); + + expect(await pidFileExists()).toBe(true); + + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + expect(pid).toMatch(/^\d+$/); + + expect(await isProcessRunning(pid)).toBe(true); + + await assertSingleApiInstance(); + }); + + it('second start does not create duplicate process', async () => { + await startApi(); + + const initialPid = await getRemotePid(); + expect(initialPid).toBeTruthy(); + + await assertSingleApiInstance(); + + await remoteExec('unraid-api start'); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + await assertSingleApiInstance(); + + expect(await pidFileExists()).toBe(true); + + const finalPid = await getRemotePid(); + expect(finalPid).toBeTruthy(); + + expect(await isProcessRunning(finalPid)).toBe(true); + }); + + it('cleans up stale PID file', async () => { + await remoteExec(`mkdir -p /var/run/unraid-api && echo '99999' > '${REMOTE_PID_PATH}'`); + + await startApi(); + + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + expect(pid).not.toBe('99999'); + + expect(await isProcessRunning(pid)).toBe(true); + }); + + it('cleans up orphaned nodemon process', async () => { + await startApi(); + + await remoteExec(`rm -f '${REMOTE_PID_PATH}'`); + + const count = await countNodemonProcesses(); + expect(count).toBe(1); + + await startApi(); + + const newCount = await countNodemonProcesses(); + expect(newCount).toBe(1); + + expect(await pidFileExists()).toBe(true); + }); + }); + + describe('status command', () => { + it('reports running when API is active', async () => { + await startApi(); + + const output = await getStatus(); + expect(output).toMatch(/running/i); + }); + + it('reports not running when API is stopped', async () => { + const output = await getStatus(); + expect(output).toMatch(/not running/i); + }); + }); + + describe('stop command', () => { + it('cleanly terminates all processes', async () => { + await startApi(); + + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + + await assertSingleApiInstance(); + + await stopApi(); + + expect(await pidFileExists()).toBe(false); + + await assertNoApiProcesses(); + }); + + it('stop --force terminates all processes immediately', async () => { + await startApi(); + + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + + await assertSingleApiInstance(); + + await stopApi(true); + + expect(await pidFileExists()).toBe(false); + + await assertNoApiProcesses(); + }); + }); + + describe('restart command', () => { + it('creates new process when already running', async () => { + await startApi(); + + const initialPid = await getRemotePid(); + expect(initialPid).toBeTruthy(); + + await assertSingleApiInstance(); + + await remoteExec('unraid-api restart'); + + await new Promise((resolve) => setTimeout(resolve, 3000)); + await waitForStart(10000); + + const newPid = await getRemotePid(); + expect(newPid).toBeTruthy(); + + expect(initialPid).not.toBe(newPid); + + await assertSingleApiInstance(); + }); + + it('works when API is not running', async () => { + await remoteExec('unraid-api restart'); + + await waitForStart(10000); + + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + + expect(await isProcessRunning(pid)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('concurrent starts result in single process', async () => { + await remoteExec('unraid-api start & unraid-api start & wait'); + + await new Promise((resolve) => setTimeout(resolve, 3000)); + + await assertSingleApiInstance(); + + expect(await pidFileExists()).toBe(true); + }); + + it('API recovers after process is killed externally', async () => { + await startApi(); + + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + + await remoteExec(`kill -9 '${pid}'`); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + await startApi(); + + const newPid = await getRemotePid(); + expect(newPid).toBeTruthy(); + + expect(await isProcessRunning(newPid)).toBe(true); + }); + }); +}); diff --git a/tests/system-integration/tsconfig.json b/tests/system-integration/tsconfig.json new file mode 100644 index 0000000000..e8ba7ba763 --- /dev/null +++ b/tests/system-integration/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "outDir": "./dist", + "rootDir": "./src", + "types": ["vitest/globals"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/tests/system-integration/vitest.config.ts b/tests/system-integration/vitest.config.ts new file mode 100644 index 0000000000..40cd01db64 --- /dev/null +++ b/tests/system-integration/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + include: ['src/tests/**/*.test.ts'], + testTimeout: 60000, + hookTimeout: 60000, + sequence: { + concurrent: false, + }, + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + }, + }, + }, +}); From e5abbcbf90293c19444caf53037dd75ec0e0ac43 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 10 Dec 2025 14:15:19 -0500 Subject: [PATCH 24/25] test: create vitest based integration suite --- package.json | 5 +- pnpm-lock.yaml | 53 +-- pnpm-workspace.yaml | 1 - tests/bats/README.md | 115 ------- tests/bats/examples/example.bats | 154 --------- tests/bats/integration/singleton_daemon.bats | 321 ------------------ tests/bats/package.json | 16 - tests/bats/setup.sh | 24 -- tests/bats/test_helper/common.bash | 8 - tests/bats/test_helper/ssh.bash | 21 -- tests/bats/test_helper/unraid-api.bash | 229 ------------- tests/system-integration/.prettierrc.cjs | 11 + tests/system-integration/README.md | 185 +--------- tests/system-integration/eslint.config.ts | 19 ++ tests/system-integration/package.json | 11 +- .../src/helpers/api-lifecycle.ts | 173 +++++----- .../system-integration/src/helpers/process.ts | 92 +++-- tests/system-integration/src/helpers/ssh.ts | 85 ++--- .../src/tests/singleton-daemon.test.ts | 264 +++++++------- 19 files changed, 363 insertions(+), 1424 deletions(-) delete mode 100644 tests/bats/README.md delete mode 100644 tests/bats/examples/example.bats delete mode 100644 tests/bats/integration/singleton_daemon.bats delete mode 100644 tests/bats/package.json delete mode 100755 tests/bats/setup.sh delete mode 100644 tests/bats/test_helper/common.bash delete mode 100644 tests/bats/test_helper/ssh.bash delete mode 100644 tests/bats/test_helper/unraid-api.bash create mode 100644 tests/system-integration/.prettierrc.cjs create mode 100644 tests/system-integration/eslint.config.ts diff --git a/package.json b/package.json index 4f6dfd26b9..16a8536917 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,6 @@ "dev": "pnpm -r dev", "unraid:deploy": "pnpm -r unraid:deploy", "test": "pnpm -r test", - "test:bats": "pnpm --filter @unraid/bats-tests test", - "test:bats:integration": "pnpm --filter @unraid/bats-tests test:integration", "test:system": "pnpm --filter @unraid/system-integration-tests test", "test:watch": "pnpm -r --parallel test:watch", "lint": "pnpm -r lint", @@ -75,6 +73,9 @@ ], "unraid-ui/**/*.{js,ts,tsx,vue}": [ "pnpm --filter @unraid/ui lint:fix" + ], + "tests/system-integration/**/*.ts": [ + "pnpm --filter @unraid/system-integration-tests lint:fix" ] }, "packageManager": "pnpm@10.15.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 038491027f..c02ac805cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -853,26 +853,29 @@ importers: specifier: 3.2.4 version: 3.2.4(@types/node@22.18.0)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) - tests/bats: - devDependencies: - bats: - specifier: ^1.13.0 - version: 1.13.0 - bats-assert: - specifier: ^2.2.4 - version: 2.2.4(bats-support@0.3.0(bats@1.13.0))(bats@1.13.0) - bats-support: - specifier: ^0.3.0 - version: 0.3.0(bats@1.13.0) - tests/system-integration: devDependencies: + '@eslint/js': + specifier: ^9.34.0 + version: 9.34.0 + eslint: + specifier: ^9.34.0 + version: 9.34.0(jiti@2.5.1) execa: specifier: ^9.6.0 version: 9.6.0 + jiti: + specifier: ^2.5.1 + version: 2.5.1 + prettier: + specifier: ^3.6.2 + version: 3.6.2 typescript: specifier: ^5.9.2 version: 5.9.2 + typescript-eslint: + specifier: ^8.41.0 + version: 8.41.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2) vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@22.18.0)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) @@ -6050,21 +6053,6 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} - bats-assert@2.2.4: - resolution: {integrity: sha512-EcaY4Z+Tbz1c7pnC1SrVSq0epr7tLwFpz6qt7KUW9K8uSw8V12DTfH9d2HxZWvBEATaCuMsZ7KoZMFiSQPRoXw==} - peerDependencies: - bats: 0.4 || ^1 - bats-support: ^0.3 - - bats-support@0.3.0: - resolution: {integrity: sha512-z+2WzXbI4OZgLnynydqH8GpI3+DcOtepO66PlK47SfEzTkiuV9hxn9eIQX+uLVFbt2Oqoc7Ky3TJ/N83lqD+cg==} - peerDependencies: - bats: 0.4 || ^1 - - bats@1.13.0: - resolution: {integrity: sha512-giSYKGTOcPZyJDbfbTtzAedLcNWdjCLbXYU3/MwPnjyvDXzu6Dgw8d2M+8jHhZXSmsCMSQqCp+YBsJ603UO4vQ==} - hasBin: true - bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} @@ -17943,17 +17931,6 @@ snapshots: dependencies: safe-buffer: 5.1.2 - bats-assert@2.2.4(bats-support@0.3.0(bats@1.13.0))(bats@1.13.0): - dependencies: - bats: 1.13.0 - bats-support: 0.3.0(bats@1.13.0) - - bats-support@0.3.0(bats@1.13.0): - dependencies: - bats: 1.13.0 - - bats@1.13.0: {} - bcrypt-pbkdf@1.0.2: dependencies: tweetnacl: 0.14.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4197de546b..0e40d11bd3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,5 +5,4 @@ packages: - "./unraid-ui" - "./web" - "./packages/*" - - "./tests/bats" - "./tests/system-integration" diff --git a/tests/bats/README.md b/tests/bats/README.md deleted file mode 100644 index 8a870e85ab..0000000000 --- a/tests/bats/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# BATS Integration Tests - -This directory contains BATS (Bash Automated Testing System) integration tests for the unraid-api. - -## Prerequisites - -- BATS installed (via `pnpm install`) -- SSH access to target Unraid server as root -- unraid-api deployed on target server - -## Running Tests - -```bash -# Run all tests -SERVER=tower pnpm test:bats - -# Run integration tests only -SERVER=tower pnpm test:bats:integration - -# Run a specific test file -SERVER=tower pnpm exec bats tests/bats/integration/singleton.bats - -# Run tests matching a pattern -SERVER=tower pnpm exec bats tests/bats/ --filter "start:" -``` - -## Adding New Tests - -1. Create a new `.bats` file in the appropriate directory: - - `integration/` - Tests requiring remote server - -2. Load the common helpers in setup: - ```bash - setup_file() { - load '../test_helper/common' - } - - setup() { - load '../test_helper/common' - # your per-test setup - } - ``` - -3. Write tests using bats-assert functions: - ```bash - @test "example test" { - run my_command - assert_success - assert_output --partial "expected text" - } - ``` - -## Available Assertions (bats-assert) - -| Assertion | Description | -|-----------|-------------| -| `assert_success` | Command exited with status 0 | -| `assert_failure` | Command exited with non-zero status | -| `assert_output "text"` | Check exact output | -| `assert_output --partial "text"` | Check output contains text | -| `assert_output --regexp "pattern"` | Check output matches regex | -| `assert_line "text"` | Check specific line in output | -| `assert_line --index 0 "text"` | Check line at index | -| `refute_output "text"` | Assert output does NOT contain text | -| `assert_regex "$var" "pattern"` | Assert variable matches regex | - -## Helper Functions (common.bash) - -### SSH Execution -| Function | Description | -|----------|-------------| -| `remote_exec ` | Execute command on remote server | -| `remote_exec_safe ` | Execute, ignoring failures | - -### Process Management -| Function | Description | -|----------|-------------| -| `start_api` | Start the API and wait for ready | -| `stop_api [--force]` | Stop the API | -| `cleanup` | Kill all API processes | -| `get_remote_pid` | Get PID from remote PID file | -| `pid_file_exists` | Check if PID file exists | -| `is_process_running ` | Check if process is running | - -### Assertions -| Function | Description | -|----------|-------------| -| `assert_single_api_instance` | Verify exactly one API running | -| `assert_no_api_processes` | Verify no API processes | - -### Wait Helpers -| Function | Description | -|----------|-------------| -| `wait_for_start [timeout]` | Wait for API to start | -| `wait_for_stop [timeout]` | Wait for API to stop | - -## Environment Variables - -| Variable | Required | Description | -|----------|----------|-------------| -| `SERVER` | Yes | SSH server name or IP address | -| `DEFAULT_TIMEOUT` | No | Test timeout in seconds (default: 10) | - -## Directory Structure - -``` -tests/bats/ -├── test_helper/ -│ ├── common.bash # Shared helper functions -│ ├── bats-support/ # Symlink to node_modules -│ └── bats-assert/ # Symlink to node_modules -├── integration/ -│ └── singleton.bats # Singleton process tests -└── README.md # This file -``` diff --git a/tests/bats/examples/example.bats b/tests/bats/examples/example.bats deleted file mode 100644 index 3c1b5f30af..0000000000 --- a/tests/bats/examples/example.bats +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env bats -# example.bats - Example test file demonstrating bats-assert and bats-support -# -# Run with: pnpm test:bats -# Or directly: bats tests/bats/examples/example.bats -# -# This file demonstrates common BATS patterns and assertions. -# Delete or modify this file as needed. - -# ----------------------------------------------------------------------------- -# Setup -# ----------------------------------------------------------------------------- - -setup() { - # Load assertion libraries - load '../test_helper/bats-support/load' - load '../test_helper/bats-assert/load' -} - -# ----------------------------------------------------------------------------- -# Basic Assertions -# ----------------------------------------------------------------------------- - -@test "assert_success: command exits with status 0" { - run echo "hello" - assert_success -} - -@test "assert_failure: command exits with non-zero status" { - run false - assert_failure -} - -@test "assert_failure with specific exit code" { - run bash -c "exit 42" - assert_failure 42 -} - -# ----------------------------------------------------------------------------- -# Output Assertions -# ----------------------------------------------------------------------------- - -@test "assert_output: exact match" { - run echo "hello world" - assert_output "hello world" -} - -@test "assert_output --partial: contains substring" { - run echo "hello world" - assert_output --partial "world" -} - -@test "assert_output --regexp: matches regex" { - run echo "file_2024_01_15.txt" - assert_output --regexp "file_[0-9]{4}_[0-9]{2}_[0-9]{2}\.txt" -} - -@test "refute_output: output does NOT contain" { - run echo "success" - refute_output --partial "error" -} - -# ----------------------------------------------------------------------------- -# Line Assertions -# ----------------------------------------------------------------------------- - -@test "assert_line: output contains line" { - run bash -c "echo -e 'line1\nline2\nline3'" - assert_line "line2" -} - -@test "assert_line --index: specific line number" { - run bash -c "echo -e 'first\nsecond\nthird'" - assert_line --index 0 "first" - assert_line --index 1 "second" - assert_line --index 2 "third" -} - -@test "assert_line --partial: line contains substring" { - run bash -c "echo -e 'error: something failed\nwarning: check this'" - assert_line --partial "something failed" -} - -@test "refute_line: output does NOT contain line" { - run bash -c "echo -e 'info: ok\ninfo: done'" - refute_line --partial "error" -} - -# ----------------------------------------------------------------------------- -# Variable Assertions -# ----------------------------------------------------------------------------- - -@test "assert: test expression" { - result="hello" - assert [ -n "$result" ] - assert [ "$result" = "hello" ] -} - -@test "assert_equal: two values are equal" { - expected="42" - actual="42" - assert_equal "$expected" "$actual" -} - -@test "assert_not_equal: two values differ" { - value1="foo" - value2="bar" - assert_not_equal "$value1" "$value2" -} - -@test "assert_regex: variable matches pattern" { - version="v1.2.3" - assert_regex "$version" "^v[0-9]+\.[0-9]+\.[0-9]+$" -} - -# ----------------------------------------------------------------------------- -# Working with Commands -# ----------------------------------------------------------------------------- - -@test "capture stdout and stderr separately" { - run bash -c "echo 'stdout message'; echo 'stderr message' >&2" - # $output contains both stdout and stderr by default - assert_output --partial "stdout message" - assert_output --partial "stderr message" -} - -@test "check command exists" { - run command -v bash - assert_success -} - -@test "working with JSON output (using grep)" { - run echo '{"status": "ok", "count": 5}' - assert_output --partial '"status": "ok"' -} - -# ----------------------------------------------------------------------------- -# Skipping Tests -# ----------------------------------------------------------------------------- - -@test "skip: conditionally skip a test" { - if [[ -z "${RUN_SLOW_TESTS:-}" ]]; then - skip "RUN_SLOW_TESTS not set" - fi - # This would be a slow test... - run sleep 0.1 - assert_success -} - -@test "skip based on environment" { - [[ -n "${CI:-}" ]] || skip "Only runs in CI" - run echo "running in CI" - assert_success -} diff --git a/tests/bats/integration/singleton_daemon.bats b/tests/bats/integration/singleton_daemon.bats deleted file mode 100644 index 9b65aab068..0000000000 --- a/tests/bats/integration/singleton_daemon.bats +++ /dev/null @@ -1,321 +0,0 @@ -#!/usr/bin/env bats -# singleton_daemon.bats - Tests for unraid-api singleton daemon process management -# -# Usage: SERVER= bats singleton.bats -# SERVER= pnpm test:bats:integration -# -# These tests verify that the unraid-api is properly daemonized as a singleton process. -# See: https://bats-core.readthedocs.io/en/stable/writing-tests.html - -# ----------------------------------------------------------------------------- -# Test Setup -# ----------------------------------------------------------------------------- - -# setup_file runs once before all tests in this file -setup_file() { - load '../test_helper/unraid-api' -} - -# Setup runs before each test - ensure clean state -setup() { - load '../test_helper/unraid-api' - cleanup -} - -# Teardown runs after each test - clean up -teardown() { - cleanup -} - -teardown_file() { - load '../test_helper/unraid-api' - start_api -} - -# ----------------------------------------------------------------------------- -# Start Command Tests -# ----------------------------------------------------------------------------- - -@test "start: creates a single process with PID file" { - # Start the API - run start_api - assert_success - - # Verify PID file exists - run pid_file_exists - assert_success - - # Verify PID is valid (non-empty and numeric) - pid=$(get_remote_pid) - assert [ -n "$pid" ] - assert_regex "$pid" '^[0-9]+$' - - # Verify process is running - run is_process_running "$pid" - assert_success - - # Verify exactly ONE nodemon AND ONE main.js - run assert_single_api_instance - assert_success -} - -@test "start: second start does not create duplicate process" { - # Start the API first - run start_api - assert_success - - # Get the initial PID - initial_pid=$(get_remote_pid) - assert [ -n "$initial_pid" ] - - # Verify single instance initially - run assert_single_api_instance - assert_success - - # Try to start again - run remote_exec "unraid-api start" - # Should succeed (either no-op or restart) - - # Wait a moment for any process changes - sleep 2 - - # Verify still exactly one nodemon AND one main.js (singleton enforcement) - run assert_single_api_instance - assert_success - - # Verify process is still running (either same or new PID after restart) - run pid_file_exists - assert_success - - final_pid=$(get_remote_pid) - assert [ -n "$final_pid" ] - - run is_process_running "$final_pid" - assert_success -} - -@test "start: cleans up stale PID file" { - # Create a stale PID file with a non-existent PID - run remote_exec "mkdir -p /var/run/unraid-api && echo '99999' > '${REMOTE_PID_PATH}'" - assert_success - - # Start should clean up and proceed - run start_api - assert_success - - # Verify new valid PID (not the stale one) - pid=$(get_remote_pid) - assert [ -n "$pid" ] - assert [ "$pid" != "99999" ] - - # Verify process is actually running - run is_process_running "$pid" - assert_success -} - -@test "start: cleans up orphaned nodemon process" { - # Start API normally - run start_api - assert_success - - # Remove PID file but leave process running (simulate orphan) - run remote_exec "rm -f '${REMOTE_PID_PATH}'" - assert_success - - # Verify orphaned process still running - count=$(count_nodemon_processes) - assert [ "$count" -eq 1 ] - - # Start should detect orphan and clean it up - run start_api - assert_success - - # Should still have exactly one process - count=$(count_nodemon_processes) - assert [ "$count" -eq 1 ] - - # PID file should exist again - run pid_file_exists - assert_success -} - -# ----------------------------------------------------------------------------- -# Status Command Tests -# ----------------------------------------------------------------------------- - -@test "status: reports running when API is active" { - # Start the API - run start_api - assert_success - - # Check status - should contain "running" - output=$(get_status) - assert_regex "$output" "running" -} - -@test "status: reports not running when API is stopped" { - # Ensure API is stopped (cleanup already called in setup) - - # Check status - should indicate not running - output=$(get_status) - assert_regex "$output" "not running" -} - -# ----------------------------------------------------------------------------- -# Stop Command Tests -# ----------------------------------------------------------------------------- - -@test "stop: cleanly terminates all processes" { - # Start the API first - run start_api - assert_success - - # Verify it's running - pid=$(get_remote_pid) - assert [ -n "$pid" ] - - # Verify single instance before stop - run assert_single_api_instance - assert_success - - # Stop the API - run stop_api - assert_success - - # Verify PID file is removed - run pid_file_exists - assert_failure - - # Verify NO nodemon AND NO main.js processes remain - run assert_no_api_processes - assert_success -} - -@test "stop --force: terminates all processes immediately" { - # Start the API - run start_api - assert_success - - # Get the PID - pid=$(get_remote_pid) - assert [ -n "$pid" ] - - # Verify single instance before stop - run assert_single_api_instance - assert_success - - # Force stop - run stop_api --force - assert_success - - # Verify PID file is removed - run pid_file_exists - assert_failure - - # Verify NO processes remain (nodemon AND main.js) - run assert_no_api_processes - assert_success -} - -# ----------------------------------------------------------------------------- -# Restart Command Tests -# ----------------------------------------------------------------------------- - -@test "restart: creates new process when already running" { - # Start the API - run start_api - assert_success - - # Get the initial PID - initial_pid=$(get_remote_pid) - assert [ -n "$initial_pid" ] - - # Verify single instance initially - run assert_single_api_instance - assert_success - - # Restart the API - run remote_exec "unraid-api restart" - assert_success - - # Wait for restart to complete - sleep 3 - run wait_for_start 10 - assert_success - - # Get new PID - new_pid=$(get_remote_pid) - assert [ -n "$new_pid" ] - - # PIDs should be different (process was actually restarted) - assert [ "$initial_pid" != "$new_pid" ] - - # Verify exactly one nodemon AND one main.js after restart - run assert_single_api_instance - assert_success -} - -@test "restart: works when API is not running" { - # Ensure API is stopped (cleanup already called in setup) - - # Restart should start the API - run remote_exec "unraid-api restart" - assert_success - - # Wait for start - run wait_for_start 10 - assert_success - - # Verify process is running - pid=$(get_remote_pid) - assert [ -n "$pid" ] - - run is_process_running "$pid" - assert_success -} - -# ----------------------------------------------------------------------------- -# Edge Case Tests -# ----------------------------------------------------------------------------- - -@test "concurrent starts: result in single process" { - # Launch multiple starts concurrently - run remote_exec "unraid-api start & unraid-api start & wait" - - # Wait for things to settle - sleep 3 - - # Must have exactly one nodemon AND one main.js, not just "one nodemon" - run assert_single_api_instance - assert_success - - # PID file should exist - run pid_file_exists - assert_success -} - -@test "recovery: API recovers after process is killed externally" { - # Start the API - run start_api - assert_success - - pid=$(get_remote_pid) - assert [ -n "$pid" ] - - # Kill the process directly (simulate crash) - run remote_exec "kill -9 '$pid'" - - # Wait for process to die - sleep 1 - - # Start should recover - run start_api - assert_success - - # Verify new process running - new_pid=$(get_remote_pid) - assert [ -n "$new_pid" ] - - run is_process_running "$new_pid" - assert_success -} diff --git a/tests/bats/package.json b/tests/bats/package.json deleted file mode 100644 index fe88c7f3a1..0000000000 --- a/tests/bats/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@unraid/bats-tests", - "version": "0.0.0", - "private": true, - "description": "BATS integration tests for unraid-api", - "scripts": { - "postinstall": "./setup.sh", - "test": "bats --recursive .", - "test:integration": "bats --recursive integration/" - }, - "devDependencies": { - "bats": "^1.13.0", - "bats-assert": "^2.2.4", - "bats-support": "^0.3.0" - } -} diff --git a/tests/bats/setup.sh b/tests/bats/setup.sh deleted file mode 100755 index 202ae23d60..0000000000 --- a/tests/bats/setup.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# setup.sh - Setup symlinks for BATS libraries from node_modules -# -# This script is called by postinstall to link bats-support and bats-assert -# from node_modules into test_helper/ where BATS can load them. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -NODE_MODULES="$SCRIPT_DIR/node_modules" - -# Create test_helper directory if needed -mkdir -p "$SCRIPT_DIR/test_helper" - -# Create symlinks (use -f to overwrite existing) -if [[ -d "$NODE_MODULES/bats-support" ]]; then - ln -sfn "$NODE_MODULES/bats-support" "$SCRIPT_DIR/test_helper/bats-support" -fi - -if [[ -d "$NODE_MODULES/bats-assert" ]]; then - ln -sfn "$NODE_MODULES/bats-assert" "$SCRIPT_DIR/test_helper/bats-assert" -fi - -echo "BATS libraries linked successfully" diff --git a/tests/bats/test_helper/common.bash b/tests/bats/test_helper/common.bash deleted file mode 100644 index ceb42ed40b..0000000000 --- a/tests/bats/test_helper/common.bash +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -# common.bash - Base helper that loads BATS assertion libraries -# -# Usage in tests: -# load '../test_helper/common' - -load 'bats-support/load' -load 'bats-assert/load' diff --git a/tests/bats/test_helper/ssh.bash b/tests/bats/test_helper/ssh.bash deleted file mode 100644 index 1b77b074ad..0000000000 --- a/tests/bats/test_helper/ssh.bash +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -# ssh.bash - SSH execution helpers for remote server testing -# -# Requires: SERVER environment variable -# -# Usage in tests: -# load '../test_helper/ssh' - -load 'common' - -: "${SERVER:?SERVER environment variable must be set}" - -# Execute a command on the remote server -remote_exec() { - ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${SERVER}" "$@" -} - -# Execute a command on the remote server, ignoring failures (for cleanup) -remote_exec_safe() { - ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${SERVER}" "$@" 2>/dev/null || true -} diff --git a/tests/bats/test_helper/unraid-api.bash b/tests/bats/test_helper/unraid-api.bash deleted file mode 100644 index a55e4e4c52..0000000000 --- a/tests/bats/test_helper/unraid-api.bash +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env bash -# unraid-api.bash - Helpers for testing unraid-api daemon process management -# -# Requires: SERVER environment variable -# -# Usage in tests: -# load '../test_helper/unraid-api' - -load 'ssh' - -# Remote paths -export REMOTE_PID_PATH="/var/run/unraid-api/nodemon.pid" - -# Timeouts (seconds) -export DEFAULT_TIMEOUT=10 - -# ----------------------------------------------------------------------------- -# Process Query Helpers -# ----------------------------------------------------------------------------- - -# Get the PID from the remote PID file, returns empty if not found -get_remote_pid() { - remote_exec "cat '${REMOTE_PID_PATH}' 2>/dev/null || true" | tr -d '[:space:]' -} - -# Check if the PID file exists on the remote server (returns 0 if exists, 1 if not) -pid_file_exists() { - remote_exec "test -f '${REMOTE_PID_PATH}'" 2>/dev/null -} - -# Check if a process is running on the remote server -is_process_running() { - local pid="$1" - [[ -n "$pid" ]] && remote_exec "kill -0 '${pid}' 2>/dev/null" -} - -# Count nodemon processes matching our config on remote server -count_nodemon_processes() { - local result - result=$(remote_exec "ps -eo pid,args 2>/dev/null | grep -E 'nodemon.*nodemon.json' | grep -v grep | wc -l" 2>/dev/null || echo "0") - echo "${result}" | tr -d '[:space:]' -} - -# Count main.js worker processes (children of nodemon) -count_main_processes() { - local result - result=$(remote_exec "ps -eo args 2>/dev/null | grep -E 'node.*dist/main\.js' | grep -v grep | wc -l" 2>/dev/null || echo "0") - echo "${result}" | tr -d '[:space:]' -} - -# Count all unraid-api related processes (nodemon + main.js) -count_unraid_api_processes() { - local nodemon_count main_count - nodemon_count=$(count_nodemon_processes) - main_count=$(count_main_processes) - echo $((nodemon_count + main_count)) -} - -# ----------------------------------------------------------------------------- -# Process Assertions -# ----------------------------------------------------------------------------- - -# Assert exactly one nodemon and one main.js process -assert_single_api_instance() { - local nodemon_count main_count - nodemon_count=$(count_nodemon_processes) - main_count=$(count_main_processes) - - if [[ "$nodemon_count" -ne 1 ]]; then - echo "Expected 1 nodemon process, found $nodemon_count" >&2 - remote_exec "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" >&2 - return 1 - fi - - if [[ "$main_count" -ne 1 ]]; then - echo "Expected 1 main.js process, found $main_count" >&2 - remote_exec "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" >&2 - return 1 - fi - - return 0 -} - -# Assert no API processes running -assert_no_api_processes() { - local nodemon_count main_count - nodemon_count=$(count_nodemon_processes) - main_count=$(count_main_processes) - - if [[ "$nodemon_count" -ne 0 ]] || [[ "$main_count" -ne 0 ]]; then - echo "Expected 0 processes, found nodemon=$nodemon_count main.js=$main_count" >&2 - remote_exec "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" >&2 - return 1 - fi - - return 0 -} - -# ----------------------------------------------------------------------------- -# Wait Helpers -# ----------------------------------------------------------------------------- - -# Wait for a process to start (PID file to exist and process running) -wait_for_start() { - local timeout="${1:-$DEFAULT_TIMEOUT}" - local deadline=$((SECONDS + timeout)) - - while [[ $SECONDS -lt $deadline ]]; do - local pid - pid=$(get_remote_pid) - if [[ -n "$pid" ]] && is_process_running "$pid"; then - return 0 - fi - sleep 1 - done - - return 1 -} - -# Wait for a process to stop (PID file removed or process not running) -wait_for_stop() { - local timeout="${1:-$DEFAULT_TIMEOUT}" - local deadline=$((SECONDS + timeout)) - - while [[ $SECONDS -lt $deadline ]]; do - local pid - pid=$(get_remote_pid) - if [[ -z "$pid" ]]; then - return 0 - fi - if ! is_process_running "$pid"; then - return 0 - fi - sleep 1 - done - - return 1 -} - -# Wait for all unraid-api processes to stop -wait_for_all_processes_stop() { - local timeout="${1:-$DEFAULT_TIMEOUT}" - local deadline=$((SECONDS + timeout)) - - while [[ $SECONDS -lt $deadline ]]; do - local count - count=$(count_unraid_api_processes) - if [[ "$count" -eq 0 ]]; then - return 0 - fi - sleep 1 - done - - return 1 -} - -# ----------------------------------------------------------------------------- -# API Lifecycle Helpers -# ----------------------------------------------------------------------------- - -# Clean up: stop any running unraid-api processes -cleanup() { - # Step 1: Try graceful stop via unraid-api - remote_exec_safe "unraid-api stop 2>/dev/null; true" - sleep 1 - - # Step 2: Check if processes remain - local count - count=$(count_unraid_api_processes) - if [[ "$count" -eq 0 ]]; then - remote_exec_safe "rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true" - return 0 - fi - - # Step 3: Force kill - nodemon FIRST (prevents restart of child) - remote_exec_safe "pkill -KILL -f 'nodemon.*nodemon.json' 2>/dev/null; true" - sleep 0.5 - - # Step 4: Force kill - then main.js children - remote_exec_safe "pkill -KILL -f 'node.*dist/main.js' 2>/dev/null; true" - sleep 1 - - # Step 5: Clean up PID file - remote_exec_safe "rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true" - - # Step 6: Verify - if still running, try harder with explicit PIDs - count=$(count_unraid_api_processes) - if [[ "$count" -ne 0 ]]; then - local pids - pids=$(remote_exec_safe "ps -eo pid,args | grep -E 'nodemon.*nodemon.json|node.*dist/main.js' | grep -v grep | awk '{print \$1}'" 2>/dev/null || true) - for pid in $pids; do - remote_exec_safe "kill -9 $pid 2>/dev/null; true" - done - sleep 1 - fi - - # Final check - count=$(count_unraid_api_processes) - if [[ "$count" -ne 0 ]]; then - echo "WARNING: Cleanup incomplete, remaining processes:" >&2 - remote_exec_safe "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" >&2 - fi - - return 0 -} - -# Start the API and wait for it to be ready -start_api() { - remote_exec "unraid-api start" - wait_for_start -} - -# Stop the API using unraid-api stop command -stop_api() { - local force="${1:-}" - if [[ "$force" == "--force" ]]; then - remote_exec "unraid-api stop --force" - else - remote_exec "unraid-api stop" - fi - - wait_for_stop - wait_for_all_processes_stop 10 -} - -# Get status output from remote -get_status() { - remote_exec "unraid-api status 2>&1" || true -} diff --git a/tests/system-integration/.prettierrc.cjs b/tests/system-integration/.prettierrc.cjs new file mode 100644 index 0000000000..f0ea83b21d --- /dev/null +++ b/tests/system-integration/.prettierrc.cjs @@ -0,0 +1,11 @@ +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import("prettier").Config} + */ +module.exports = { + trailingComma: 'es5', + tabWidth: 4, + semi: true, + singleQuote: true, + printWidth: 105, +}; diff --git a/tests/system-integration/README.md b/tests/system-integration/README.md index bef4df1b6e..dd489d93b0 100644 --- a/tests/system-integration/README.md +++ b/tests/system-integration/README.md @@ -1,186 +1,22 @@ # System Integration Tests -TypeScript + Vitest integration tests for the unraid-api daemon process management. These tests validate singleton daemon behavior by executing commands on a remote Unraid server via SSH. +Integration tests that run against a live Unraid server via SSH. ## Prerequisites -- Node.js 22+ -- pnpm - SSH key-based authentication to the target Unraid server -- The target server must have `unraid-api` installed and accessible - -## Installation - -```bash -cd tests/system-integration -pnpm install -``` - -Or from the monorepo root: - -```bash -pnpm install -``` +- `unraid-api` installed on the target server ## Usage -### Running Tests - -Tests require the `SERVER` environment variable to specify the target Unraid server: - ```bash -# Run all tests +# Run tests SERVER=tower pnpm test -# Run tests in watch mode -SERVER=tower pnpm test:watch - # From monorepo root SERVER=tower pnpm test:system ``` -### Environment Variables - -| Variable | Required | Description | -|----------|----------|-------------| -| `SERVER` | Yes | Hostname or IP address of the target Unraid server | - -## Test Coverage - -The test suite validates daemon singleton process management: - -### Start Command Tests -- Creates a single process with PID file -- Second start does not create duplicate process -- Cleans up stale PID file -- Cleans up orphaned nodemon process - -### Status Command Tests -- Reports running when API is active -- Reports not running when API is stopped - -### Stop Command Tests -- Cleanly terminates all processes -- Force stop terminates all processes immediately - -### Restart Command Tests -- Creates new process when already running -- Works when API is not running - -### Edge Case Tests -- Concurrent starts result in single process -- API recovers after process is killed externally - -## Architecture - -### Process Model - -The unraid-api runs as a singleton daemon with two processes: - -``` -nodemon (supervisor) -└── node dist/main.js (worker) -``` - -- **nodemon**: Process supervisor that monitors and restarts the main process -- **main.js**: The actual API server - -### PID File - -The daemon tracks its process via a PID file: -``` -/var/run/unraid-api/nodemon.pid -``` - -## Project Structure - -``` -tests/system-integration/ -├── package.json -├── tsconfig.json -├── vitest.config.ts -├── README.md -└── src/ - ├── helpers/ - │ ├── ssh.ts # SSH execution via execa - │ ├── process.ts # Process query/assertion helpers - │ └── api-lifecycle.ts # Start/stop/cleanup helpers - └── tests/ - └── singleton-daemon.test.ts -``` - -### Helper Modules - -#### `ssh.ts` -Remote command execution via SSH: -- `remoteExec(cmd)` - Execute command, return result -- `remoteExecSafe(cmd)` - Execute command, ignore failures (for cleanup) - -#### `process.ts` -Process inspection and assertions: -- `getRemotePid()` - Read PID from file -- `pidFileExists()` - Check PID file existence -- `isProcessRunning(pid)` - Verify process is alive -- `countNodemonProcesses()` - Count nodemon instances -- `countMainProcesses()` - Count main.js workers -- `assertSingleApiInstance()` - Assert exactly 1 nodemon + 1 main.js -- `assertNoApiProcesses()` - Assert all processes stopped - -#### `api-lifecycle.ts` -High-level daemon management: -- `startApi()` - Start and wait for ready -- `stopApi(force?)` - Stop with optional force flag -- `cleanup()` - Multi-step process cleanup -- `waitForStart(timeout)` - Poll until started -- `waitForStop(timeout)` - Poll until stopped -- `getStatus()` - Get status output - -## Configuration - -### Vitest Configuration - -The tests run sequentially (not in parallel) since they interact with shared server state: - -```typescript -// vitest.config.ts -export default defineConfig({ - test: { - globals: true, - testTimeout: 60000, // SSH operations can be slow - hookTimeout: 60000, - sequence: { - concurrent: false, // Run tests sequentially - }, - pool: 'forks', - poolOptions: { - forks: { - singleFork: true, // Single process for all tests - }, - }, - }, -}); -``` - -### SSH Configuration - -SSH connections use these options: -- `ConnectTimeout=10` - 10 second connection timeout -- `BatchMode=yes` - Disable password prompts (requires key auth) -- `StrictHostKeyChecking=accept-new` - Auto-accept new host keys - -## Comparison with BATS Tests - -This package is a TypeScript port of the BATS test suite in `tests/bats/`. Key differences: - -| Feature | BATS | TypeScript/Vitest | -|---------|------|-------------------| -| Language | Bash | TypeScript | -| Test Runner | bats-core | Vitest | -| Assertions | bats-assert | Vitest expect() | -| SSH Execution | Raw ssh command | execa | -| Async Model | Synchronous shell | Async/await | -| Type Safety | None | Full TypeScript types | - ## Troubleshooting ### SSH Connection Fails @@ -188,26 +24,15 @@ This package is a TypeScript port of the BATS test suite in `tests/bats/`. Key d Ensure SSH key authentication is configured: ```bash -# Test SSH connection ssh root@tower echo "Connected" -# If prompted for password, set up key auth: +# If prompted for password: ssh-copy-id root@tower ``` -### Tests Time Out - -Increase timeouts in `vitest.config.ts` or individual tests: - -```typescript -it('slow test', async () => { - // ... -}, 120000); // 2 minute timeout -``` - ### Processes Not Cleaned Up -If tests fail and leave processes running, manually clean up: +If tests fail and leave processes running: ```bash ssh root@tower 'unraid-api stop --force; pkill -f nodemon; pkill -f main.js' diff --git a/tests/system-integration/eslint.config.ts b/tests/system-integration/eslint.config.ts new file mode 100644 index 0000000000..1b4dfa19fa --- /dev/null +++ b/tests/system-integration/eslint.config.ts @@ -0,0 +1,19 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.ts'], + rules: { + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }], + 'eol-last': ['error', 'always'], + }, + }, + { + ignores: ['node_modules/**/*', 'dist/**/*'], + } +); diff --git a/tests/system-integration/package.json b/tests/system-integration/package.json index e42d450e9f..e1b476dd7c 100644 --- a/tests/system-integration/package.json +++ b/tests/system-integration/package.json @@ -6,11 +6,20 @@ "description": "System integration tests for unraid-api", "scripts": { "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "lint": "pnpm lint:eslint && pnpm lint:prettier", + "lint:eslint": "eslint --cache --config eslint.config.ts src/", + "lint:prettier": "prettier --check \"src/**/*.ts\"", + "lint:fix": "eslint --cache --fix --config eslint.config.ts src/ && prettier --write \"src/**/*.ts\"" }, "devDependencies": { + "@eslint/js": "^9.34.0", + "eslint": "^9.34.0", "execa": "^9.6.0", + "jiti": "^2.5.1", + "prettier": "^3.6.2", "typescript": "^5.9.2", + "typescript-eslint": "^8.41.0", "vitest": "^3.2.4" } } diff --git a/tests/system-integration/src/helpers/api-lifecycle.ts b/tests/system-integration/src/helpers/api-lifecycle.ts index a5f54aefdf..c769abacac 100644 --- a/tests/system-integration/src/helpers/api-lifecycle.ts +++ b/tests/system-integration/src/helpers/api-lifecycle.ts @@ -23,12 +23,7 @@ */ import { remoteExec, remoteExecSafe } from './ssh.js'; -import { - getRemotePid, - isProcessRunning, - countUnraidApiProcesses, - REMOTE_PID_PATH, -} from './process.js'; +import { getRemotePid, isProcessRunning, countUnraidApiProcesses, REMOTE_PID_PATH } from './process.js'; /** * Default timeout for wait operations in milliseconds. @@ -40,7 +35,7 @@ const DEFAULT_TIMEOUT = 10000; * @param ms - Duration to sleep in milliseconds */ function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } /** @@ -59,17 +54,17 @@ function sleep(ms: number): Promise { * ``` */ export async function waitForStart(timeout = DEFAULT_TIMEOUT): Promise { - const deadline = Date.now() + timeout; + const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - const pid = await getRemotePid(); - if (pid && (await isProcessRunning(pid))) { - return true; + while (Date.now() < deadline) { + const pid = await getRemotePid(); + if (pid && (await isProcessRunning(pid))) { + return true; + } + await sleep(1000); } - await sleep(1000); - } - return false; + return false; } /** @@ -86,20 +81,20 @@ export async function waitForStart(timeout = DEFAULT_TIMEOUT): Promise * ``` */ export async function waitForStop(timeout = DEFAULT_TIMEOUT): Promise { - const deadline = Date.now() + timeout; + const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - const pid = await getRemotePid(); - if (!pid) { - return true; - } - if (!(await isProcessRunning(pid))) { - return true; + while (Date.now() < deadline) { + const pid = await getRemotePid(); + if (!pid) { + return true; + } + if (!(await isProcessRunning(pid))) { + return true; + } + await sleep(1000); } - await sleep(1000); - } - return false; + return false; } /** @@ -115,20 +110,18 @@ export async function waitForStop(timeout = DEFAULT_TIMEOUT): Promise { * expect(allStopped).toBe(true); * ``` */ -export async function waitForAllProcessesStop( - timeout = DEFAULT_TIMEOUT -): Promise { - const deadline = Date.now() + timeout; +export async function waitForAllProcessesStop(timeout = DEFAULT_TIMEOUT): Promise { + const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - const count = await countUnraidApiProcesses(); - if (count === 0) { - return true; + while (Date.now() < deadline) { + const count = await countUnraidApiProcesses(); + if (count === 0) { + return true; + } + await sleep(1000); } - await sleep(1000); - } - return false; + return false; } /** @@ -156,49 +149,49 @@ export async function waitForAllProcessesStop( * ``` */ export async function cleanup(): Promise { - // Step 1: Try graceful stop via unraid-api - await remoteExecSafe('unraid-api stop 2>/dev/null; true'); - await sleep(1000); + // Step 1: Try graceful stop via unraid-api + await remoteExecSafe('unraid-api stop 2>/dev/null; true'); + await sleep(1000); - // Step 2: Check if processes remain - let count = await countUnraidApiProcesses(); - if (count === 0) { - await remoteExecSafe(`rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true`); - return; - } + // Step 2: Check if processes remain + let count = await countUnraidApiProcesses(); + if (count === 0) { + await remoteExecSafe(`rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true`); + return; + } - // Step 3: Force kill - nodemon FIRST (prevents restart of child) - await remoteExecSafe("pkill -KILL -f 'nodemon.*nodemon.json' 2>/dev/null; true"); - await sleep(500); + // Step 3: Force kill - nodemon FIRST (prevents restart of child) + await remoteExecSafe("pkill -KILL -f 'nodemon.*nodemon.json' 2>/dev/null; true"); + await sleep(500); - // Step 4: Force kill - then main.js children - await remoteExecSafe("pkill -KILL -f 'node.*dist/main.js' 2>/dev/null; true"); - await sleep(1000); + // Step 4: Force kill - then main.js children + await remoteExecSafe("pkill -KILL -f 'node.*dist/main.js' 2>/dev/null; true"); + await sleep(1000); - // Step 5: Clean up PID file - await remoteExecSafe(`rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true`); + // Step 5: Clean up PID file + await remoteExecSafe(`rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true`); - // Step 6: Verify - if still running, try harder with explicit PIDs - count = await countUnraidApiProcesses(); - if (count !== 0) { - const pidsResult = await remoteExecSafe( - "ps -eo pid,args | grep -E 'nodemon.*nodemon.json|node.*dist/main.js' | grep -v grep | awk '{print $1}'" - ); - const pids = pidsResult.stdout.trim().split('\n').filter(Boolean); - for (const pid of pids) { - await remoteExecSafe(`kill -9 ${pid} 2>/dev/null; true`); + // Step 6: Verify - if still running, try harder with explicit PIDs + count = await countUnraidApiProcesses(); + if (count !== 0) { + const pidsResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon.*nodemon.json|node.*dist/main.js' | grep -v grep | awk '{print $1}'" + ); + const pids = pidsResult.stdout.trim().split('\n').filter(Boolean); + for (const pid of pids) { + await remoteExecSafe(`kill -9 ${pid} 2>/dev/null; true`); + } + await sleep(1000); } - await sleep(1000); - } - // Final check - count = await countUnraidApiProcesses(); - if (count !== 0) { - const psResult = await remoteExecSafe( - "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" - ); - console.warn(`WARNING: Cleanup incomplete, remaining processes:\n${psResult.stdout}`); - } + // Final check + count = await countUnraidApiProcesses(); + if (count !== 0) { + const psResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" + ); + console.warn(`WARNING: Cleanup incomplete, remaining processes:\n${psResult.stdout}`); + } } /** @@ -214,14 +207,14 @@ export async function cleanup(): Promise { * ``` */ export async function startApi(): Promise { - const result = await remoteExec('unraid-api start'); - if (result.exitCode !== 0) { - throw new Error(`Failed to start API: ${result.stderr}`); - } - const started = await waitForStart(); - if (!started) { - throw new Error('API did not start within timeout'); - } + const result = await remoteExec('unraid-api start'); + if (result.exitCode !== 0) { + throw new Error(`Failed to start API: ${result.stderr}`); + } + const started = await waitForStart(); + if (!started) { + throw new Error('API did not start within timeout'); + } } /** @@ -241,13 +234,13 @@ export async function startApi(): Promise { * ``` */ export async function stopApi(force = false): Promise { - const cmd = force ? 'unraid-api stop --force' : 'unraid-api stop'; - const result = await remoteExec(cmd); - if (result.exitCode !== 0) { - throw new Error(`Failed to stop API: ${result.stderr}`); - } - await waitForStop(); - await waitForAllProcessesStop(10000); + const cmd = force ? 'unraid-api stop --force' : 'unraid-api stop'; + const result = await remoteExec(cmd); + if (result.exitCode !== 0) { + throw new Error(`Failed to stop API: ${result.stderr}`); + } + await waitForStop(); + await waitForAllProcessesStop(10000); } /** @@ -266,6 +259,6 @@ export async function stopApi(force = false): Promise { * ``` */ export async function getStatus(): Promise { - const result = await remoteExec('unraid-api status 2>&1'); - return result.stdout; + const result = await remoteExec('unraid-api status 2>&1'); + return result.stdout; } diff --git a/tests/system-integration/src/helpers/process.ts b/tests/system-integration/src/helpers/process.ts index a2c1d0f3cf..cb6116cf1d 100644 --- a/tests/system-integration/src/helpers/process.ts +++ b/tests/system-integration/src/helpers/process.ts @@ -41,8 +41,8 @@ export const REMOTE_PID_PATH = '/var/run/unraid-api/nodemon.pid'; * ``` */ export async function getRemotePid(): Promise { - const result = await remoteExec(`cat '${REMOTE_PID_PATH}' 2>/dev/null || true`); - return result.stdout.trim(); + const result = await remoteExec(`cat '${REMOTE_PID_PATH}' 2>/dev/null || true`); + return result.stdout.trim(); } /** @@ -58,8 +58,8 @@ export async function getRemotePid(): Promise { * ``` */ export async function pidFileExists(): Promise { - const result = await remoteExec(`test -f '${REMOTE_PID_PATH}'`); - return result.exitCode === 0; + const result = await remoteExec(`test -f '${REMOTE_PID_PATH}'`); + return result.exitCode === 0; } /** @@ -78,9 +78,9 @@ export async function pidFileExists(): Promise { * ``` */ export async function isProcessRunning(pid: string): Promise { - if (!pid) return false; - const result = await remoteExec(`kill -0 '${pid}' 2>/dev/null`); - return result.exitCode === 0; + if (!pid) return false; + const result = await remoteExec(`kill -0 '${pid}' 2>/dev/null`); + return result.exitCode === 0; } /** @@ -96,11 +96,11 @@ export async function isProcessRunning(pid: string): Promise { * ``` */ export async function countNodemonProcesses(): Promise { - const result = await remoteExecSafe( - "ps -eo pid,args 2>/dev/null | grep -E 'nodemon.*nodemon.json' | grep -v grep | wc -l" - ); - const count = parseInt(result.stdout.trim(), 10); - return isNaN(count) ? 0 : count; + const result = await remoteExecSafe( + "ps -eo pid,args 2>/dev/null | grep -E 'nodemon.*nodemon.json' | grep -v grep | wc -l" + ); + const count = parseInt(result.stdout.trim(), 10); + return isNaN(count) ? 0 : count; } /** @@ -116,11 +116,11 @@ export async function countNodemonProcesses(): Promise { * ``` */ export async function countMainProcesses(): Promise { - const result = await remoteExecSafe( - "ps -eo args 2>/dev/null | grep -E 'node.*dist/main\\.js' | grep -v grep | wc -l" - ); - const count = parseInt(result.stdout.trim(), 10); - return isNaN(count) ? 0 : count; + const result = await remoteExecSafe( + "ps -eo args 2>/dev/null | grep -E 'node.*dist/main\\.js' | grep -v grep | wc -l" + ); + const count = parseInt(result.stdout.trim(), 10); + return isNaN(count) ? 0 : count; } /** @@ -136,9 +136,9 @@ export async function countMainProcesses(): Promise { * ``` */ export async function countUnraidApiProcesses(): Promise { - const nodemonCount = await countNodemonProcesses(); - const mainCount = await countMainProcesses(); - return nodemonCount + mainCount; + const nodemonCount = await countNodemonProcesses(); + const mainCount = await countMainProcesses(); + return nodemonCount + mainCount; } /** @@ -154,26 +154,22 @@ export async function countUnraidApiProcesses(): Promise { * ``` */ export async function assertSingleApiInstance(): Promise { - const nodemonCount = await countNodemonProcesses(); - const mainCount = await countMainProcesses(); + const nodemonCount = await countNodemonProcesses(); + const mainCount = await countMainProcesses(); - if (nodemonCount !== 1) { - const psResult = await remoteExecSafe( - "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" - ); - throw new Error( - `Expected 1 nodemon process, found ${nodemonCount}\n${psResult.stdout}` - ); - } + if (nodemonCount !== 1) { + const psResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" + ); + throw new Error(`Expected 1 nodemon process, found ${nodemonCount}\n${psResult.stdout}`); + } - if (mainCount !== 1) { - const psResult = await remoteExecSafe( - "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" - ); - throw new Error( - `Expected 1 main.js process, found ${mainCount}\n${psResult.stdout}` - ); - } + if (mainCount !== 1) { + const psResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" + ); + throw new Error(`Expected 1 main.js process, found ${mainCount}\n${psResult.stdout}`); + } } /** @@ -189,15 +185,15 @@ export async function assertSingleApiInstance(): Promise { * ``` */ export async function assertNoApiProcesses(): Promise { - const nodemonCount = await countNodemonProcesses(); - const mainCount = await countMainProcesses(); + const nodemonCount = await countNodemonProcesses(); + const mainCount = await countMainProcesses(); - if (nodemonCount !== 0 || mainCount !== 0) { - const psResult = await remoteExecSafe( - "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" - ); - throw new Error( - `Expected 0 processes, found nodemon=${nodemonCount} main.js=${mainCount}\n${psResult.stdout}` - ); - } + if (nodemonCount !== 0 || mainCount !== 0) { + const psResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" + ); + throw new Error( + `Expected 0 processes, found nodemon=${nodemonCount} main.js=${mainCount}\n${psResult.stdout}` + ); + } } diff --git a/tests/system-integration/src/helpers/ssh.ts b/tests/system-integration/src/helpers/ssh.ts index e4b11c6e21..d550895619 100644 --- a/tests/system-integration/src/helpers/ssh.ts +++ b/tests/system-integration/src/helpers/ssh.ts @@ -23,12 +23,12 @@ import { execa } from 'execa'; * Result of a remote command execution. */ export interface ExecResult { - /** Standard output from the command */ - stdout: string; - /** Standard error from the command */ - stderr: string; - /** Exit code of the command (0 indicates success) */ - exitCode: number; + /** Standard output from the command */ + stdout: string; + /** Standard error from the command */ + stderr: string; + /** Exit code of the command (0 indicates success) */ + exitCode: number; } /** @@ -37,11 +37,11 @@ export interface ExecResult { * @returns The server hostname or IP address */ function getServer(): string { - const server = process.env.SERVER; - if (!server) { - throw new Error('SERVER environment variable must be set'); - } - return server; + const server = process.env.SERVER; + if (!server) { + throw new Error('SERVER environment variable must be set'); + } + return server; } /** @@ -51,9 +51,12 @@ function getServer(): string { * - StrictHostKeyChecking: Automatically accepts new host keys */ const SSH_OPTIONS = [ - '-o', 'ConnectTimeout=10', - '-o', 'BatchMode=yes', - '-o', 'StrictHostKeyChecking=accept-new', + '-o', + 'ConnectTimeout=10', + '-o', + 'BatchMode=yes', + '-o', + 'StrictHostKeyChecking=accept-new', ]; /** @@ -71,16 +74,16 @@ const SSH_OPTIONS = [ * ``` */ export async function remoteExec(cmd: string): Promise { - const server = getServer(); - const result = await execa('ssh', [...SSH_OPTIONS, `root@${server}`, cmd], { - reject: false, - }); + const server = getServer(); + const result = await execa('ssh', [...SSH_OPTIONS, `root@${server}`, cmd], { + reject: false, + }); - return { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode ?? 0, - }; + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode ?? 0, + }; } /** @@ -97,23 +100,23 @@ export async function remoteExec(cmd: string): Promise { * ``` */ export async function remoteExecSafe(cmd: string): Promise { - const server = getServer(); - try { - const result = await execa('ssh', [...SSH_OPTIONS, `root@${server}`, cmd], { - reject: false, - }); - return { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode ?? 0, - }; - } catch { - return { - stdout: '', - stderr: '', - exitCode: 0, - }; - } + const server = getServer(); + try { + const result = await execa('ssh', [...SSH_OPTIONS, `root@${server}`, cmd], { + reject: false, + }); + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode ?? 0, + }; + } catch { + return { + stdout: '', + stderr: '', + exitCode: 0, + }; + } } /** @@ -123,5 +126,5 @@ export async function remoteExecSafe(cmd: string): Promise { * @returns The server hostname or IP address */ export function getServerName(): string { - return getServer(); + return getServer(); } diff --git a/tests/system-integration/src/tests/singleton-daemon.test.ts b/tests/system-integration/src/tests/singleton-daemon.test.ts index f4a428d841..7e335d591a 100644 --- a/tests/system-integration/src/tests/singleton-daemon.test.ts +++ b/tests/system-integration/src/tests/singleton-daemon.test.ts @@ -1,211 +1,205 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { remoteExec } from '../helpers/ssh.js'; import { - getRemotePid, - pidFileExists, - isProcessRunning, - countNodemonProcesses, - assertSingleApiInstance, - assertNoApiProcesses, - REMOTE_PID_PATH, + getRemotePid, + pidFileExists, + isProcessRunning, + countNodemonProcesses, + assertSingleApiInstance, + assertNoApiProcesses, + REMOTE_PID_PATH, } from '../helpers/process.js'; -import { - cleanup, - startApi, - stopApi, - getStatus, - waitForStart, -} from '../helpers/api-lifecycle.js'; +import { cleanup, startApi, stopApi, getStatus, waitForStart } from '../helpers/api-lifecycle.js'; describe('singleton daemon', () => { - beforeAll(async () => { - if (!process.env.SERVER) { - throw new Error('SERVER environment variable must be set'); - } - }); + beforeAll(async () => { + if (!process.env.SERVER) { + throw new Error('SERVER environment variable must be set'); + } + }); - afterAll(async () => { - await cleanup(); - await startApi(); - }); + afterAll(async () => { + await cleanup(); + await startApi(); + }); - beforeEach(async () => { - await cleanup(); - }); + beforeEach(async () => { + await cleanup(); + }); - describe('start command', () => { - it('creates a single process with PID file', async () => { - await startApi(); + describe('start command', () => { + it('creates a single process with PID file', async () => { + await startApi(); - expect(await pidFileExists()).toBe(true); + expect(await pidFileExists()).toBe(true); - const pid = await getRemotePid(); - expect(pid).toBeTruthy(); - expect(pid).toMatch(/^\d+$/); + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + expect(pid).toMatch(/^\d+$/); - expect(await isProcessRunning(pid)).toBe(true); + expect(await isProcessRunning(pid)).toBe(true); - await assertSingleApiInstance(); - }); + await assertSingleApiInstance(); + }); - it('second start does not create duplicate process', async () => { - await startApi(); + it('second start does not create duplicate process', async () => { + await startApi(); - const initialPid = await getRemotePid(); - expect(initialPid).toBeTruthy(); + const initialPid = await getRemotePid(); + expect(initialPid).toBeTruthy(); - await assertSingleApiInstance(); + await assertSingleApiInstance(); - await remoteExec('unraid-api start'); + await remoteExec('unraid-api start'); - await new Promise((resolve) => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)); - await assertSingleApiInstance(); + await assertSingleApiInstance(); - expect(await pidFileExists()).toBe(true); + expect(await pidFileExists()).toBe(true); - const finalPid = await getRemotePid(); - expect(finalPid).toBeTruthy(); + const finalPid = await getRemotePid(); + expect(finalPid).toBeTruthy(); - expect(await isProcessRunning(finalPid)).toBe(true); - }); + expect(await isProcessRunning(finalPid)).toBe(true); + }); - it('cleans up stale PID file', async () => { - await remoteExec(`mkdir -p /var/run/unraid-api && echo '99999' > '${REMOTE_PID_PATH}'`); + it('cleans up stale PID file', async () => { + await remoteExec(`mkdir -p /var/run/unraid-api && echo '99999' > '${REMOTE_PID_PATH}'`); - await startApi(); + await startApi(); - const pid = await getRemotePid(); - expect(pid).toBeTruthy(); - expect(pid).not.toBe('99999'); + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + expect(pid).not.toBe('99999'); - expect(await isProcessRunning(pid)).toBe(true); - }); + expect(await isProcessRunning(pid)).toBe(true); + }); - it('cleans up orphaned nodemon process', async () => { - await startApi(); + it('cleans up orphaned nodemon process', async () => { + await startApi(); - await remoteExec(`rm -f '${REMOTE_PID_PATH}'`); + await remoteExec(`rm -f '${REMOTE_PID_PATH}'`); - const count = await countNodemonProcesses(); - expect(count).toBe(1); + const count = await countNodemonProcesses(); + expect(count).toBe(1); - await startApi(); + await startApi(); - const newCount = await countNodemonProcesses(); - expect(newCount).toBe(1); + const newCount = await countNodemonProcesses(); + expect(newCount).toBe(1); - expect(await pidFileExists()).toBe(true); + expect(await pidFileExists()).toBe(true); + }); }); - }); - describe('status command', () => { - it('reports running when API is active', async () => { - await startApi(); + describe('status command', () => { + it('reports running when API is active', async () => { + await startApi(); - const output = await getStatus(); - expect(output).toMatch(/running/i); - }); + const output = await getStatus(); + expect(output).toMatch(/running/i); + }); - it('reports not running when API is stopped', async () => { - const output = await getStatus(); - expect(output).toMatch(/not running/i); + it('reports not running when API is stopped', async () => { + const output = await getStatus(); + expect(output).toMatch(/not running/i); + }); }); - }); - describe('stop command', () => { - it('cleanly terminates all processes', async () => { - await startApi(); + describe('stop command', () => { + it('cleanly terminates all processes', async () => { + await startApi(); - const pid = await getRemotePid(); - expect(pid).toBeTruthy(); + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); - await assertSingleApiInstance(); + await assertSingleApiInstance(); - await stopApi(); + await stopApi(); - expect(await pidFileExists()).toBe(false); + expect(await pidFileExists()).toBe(false); - await assertNoApiProcesses(); - }); + await assertNoApiProcesses(); + }); - it('stop --force terminates all processes immediately', async () => { - await startApi(); + it('stop --force terminates all processes immediately', async () => { + await startApi(); - const pid = await getRemotePid(); - expect(pid).toBeTruthy(); + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); - await assertSingleApiInstance(); + await assertSingleApiInstance(); - await stopApi(true); + await stopApi(true); - expect(await pidFileExists()).toBe(false); + expect(await pidFileExists()).toBe(false); - await assertNoApiProcesses(); + await assertNoApiProcesses(); + }); }); - }); - describe('restart command', () => { - it('creates new process when already running', async () => { - await startApi(); + describe('restart command', () => { + it('creates new process when already running', async () => { + await startApi(); - const initialPid = await getRemotePid(); - expect(initialPid).toBeTruthy(); + const initialPid = await getRemotePid(); + expect(initialPid).toBeTruthy(); - await assertSingleApiInstance(); + await assertSingleApiInstance(); - await remoteExec('unraid-api restart'); + await remoteExec('unraid-api restart'); - await new Promise((resolve) => setTimeout(resolve, 3000)); - await waitForStart(10000); + await new Promise((resolve) => setTimeout(resolve, 3000)); + await waitForStart(10000); - const newPid = await getRemotePid(); - expect(newPid).toBeTruthy(); + const newPid = await getRemotePid(); + expect(newPid).toBeTruthy(); - expect(initialPid).not.toBe(newPid); + expect(initialPid).not.toBe(newPid); - await assertSingleApiInstance(); - }); + await assertSingleApiInstance(); + }); - it('works when API is not running', async () => { - await remoteExec('unraid-api restart'); + it('works when API is not running', async () => { + await remoteExec('unraid-api restart'); - await waitForStart(10000); + await waitForStart(10000); - const pid = await getRemotePid(); - expect(pid).toBeTruthy(); + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); - expect(await isProcessRunning(pid)).toBe(true); + expect(await isProcessRunning(pid)).toBe(true); + }); }); - }); - describe('edge cases', () => { - it('concurrent starts result in single process', async () => { - await remoteExec('unraid-api start & unraid-api start & wait'); + describe('edge cases', () => { + it('concurrent starts result in single process', async () => { + await remoteExec('unraid-api start & unraid-api start & wait'); - await new Promise((resolve) => setTimeout(resolve, 3000)); + await new Promise((resolve) => setTimeout(resolve, 3000)); - await assertSingleApiInstance(); + await assertSingleApiInstance(); - expect(await pidFileExists()).toBe(true); - }); + expect(await pidFileExists()).toBe(true); + }); - it('API recovers after process is killed externally', async () => { - await startApi(); + it('API recovers after process is killed externally', async () => { + await startApi(); - const pid = await getRemotePid(); - expect(pid).toBeTruthy(); + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); - await remoteExec(`kill -9 '${pid}'`); + await remoteExec(`kill -9 '${pid}'`); - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); - await startApi(); + await startApi(); - const newPid = await getRemotePid(); - expect(newPid).toBeTruthy(); + const newPid = await getRemotePid(); + expect(newPid).toBeTruthy(); - expect(await isProcessRunning(newPid)).toBe(true); + expect(await isProcessRunning(newPid)).toBe(true); + }); }); - }); }); From 0d7bee249071570fabf8018e4ccb57692d86fe38 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 10 Dec 2025 15:36:04 -0500 Subject: [PATCH 25/25] add reboot test --- .gitignore | 4 - pnpm-lock.yaml | 191 ++++++++++++++++++ tests/system-integration/eslint.config.ts | 13 ++ tests/system-integration/package.json | 1 + .../src/helpers/api-lifecycle.ts | 13 +- .../system-integration/src/helpers/server.ts | 63 ++++++ tests/system-integration/src/helpers/utils.ts | 19 ++ .../src/tests/singleton-daemon.test.ts | 35 +++- 8 files changed, 322 insertions(+), 17 deletions(-) create mode 100644 tests/system-integration/src/helpers/server.ts create mode 100644 tests/system-integration/src/helpers/utils.ts diff --git a/.gitignore b/.gitignore index 4ab0014236..8449fad731 100644 --- a/.gitignore +++ b/.gitignore @@ -120,10 +120,6 @@ api/dev/Unraid.net/myservers.cfg # Claude local settings .claude/settings.local.json -# BATS library symlinks (created by tests/bats/setup.sh) -tests/bats/test_helper/bats-support -tests/bats/test_helper/bats-assert - # local Mise settings .mise.toml diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c02ac805cd..4930430442 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -861,6 +861,9 @@ importers: eslint: specifier: ^9.34.0 version: 9.34.0(jiti@2.5.1) + eslint-plugin-unicorn: + specifier: ^62.0.0 + version: 62.0.0(eslint@9.34.0(jiti@2.5.1)) execa: specifier: ^9.6.0 version: 9.6.0 @@ -1644,6 +1647,10 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -2507,6 +2514,12 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.12.1': resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -2523,6 +2536,10 @@ packages: resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/eslintrc@3.3.1': resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2539,6 +2556,10 @@ packages: resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@faker-js/faker@10.0.0': resolution: {integrity: sha512-UollFEUkVXutsaP+Vndjxar40Gs5JL2HeLcl8xO1QAjJgOdhc3OmBFWyEylS+RddWaaBiAzH+5/17PLQJwDiLw==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} @@ -6049,6 +6070,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.9.6: + resolution: {integrity: sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg==} + hasBin: true + basic-auth@2.0.1: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} @@ -6112,6 +6137,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -6132,6 +6162,10 @@ packages: resolution: {integrity: sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==} engines: {node: '>=10.0.0'} + builtin-modules@5.0.0: + resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} + engines: {node: '>=18.20'} + bun-types@1.2.21: resolution: {integrity: sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw==} peerDependencies: @@ -6219,6 +6253,9 @@ packages: caniuse-lite@1.0.30001731: resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==} + caniuse-lite@1.0.30001760: + resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} @@ -6278,6 +6315,10 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} @@ -6290,6 +6331,10 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clean-regexp@1.0.0: + resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} + engines: {node: '>=4'} + cli-color@1.4.0: resolution: {integrity: sha512-xu6RvQqqrWEo6MPR1eixqGPywhYBHRs653F9jfXB2Hx4jdM/3WxiNE1vppRmxtMIfl16SFYTpYlrnqH/HsK/2w==} @@ -6552,6 +6597,9 @@ packages: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} engines: {node: '>=12.13'} + core-js-compat@3.47.0: + resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==} + core-js@2.6.12: resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. @@ -7027,6 +7075,9 @@ packages: electron-to-chromium@1.5.192: resolution: {integrity: sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg==} + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + embla-carousel-auto-height@8.6.0: resolution: {integrity: sha512-/HrJQOEM6aol/oF33gd2QlINcXy3e19fJWvHDuHWp2bpyTa+2dm9tVVJak30m2Qy6QyQ6Fc8DkImtv7pxWOJUQ==} peerDependencies: @@ -7458,6 +7509,12 @@ packages: eslint: '>=8' storybook: ^9.1.3 + eslint-plugin-unicorn@62.0.0: + resolution: {integrity: sha512-HIlIkGLkvf29YEiS/ImuDZQbP12gWyx5i3C6XrRxMvVdqMroCI9qoVYCoIl17ChN+U89pn9sVwLxhIWj5nEc7g==} + engines: {node: ^20.10.0 || >=21.0.0} + peerDependencies: + eslint: '>=9.38.0' + eslint-plugin-vue@10.4.0: resolution: {integrity: sha512-K6tP0dW8FJVZLQxa2S7LcE1lLw3X8VvB3t887Q6CLrFVxHYBXGANbXvwNzYIu6Ughx1bSJ5BDT0YB3ybPT39lw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -7746,6 +7803,10 @@ packages: find-package-json@1.2.0: resolution: {integrity: sha512-+SOGcLGYDJHtyqHd87ysBhmaeQ95oWspDKnMXBrnQ9Eq4OkLNqejgoaD8xVWu6GPa0B6roa6KinCMEMcVeqONw==} + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + find-up@3.0.0: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} @@ -7989,6 +8050,10 @@ packages: resolution: {integrity: sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==} engines: {node: '>=18'} + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -8319,6 +8384,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -8413,6 +8482,10 @@ packages: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} + is-builtin-module@5.0.0: + resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} + engines: {node: '>=18.20'} + is-bun-module@2.0.0: resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} @@ -9523,6 +9596,9 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-window-polyfill@1.0.4: resolution: {integrity: sha512-Od/jDxv5w7gtZfIS+Czy0UgLQldtituEjT9djgykQK4yq/hKySc3GXTXuUvxKvpM+J/+AwO789ojLmq2Jk8coQ==} @@ -10040,6 +10116,10 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + portfinder@1.0.35: resolution: {integrity: sha512-73JaFg4NwYNAufDtS5FsFu/PdM49ahJrO1i44aCRsDWju1z5wuGDaqyFUQWR6aJoK2JPDWlaYYAGFNIGTSUHSw==} engines: {node: '>= 10.12'} @@ -10577,6 +10657,10 @@ packages: resolution: {integrity: sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==} engines: {node: '>=12'} + regjsparser@0.13.0: + resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} + hasBin: true + rehackt@0.1.0: resolution: {integrity: sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==} peerDependencies: @@ -10820,6 +10904,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -11165,6 +11254,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -11900,6 +11993,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-browserslist-db@1.2.2: + resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + upper-case-first@2.0.2: resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} @@ -13121,6 +13220,8 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.27.6': @@ -13815,6 +13916,11 @@ snapshots: eslint: 9.34.0(jiti@2.5.1) eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.0(eslint@9.34.0(jiti@2.5.1))': + dependencies: + eslint: 9.34.0(jiti@2.5.1) + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.12.1': {} '@eslint/config-array@0.21.0': @@ -13831,6 +13937,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 @@ -13854,6 +13964,11 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + '@faker-js/faker@10.0.0': {} '@fastify/ajv-compiler@4.0.2': @@ -17927,6 +18042,8 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.9.6: {} + basic-auth@2.0.1: dependencies: safe-buffer: 5.1.2 @@ -18006,6 +18123,14 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.1) + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.6 + caniuse-lite: 1.0.30001760 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.2(browserslist@4.28.1) + bser@2.1.1: dependencies: node-int64: 0.4.0 @@ -18027,6 +18152,8 @@ snapshots: buildcheck@0.0.6: optional: true + builtin-modules@5.0.0: {} + bun-types@1.2.21(@types/react@19.0.8): dependencies: '@types/node': 22.18.0 @@ -18139,6 +18266,8 @@ snapshots: caniuse-lite@1.0.30001731: {} + caniuse-lite@1.0.30001760: {} + capital-case@1.0.4: dependencies: no-case: 3.0.4 @@ -18233,6 +18362,8 @@ snapshots: chownr@3.0.0: {} + ci-info@4.3.1: {} + citty@0.1.6: dependencies: consola: 3.4.2 @@ -18249,6 +18380,10 @@ snapshots: dependencies: clsx: 2.1.1 + clean-regexp@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + cli-color@1.4.0: dependencies: ansi-regex: 2.1.1 @@ -18493,6 +18628,10 @@ snapshots: dependencies: is-what: 4.1.16 + core-js-compat@3.47.0: + dependencies: + browserslist: 4.28.1 + core-js@2.6.12: {} core-util-is@1.0.3: {} @@ -18963,6 +19102,8 @@ snapshots: electron-to-chromium@1.5.192: {} + electron-to-chromium@1.5.267: {} + embla-carousel-auto-height@8.6.0(embla-carousel@8.6.0): dependencies: embla-carousel: 8.6.0 @@ -19484,6 +19625,28 @@ snapshots: - supports-color - typescript + eslint-plugin-unicorn@62.0.0(eslint@9.34.0(jiti@2.5.1)): + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.34.0(jiti@2.5.1)) + '@eslint/plugin-kit': 0.4.1 + change-case: 5.4.4 + ci-info: 4.3.1 + clean-regexp: 1.0.0 + core-js-compat: 3.47.0 + eslint: 9.34.0(jiti@2.5.1) + esquery: 1.6.0 + find-up-simple: 1.0.1 + globals: 16.5.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + regexp-tree: 0.1.27 + regjsparser: 0.13.0 + semver: 7.7.3 + strip-indent: 4.1.1 + eslint-plugin-vue@10.4.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1))(vue-eslint-parser@10.2.0(eslint@9.34.0(jiti@2.5.1))): dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.5.1)) @@ -19890,6 +20053,8 @@ snapshots: find-package-json@1.2.0: {} + find-up-simple@1.0.1: {} + find-up@3.0.0: dependencies: locate-path: 3.0.0 @@ -20170,6 +20335,8 @@ snapshots: globals@16.3.0: {} + globals@16.5.0: {} + globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -20501,6 +20668,8 @@ snapshots: indent-string@4.0.0: {} + indent-string@5.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -20635,6 +20804,10 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-builtin-module@5.0.0: + dependencies: + builtin-modules: 5.0.0 + is-bun-module@2.0.0: dependencies: semver: 7.7.2 @@ -21728,6 +21901,8 @@ snapshots: node-releases@2.0.19: {} + node-releases@2.0.27: {} + node-window-polyfill@1.0.4: dependencies: ws: 7.5.10 @@ -22439,6 +22614,8 @@ snapshots: dependencies: find-up: 3.0.0 + pluralize@8.0.0: {} + portfinder@1.0.35: dependencies: async: 3.2.6 @@ -22971,6 +23148,10 @@ snapshots: dependencies: rc: 1.2.8 + regjsparser@0.13.0: + dependencies: + jsesc: 3.1.0 + rehackt@0.1.0(@types/react@19.0.8)(react@19.1.0): optionalDependencies: '@types/react': 19.0.8 @@ -23244,6 +23425,8 @@ snapshots: semver@7.7.2: {} + semver@7.7.3: {} + send@0.19.0: dependencies: debug: 2.6.9 @@ -23711,6 +23894,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-indent@4.1.1: {} + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -24466,6 +24651,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.2.2(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + upper-case-first@2.0.2: dependencies: tslib: 2.8.1 diff --git a/tests/system-integration/eslint.config.ts b/tests/system-integration/eslint.config.ts index 1b4dfa19fa..b1e095b5c5 100644 --- a/tests/system-integration/eslint.config.ts +++ b/tests/system-integration/eslint.config.ts @@ -1,16 +1,29 @@ import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; +import unicorn from 'eslint-plugin-unicorn'; export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommended, { files: ['**/*.ts'], + plugins: { + unicorn, + }, rules: { '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-explicit-any': 'off', 'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }], 'eol-last': ['error', 'always'], + 'unicorn/numeric-separators-style': [ + 'error', + { + number: { + minimumDigits: 5, + groupLength: 3, + }, + }, + ], }, }, { diff --git a/tests/system-integration/package.json b/tests/system-integration/package.json index e1b476dd7c..58fa78c524 100644 --- a/tests/system-integration/package.json +++ b/tests/system-integration/package.json @@ -15,6 +15,7 @@ "devDependencies": { "@eslint/js": "^9.34.0", "eslint": "^9.34.0", + "eslint-plugin-unicorn": "^62.0.0", "execa": "^9.6.0", "jiti": "^2.5.1", "prettier": "^3.6.2", diff --git a/tests/system-integration/src/helpers/api-lifecycle.ts b/tests/system-integration/src/helpers/api-lifecycle.ts index c769abacac..a7efe08b91 100644 --- a/tests/system-integration/src/helpers/api-lifecycle.ts +++ b/tests/system-integration/src/helpers/api-lifecycle.ts @@ -24,19 +24,12 @@ import { remoteExec, remoteExecSafe } from './ssh.js'; import { getRemotePid, isProcessRunning, countUnraidApiProcesses, REMOTE_PID_PATH } from './process.js'; +import { sleep, TEN_SECONDS } from './utils.js'; /** * Default timeout for wait operations in milliseconds. */ -const DEFAULT_TIMEOUT = 10000; - -/** - * Utility function to pause execution. - * @param ms - Duration to sleep in milliseconds - */ -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +const DEFAULT_TIMEOUT = TEN_SECONDS; /** * Waits for the API to start by polling for PID file existence and process running state. @@ -240,7 +233,7 @@ export async function stopApi(force = false): Promise { throw new Error(`Failed to stop API: ${result.stderr}`); } await waitForStop(); - await waitForAllProcessesStop(10000); + await waitForAllProcessesStop(TEN_SECONDS); } /** diff --git a/tests/system-integration/src/helpers/server.ts b/tests/system-integration/src/helpers/server.ts new file mode 100644 index 0000000000..06d33aaf12 --- /dev/null +++ b/tests/system-integration/src/helpers/server.ts @@ -0,0 +1,63 @@ +/** + * @fileoverview Server management helpers for system-level operations. + * Provides utilities for rebooting and waiting for server availability. + */ + +import { remoteExecSafe, remoteExec } from './ssh.js'; +import { sleep, FIVE_SECONDS, FIVE_MINUTES, TEN_MINUTES } from './utils.js'; + +/** + * Reboots the remote server. + * Sends a reboot command via SSH and waits briefly for the connection to drop. + */ +export async function rebootServer(): Promise { + await remoteExecSafe('reboot'); +} + +/** + * Waits for the server to go offline after a reboot command. + * Polls SSH connectivity until the server stops responding or timeout is reached. + * + * @param timeout - Maximum time to wait in milliseconds (default: 5 minutes) + * @returns `true` if the server went offline within the timeout, `false` otherwise + */ +export async function waitForServerOffline(timeout = FIVE_MINUTES): Promise { + const deadline = Date.now() + timeout; + const pollInterval = FIVE_SECONDS; + + while (Date.now() < deadline) { + const result = await remoteExec('echo online'); + if (result.exitCode !== 0 || !result.stdout.includes('online')) { + return true; + } + await sleep(pollInterval); + } + + return false; +} + +/** + * Waits for the server to come back online after a reboot. + * Polls SSH connectivity until the server responds or timeout is reached. + * + * @param timeout - Maximum time to wait in milliseconds (default: 10 minutes) + * @returns `true` if the server is online within the timeout, `false` otherwise + */ +export async function waitForServerOnline(timeout = TEN_MINUTES): Promise { + const deadline = Date.now() + timeout; + const pollInterval = FIVE_SECONDS; + + while (Date.now() < deadline) { + try { + const result = await remoteExec('echo online'); + if (result.exitCode === 0 && result.stdout.includes('online')) { + return true; + } + } catch { + // SSH connection failed, server still rebooting + } + await sleep(pollInterval); + } + + return false; +} diff --git a/tests/system-integration/src/helpers/utils.ts b/tests/system-integration/src/helpers/utils.ts new file mode 100644 index 0000000000..8572efb5af --- /dev/null +++ b/tests/system-integration/src/helpers/utils.ts @@ -0,0 +1,19 @@ +/** + * @fileoverview Shared utility functions for system integration tests. + */ + +export const ONE_SECOND = 1000; +export const FIVE_SECONDS = 5 * ONE_SECOND; +export const TEN_SECONDS = 10 * ONE_SECOND; +export const ONE_MINUTE = 60 * ONE_SECOND; +export const FIVE_MINUTES = 5 * ONE_MINUTE; +export const TEN_MINUTES = 10 * ONE_MINUTE; +export const FIFTEEN_MINUTES = 15 * ONE_MINUTE; + +/** + * Utility function to pause execution. + * @param ms - Duration to sleep in milliseconds + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/tests/system-integration/src/tests/singleton-daemon.test.ts b/tests/system-integration/src/tests/singleton-daemon.test.ts index 7e335d591a..aca7907372 100644 --- a/tests/system-integration/src/tests/singleton-daemon.test.ts +++ b/tests/system-integration/src/tests/singleton-daemon.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { remoteExec } from '../helpers/ssh.js'; import { getRemotePid, @@ -10,6 +10,8 @@ import { REMOTE_PID_PATH, } from '../helpers/process.js'; import { cleanup, startApi, stopApi, getStatus, waitForStart } from '../helpers/api-lifecycle.js'; +import { rebootServer, waitForServerOffline, waitForServerOnline } from '../helpers/server.js'; +import { TEN_SECONDS, ONE_MINUTE, FIFTEEN_MINUTES } from '../helpers/utils.js'; describe('singleton daemon', () => { beforeAll(async () => { @@ -151,7 +153,7 @@ describe('singleton daemon', () => { await remoteExec('unraid-api restart'); await new Promise((resolve) => setTimeout(resolve, 3000)); - await waitForStart(10000); + await waitForStart(TEN_SECONDS); const newPid = await getRemotePid(); expect(newPid).toBeTruthy(); @@ -164,7 +166,7 @@ describe('singleton daemon', () => { it('works when API is not running', async () => { await remoteExec('unraid-api restart'); - await waitForStart(10000); + await waitForStart(TEN_SECONDS); const pid = await getRemotePid(); expect(pid).toBeTruthy(); @@ -202,4 +204,31 @@ describe('singleton daemon', () => { expect(await isProcessRunning(newPid)).toBe(true); }); }); + + describe('server reboot', () => { + it( + 'API starts automatically after server reboot', + async () => { + await startApi(); + await assertSingleApiInstance(); + + await rebootServer(); + + const offline = await waitForServerOffline(); + expect(offline).toBe(true); + + const online = await waitForServerOnline(); + expect(online).toBe(true); + + const started = await waitForStart(ONE_MINUTE); + expect(started).toBe(true); + + await assertSingleApiInstance(); + + const status = await getStatus(); + expect(status).toMatch(/running/i); + }, + FIFTEEN_MINUTES + ); + }); });