diff --git a/src/args.ts b/src/args.ts index c2b20ba..d68122d 100644 --- a/src/args.ts +++ b/src/args.ts @@ -6,7 +6,11 @@ import { MIN_CONCURRENCY, } from './lib/constants.js'; import type { Args, OutdatedMap, SkipFileConfig } from './lib/types.js'; -import { isVersionHigher, parseSkipEntry } from './lib/utils.js'; +import { + isSkipFileConfig, + isVersionHigher, + parseSkipEntry, +} from './lib/utils.js'; function isSortBy(value: unknown): value is Args['sortBy'] { return ( value === 'name' || @@ -42,16 +46,46 @@ function isFormat(value: unknown): value is Args['format'] { */ export function parseArgs(argv: string[]): Args { const a = new Map(); + const skipArgs: string[] = []; for (let i = 2; i < argv.length; i += 1) { - const k = argv[i]; - const v = argv[i + 1]; - if (k.startsWith('--')) { + let k = argv[i]; + let v: string | undefined = argv[i + 1]; + if (!k.startsWith('--')) { + continue; + } + // Support --key=value syntax + const eqIdx = k.indexOf('='); + if (eqIdx !== -1) { + v = k.substring(eqIdx + 1); + k = k.substring(0, eqIdx); + } + if (k === '--skip') { + // Accumulate --skip values instead of overwriting if (v && !v.startsWith('--')) { + const parts = v + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0); + for (const part of parts) { + if (!skipArgs.includes(part)) { + skipArgs.push(part); + } + } + if (eqIdx === -1) { + i += 1; + } + } + } else if (v !== undefined && !v.startsWith('--') && eqIdx === -1) { + a.set(k, v); + i += 1; + } else if (eqIdx !== -1 && v !== undefined) { + // For --key= with an empty value (e.g. "--concurrency="), treat it as + // "no value provided" so downstream numeric options keep their defaults. + if (v !== '') { a.set(k, v); - i += 1; - } else { - a.set(k, true); } + } else { + a.set(k, true); } } const sortByRaw = a.get('--sort-by'); @@ -71,12 +105,7 @@ export function parseArgs(argv: string[]): Args { const quiet = Boolean(a.get('--quiet')); const checkAll = Boolean(a.get('--check-all')); const iso = Boolean(a.get('--iso')); - // Parse skip packages from command line - const skipPackages: string[] = []; - const skipValue = a.get('--skip'); - if (skipValue && typeof skipValue === 'string') { - skipPackages.push(...skipValue.split(',').map((p) => p.trim())); - } + const skipPackages = skipArgs; // Load skip packages from default file let fileSkipPackages: string[] = []; @@ -86,11 +115,14 @@ export function parseArgs(argv: string[]): Args { try { const defaultSkipFile = join(process.cwd(), '.outdated-plus-skip'); const content = readFileSync(defaultSkipFile, 'utf-8'); - skipConfig = JSON.parse(content); - fileSkipPackages = skipConfig?.packages || []; - skipFilePath = defaultSkipFile; + const parsed: unknown = JSON.parse(content); + if (isSkipFileConfig(parsed)) { + skipConfig = parsed; + fileSkipPackages = skipConfig.packages; + skipFilePath = defaultSkipFile; + } } catch { - // Default file doesn't exist, ignore + // Default file doesn't exist or is invalid, ignore } const normalizedSort: Args['sortBy'] = @@ -99,6 +131,7 @@ export function parseArgs(argv: string[]): Args { : sortBy === 'published' ? 'published_latest' : sortBy; + const mergedSkips = [...new Set([...skipPackages, ...fileSkipPackages])]; return { olderThan, showAll, @@ -110,7 +143,7 @@ export function parseArgs(argv: string[]): Args { sortBy: normalizedSort, order, format, - skip: [...skipPackages, ...fileSkipPackages], + skip: mergedSkips, _skipConfig: skipConfig, _skipFilePath: skipFilePath, _commandLineSkips: skipPackages, diff --git a/src/index.ts b/src/index.ts index af82922..7b73ba2 100755 --- a/src/index.ts +++ b/src/index.ts @@ -32,25 +32,61 @@ import { shouldSkipPackage, } from './lib/utils.js'; +/** + * Module-level AbortController for graceful shutdown. + * Triggered by SIGINT/SIGTERM to cancel all in-flight HTTP requests. + * Re-created on each run() invocation to support re-use. + */ +let shutdownController = new AbortController(); + +/** Used by tests to simulate SIGINT/SIGTERM during fetchPackageMeta. */ +function __testAbortShutdown(): void { + shutdownController.abort(); +} + +/** Used by tests to reset shutdown state after simulating abort. */ +function __testResetShutdown(): void { + shutdownController = new AbortController(); +} + +if (process.env.NODE_ENV === 'test' || process.env.VITEST) { + const g = globalThis as { + __testAbortShutdown?: () => void; + __testResetShutdown?: () => void; + }; + g.__testAbortShutdown = __testAbortShutdown; + g.__testResetShutdown = __testResetShutdown; +} + /** * Spawns a command and returns its JSON output. * * @param cmd - The command to execute (e.g., 'npm'). * @param args - Array of command-line arguments. - * @returns Promise that resolves to the parsed JSON output, or an empty object if parsing fails. + * @returns Promise that resolves to the parsed JSON output, or an empty object if parsing fails. Rejects if the process fails to spawn (e.g. ENOENT). */ export function spawnJson(cmd: string, args: string[]): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] }); let out = ''; + let settled = false; child.stdout.on('data', (c) => { out += String(c); }); + child.on('error', (err) => { + if (!settled) { + settled = true; + reject(err); + } + }); child.on('close', () => { - try { - resolve(out.trim() ? JSON.parse(out) : {}); - } catch { - resolve({}); + if (!settled) { + settled = true; + try { + resolve(out.trim() ? JSON.parse(out) : {}); + } catch { + resolve({}); + } } }); }); @@ -64,14 +100,24 @@ export function spawnJson(cmd: string, args: string[]): Promise { * @returns Promise that resolves to the trimmed text output. */ export function spawnText(cmd: string, args: string[]): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] }); let out = ''; + let settled = false; child.stdout.on('data', (c) => { out += String(c); }); + child.on('error', (err) => { + if (!settled) { + settled = true; + reject(err); + } + }); child.on('close', () => { - resolve(out.trim()); + if (!settled) { + settled = true; + resolve(out.trim()); + } }); }); } @@ -87,18 +133,29 @@ export function spawnText(cmd: string, args: string[]): Promise { export async function fetchPackageMeta(pkg: string): Promise { const url = `${NPM_REGISTRY}/${encodeURIComponent(pkg)}`; + if (shutdownController.signal.aborted) { + throw new NetworkError('Operation cancelled', url); + } + const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), HTTP_REQUEST_TIMEOUT_MS, ); + const onShutdown = () => controller.abort(); + shutdownController.signal.addEventListener('abort', onShutdown); + if (shutdownController.signal.aborted) { + controller.abort(); + } + try { const response = await fetch(url, { headers: { Accept: 'application/json', }, signal: controller.signal, + redirect: 'error', }); if (!response.ok) { @@ -128,6 +185,9 @@ export async function fetchPackageMeta(pkg: string): Promise { } if (error instanceof Error && error.name === 'AbortError') { + if (shutdownController.signal.aborted) { + throw new NetworkError('Operation cancelled', url); + } throw new NetworkError('Request timeout', url); } @@ -137,6 +197,7 @@ export async function fetchPackageMeta(pkg: string): Promise { ); } finally { clearTimeout(timeoutId); + shutdownController.signal.removeEventListener('abort', onShutdown); } } @@ -146,6 +207,19 @@ export async function fetchPackageMeta(pkg: string): Promise { * @param cwd - The current working directory where package.json should be located. * @returns Object containing dependencies and devDependencies. Returns empty objects if file cannot be read or parsed. */ +function toStringRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + const result: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (typeof v === 'string') { + result[k] = v; + } + } + return result; +} + export function readPackageJson(cwd: string): { dependencies: Record; devDependencies: Record; @@ -153,11 +227,15 @@ export function readPackageJson(cwd: string): { try { const packageJsonPath = join(cwd, 'package.json'); const content = readFileSync(packageJsonPath, 'utf-8'); - const data = JSON.parse(content); - return { - dependencies: data.dependencies || {}, - devDependencies: data.devDependencies || {}, - }; + const data: unknown = JSON.parse(content); + if (!data || typeof data !== 'object') { + return { dependencies: {}, devDependencies: {} }; + } + const obj = data as Record; + const deps = 'dependencies' in obj ? toStringRecord(obj.dependencies) : {}; + const devDeps = + 'devDependencies' in obj ? toStringRecord(obj.devDependencies) : {}; + return { dependencies: deps, devDependencies: devDeps }; } catch { return { dependencies: {}, devDependencies: {} }; } @@ -326,16 +404,12 @@ export class ProgressBar { } /** - * Gets the total count of installed packages. + * Gets the total count of declared packages from package.json. */ -export async function getPackageCount(): Promise { - try { - const output = await spawnText('npm', ['list', '--depth=0', '--json']); - const data = JSON.parse(output); - return Object.keys(data.dependencies || {}).length; - } catch { - return 0; - } +export function getPackageCount(): number { + const cwd = process.cwd(); + const { dependencies, devDependencies } = readPackageJson(cwd); + return Object.keys(dependencies).length + Object.keys(devDependencies).length; } /** @@ -365,6 +439,11 @@ export function printUpToDateMessage( export async function run(): Promise { const args = parseArgs(process.argv); + shutdownController = new AbortController(); + const onSignal = () => shutdownController.abort(); + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + try { let outdated: OutdatedMap; let metas: Record; @@ -385,13 +464,13 @@ export async function run(): Promise { typeof outdatedRaw !== 'object' || Object.keys(outdatedRaw).length === 0 ) { - const packageCount = await getPackageCount(); + const packageCount = getPackageCount(); printUpToDateMessage(packageCount, args.quiet); return 0; } if (!isOutdatedMap(outdatedRaw)) { - const packageCount = await getPackageCount(); + const packageCount = getPackageCount(); printUpToDateMessage(packageCount, args.quiet); return 0; } @@ -459,7 +538,7 @@ export async function run(): Promise { // Only show "up to date" message if no filtering was applied const hasFiltering = args.olderThan > 0 || args.skip.length > 0; if (!hasFiltering) { - const packageCount = await getPackageCount(); + const packageCount = getPackageCount(); printUpToDateMessage(packageCount, args.quiet); } return 0; @@ -480,6 +559,9 @@ export async function run(): Promise { console.error(formatError(error)); } return 1; + } finally { + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); } } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index cc1bd16..bc44ebe 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -17,8 +17,8 @@ export const AGE_THRESHOLD_YELLOW = 90; export const NPM_REGISTRY = 'https://registry.npmjs.org'; export const HTTP_REQUEST_TIMEOUT_MS = 10_000; -// Regex patterns -export const NODE_MODULES_REGEX = /node_modules\/(.+)$/; +// Regex patterns — matches the last node_modules/ segment to handle nested dependencies +export const NODE_MODULES_REGEX = /.*node_modules\/(.+)$/; // Default concurrency export const DEFAULT_CONCURRENCY = 12; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 8c97eea..fa075d2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,10 @@ import { MS_PER_DAY } from './constants.js'; -import type { BumpType, NpmRegistryResponse, OutdatedMap } from './types.js'; +import type { + BumpType, + NpmRegistryResponse, + OutdatedMap, + SkipFileConfig, +} from './types.js'; export function parseIsoZ(s?: string): number | null { if (!s) { @@ -10,6 +15,20 @@ export function parseIsoZ(s?: string): number | null { return Number.isFinite(t) ? t : null; } +/** + * Type guard to check if an object has a specific key. + * + * @param obj - The object to check. + * @param key - The key to look for. + * @returns True if the object has the specified key. + */ +function hasKey( + obj: object, + key: K, +): obj is Record { + return key in obj; +} + /** * Type guard to validate npm Registry Response structure. * @@ -23,15 +42,15 @@ export function isValidNpmRegistryResponse( return false; } - if ('dist-tags' in data) { - const distTags = (data as { 'dist-tags': unknown })['dist-tags']; + if (hasKey(data, 'dist-tags')) { + const distTags = data['dist-tags']; if (typeof distTags !== 'object' || distTags === null) { return false; } } - if ('time' in data) { - const time = (data as { time: unknown }).time; + if (hasKey(data, 'time')) { + const time = data.time; if (typeof time !== 'object' || time === null) { return false; } @@ -70,6 +89,35 @@ export function isOutdatedMap(data: unknown): data is OutdatedMap { return true; } +/** + * Type guard to validate that data is a SkipFileConfig. + * + * @param data - The data to validate. + * @returns True if the data matches the SkipFileConfig structure. + */ +export function isSkipFileConfig(data: unknown): data is SkipFileConfig { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return false; + } + if (!hasKey(data, 'packages')) { + return false; + } + const pkgs = data.packages; + if (!Array.isArray(pkgs)) { + return false; + } + if (!pkgs.every((item) => typeof item === 'string')) { + return false; + } + if (hasKey(data, 'reason') && typeof data.reason !== 'string') { + return false; + } + if (hasKey(data, 'autoCleanup') && typeof data.autoCleanup !== 'boolean') { + return false; + } + return true; +} + /** * Safely extracts the latest version from an npm Registry Response. * diff --git a/tests/args.test.ts b/tests/args.test.ts index 9346333..9b66cb2 100644 --- a/tests/args.test.ts +++ b/tests/args.test.ts @@ -1,4 +1,12 @@ -import { existsSync, readFileSync, unlinkSync } from 'node:fs'; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { cleanupAndSaveSkipFile, parseArgs } from '../src/args.js'; @@ -140,6 +148,96 @@ describe('parseArgs', () => { const result = parseArgs(['node', 'script.js']); expect(result.skip).toEqual([]); }); + + it('should support --key=value syntax', () => { + const result = parseArgs([ + 'node', + 'script.js', + '--sort-by=name', + '--concurrency=8', + '--order=asc', + '--format=md', + '--older-than=30', + ]); + expect(result.sortBy).toBe('name'); + expect(result.concurrency).toBe(8); + expect(result.order).toBe('asc'); + expect(result.format).toBe('md'); + expect(result.olderThan).toBe(30); + }); + + it('should support --skip=value syntax', () => { + const result = parseArgs(['node', 'script.js', '--skip=react,vue']); + expect(result.skip).toEqual(['react', 'vue']); + }); + + it('should accumulate multiple --skip flags', () => { + const result = parseArgs([ + 'node', + 'script.js', + '--skip', + 'react', + '--skip', + 'vue', + '--skip', + 'angular', + ]); + expect(result.skip).toEqual(['react', 'vue', 'angular']); + }); + + it('should accumulate mixed --skip and --skip=value flags', () => { + const result = parseArgs([ + 'node', + 'script.js', + '--skip', + 'react', + '--skip=vue,angular', + ]); + expect(result.skip).toEqual(['react', 'vue', 'angular']); + }); + + it('should deduplicate skip entries across command line and skip file', () => { + const originalCwd = process.cwd(); + const testDir = mkdtempSync(join(tmpdir(), 'outdated-plus-args-test-')); + + try { + writeFileSync( + join(testDir, '.outdated-plus-skip'), + JSON.stringify({ + packages: ['react', 'vue'], + reason: 'test', + autoCleanup: true, + }), + ); + + process.chdir(testDir); + + const result = parseArgs([ + 'node', + 'script.js', + '--skip', + 'react,angular', + ]); + + expect(result.skip).toEqual(['react', 'angular', 'vue']); + } finally { + process.chdir(originalCwd); + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should handle boolean flags with --key=value args', () => { + const result = parseArgs([ + 'node', + 'script.js', + '--show-all', + '--sort-by=name', + '--iso', + ]); + expect(result.showAll).toBe(true); + expect(result.sortBy).toBe('name'); + expect(result.iso).toBe(true); + }); }); describe('cleanupAndSaveSkipFile', () => { diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 29cd48d..8d2cf39 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,4 +1,215 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { spawn } from 'node:child_process'; +import { NODE_MODULES_REGEX } from '../src/lib/constants.js'; +import { getInstalledVersions, readPackageJson } from '../src/index.js'; + +vi.mock('node:child_process', () => ({ spawn: vi.fn() })); + +describe('NODE_MODULES_REGEX', () => { + it('should match simple package path', () => { + const match = 'node_modules/react'.match(NODE_MODULES_REGEX); + expect(match?.[1]).toBe('react'); + }); + + it('should match scoped package path', () => { + const match = 'node_modules/@types/react'.match(NODE_MODULES_REGEX); + expect(match?.[1]).toBe('@types/react'); + }); + + it('should match the last segment for nested node_modules', () => { + const match = 'node_modules/foo/node_modules/bar'.match(NODE_MODULES_REGEX); + expect(match?.[1]).toBe('bar'); + }); + + it('should match deeply nested scoped package', () => { + const match = 'node_modules/@scope/a/node_modules/@scope/b'.match( + NODE_MODULES_REGEX, + ); + expect(match?.[1]).toBe('@scope/b'); + }); + + it('should match triple-nested dependencies', () => { + const match = 'node_modules/a/node_modules/b/node_modules/c'.match( + NODE_MODULES_REGEX, + ); + expect(match?.[1]).toBe('c'); + }); + + it('should not match empty string', () => { + const match = ''.match(NODE_MODULES_REGEX); + expect(match).toBeNull(); + }); + + it('should not match paths without node_modules', () => { + const match = 'src/components/react'.match(NODE_MODULES_REGEX); + expect(match).toBeNull(); + }); +}); + +describe('readPackageJson', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), `outdated-plus-test-`)); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should read dependencies and devDependencies', () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ + dependencies: { react: '^18.0.0', vue: '^3.0.0' }, + devDependencies: { vitest: '^1.0.0' }, + }), + ); + const result = readPackageJson(testDir); + expect(result.dependencies).toEqual({ react: '^18.0.0', vue: '^3.0.0' }); + expect(result.devDependencies).toEqual({ vitest: '^1.0.0' }); + }); + + it('should return empty objects when package.json is missing', () => { + const result = readPackageJson(join(testDir, 'nonexistent')); + expect(result).toEqual({ dependencies: {}, devDependencies: {} }); + }); + + it('should return empty objects for invalid JSON', () => { + writeFileSync(join(testDir, 'package.json'), 'not json'); + const result = readPackageJson(testDir); + expect(result).toEqual({ dependencies: {}, devDependencies: {} }); + }); + + it('should handle missing dependencies key', () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ name: 'test' }), + ); + const result = readPackageJson(testDir); + expect(result).toEqual({ dependencies: {}, devDependencies: {} }); + }); + + it('should filter out non-string values from dependencies', () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ + dependencies: { react: '^18.0.0', invalid: 123, nested: { a: 1 } }, + }), + ); + const result = readPackageJson(testDir); + expect(result.dependencies).toEqual({ react: '^18.0.0' }); + }); +}); + +describe('getInstalledVersions', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), `outdated-plus-test-`)); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should extract versions from lockfile v3 packages', () => { + writeFileSync( + join(testDir, 'package-lock.json'), + JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { name: 'test' }, + 'node_modules/react': { version: '18.2.0' }, + 'node_modules/vue': { version: '3.3.4' }, + }, + }), + ); + const versions = getInstalledVersions(testDir); + expect(versions).toEqual({ react: '18.2.0', vue: '3.3.4' }); + }); + + it('should resolve nested node_modules to the last segment', () => { + writeFileSync( + join(testDir, 'package-lock.json'), + JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { name: 'test' }, + 'node_modules/react': { version: '18.2.0' }, + 'node_modules/react/node_modules/loose-envify': { + version: '1.4.0', + }, + }, + }), + ); + const versions = getInstalledVersions(testDir); + expect(versions['react']).toBe('18.2.0'); + expect(versions['loose-envify']).toBe('1.4.0'); + }); + + it('should handle scoped packages in nested node_modules', () => { + writeFileSync( + join(testDir, 'package-lock.json'), + JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { name: 'test' }, + 'node_modules/@types/react': { version: '18.2.0' }, + 'node_modules/foo/node_modules/@scope/bar': { version: '1.0.0' }, + }, + }), + ); + const versions = getInstalledVersions(testDir); + expect(versions['@types/react']).toBe('18.2.0'); + expect(versions['@scope/bar']).toBe('1.0.0'); + }); + + it('should fallback to dependencies field for lockfile v1', () => { + writeFileSync( + join(testDir, 'package-lock.json'), + JSON.stringify({ + lockfileVersion: 1, + dependencies: { + react: { version: '17.0.2' }, + vue: { version: '2.7.0' }, + }, + }), + ); + const versions = getInstalledVersions(testDir); + expect(versions).toEqual({ react: '17.0.2', vue: '2.7.0' }); + }); + + it('should return empty object when lockfile is missing', () => { + const versions = getInstalledVersions(join(testDir, 'nonexistent')); + expect(versions).toEqual({}); + }); + + it('should return empty object for invalid JSON', () => { + writeFileSync(join(testDir, 'package-lock.json'), 'not json'); + const versions = getInstalledVersions(testDir); + expect(versions).toEqual({}); + }); + + it('should prefer last nested version when same package appears at multiple levels', () => { + writeFileSync( + join(testDir, 'package-lock.json'), + JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { name: 'test' }, + 'node_modules/semver': { version: '7.5.0' }, + 'node_modules/npm/node_modules/semver': { version: '7.3.0' }, + }, + }), + ); + const versions = getInstalledVersions(testDir); + expect(versions['semver']).toBe('7.3.0'); + }); +}); describe('ProgressBar', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -120,3 +331,37 @@ describe('ProgressBar', () => { expect(consoleSpy).not.toHaveBeenCalled(); }); }); + +describe('run', () => { + it('should return exit code 1 and print error when npm spawn fails (e.g. ENOENT)', async () => { + vi.resetModules(); + vi.mocked(spawn).mockReturnValue({ + stdout: { on: vi.fn() }, + on: vi.fn((event: string, handler: (err: Error) => void) => { + if (event === 'error') { + setImmediate(() => + handler( + Object.assign(new Error('spawn npm ENOENT'), { code: 'ENOENT' }), + ), + ); + } + }), + } as ReturnType); + + const argv = process.argv; + process.argv = ['node', 'outdated-plus']; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { run } = await import('../src/index.js'); + const code = await run(); + + expect(code).toBe(1); + expect(errSpy).toHaveBeenCalledWith( + expect.stringMatching(/ENOENT|spawn npm/), + ); + + process.argv = argv; + errSpy.mockRestore(); + vi.mocked(spawn).mockReset(); + }); +}); diff --git a/tests/fetchMeta.test.ts b/tests/fetchMeta.test.ts index 1aca382..b164039 100644 --- a/tests/fetchMeta.test.ts +++ b/tests/fetchMeta.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +declare global { + // Test-only hooks injected from src/index.ts under test runtime + var __testAbortShutdown: (() => void) | undefined; + var __testResetShutdown: (() => void) | undefined; +} + // Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch; @@ -157,4 +163,24 @@ describe('fetchPackageMeta', () => { await expect(fetchPackageMeta('test-package')).rejects.toThrow(); }); + + it('should throw Operation cancelled when shutdown is aborted during fetch', async () => { + mockFetch.mockImplementation( + (_url: string, opts?: { signal?: AbortSignal }) => + new Promise((_, reject) => { + opts?.signal?.addEventListener?.('abort', () => + reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })), + ); + }), + ); + + const { fetchPackageMeta } = await import('../src/index.js'); + + const p = fetchPackageMeta('test-pkg'); + await Promise.resolve(); + globalThis.__testAbortShutdown?.(); + + await expect(p).rejects.toThrow('Operation cancelled'); + globalThis.__testResetShutdown?.(); + }); }); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index c166eb7..578e77d 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -6,6 +6,7 @@ import { extractLatestVersion, extractTimeMap, fmtTime, + isSkipFileConfig, isValidNpmRegistryResponse, isVersionHigher, parseIsoZ, @@ -468,3 +469,66 @@ describe('extractTimeMap', () => { expect(extractTimeMap(response)).toEqual({}); }); }); + +describe('isSkipFileConfig', () => { + it('should return true for valid config with packages only', () => { + expect(isSkipFileConfig({ packages: ['react', 'vue'] })).toBe(true); + }); + + it('should return true for valid config with all fields', () => { + expect( + isSkipFileConfig({ + packages: ['react@18.0.0'], + reason: 'Skip for now', + autoCleanup: true, + }), + ).toBe(true); + }); + + it('should return true for empty packages array', () => { + expect(isSkipFileConfig({ packages: [] })).toBe(true); + }); + + it('should return false for null or undefined', () => { + expect(isSkipFileConfig(null)).toBe(false); + expect(isSkipFileConfig(undefined)).toBe(false); + }); + + it('should return false for primitive types', () => { + expect(isSkipFileConfig('string')).toBe(false); + expect(isSkipFileConfig(123)).toBe(false); + expect(isSkipFileConfig(true)).toBe(false); + }); + + it('should return false for arrays', () => { + expect(isSkipFileConfig(['react', 'vue'])).toBe(false); + }); + + it('should return false when packages key is missing', () => { + expect(isSkipFileConfig({ reason: 'test' })).toBe(false); + }); + + it('should return false when packages is not an array', () => { + expect(isSkipFileConfig({ packages: 'react' })).toBe(false); + expect(isSkipFileConfig({ packages: 123 })).toBe(false); + }); + + it('should return false when packages contains non-string entries', () => { + expect(isSkipFileConfig({ packages: [123, 'react'] })).toBe(false); + expect(isSkipFileConfig({ packages: [null] })).toBe(false); + }); + + it('should return false when reason is not a string', () => { + expect(isSkipFileConfig({ packages: ['react'], reason: 123 })).toBe(false); + expect(isSkipFileConfig({ packages: ['react'], reason: true })).toBe(false); + }); + + it('should return false when autoCleanup is not a boolean', () => { + expect(isSkipFileConfig({ packages: ['react'], autoCleanup: 'yes' })).toBe( + false, + ); + expect(isSkipFileConfig({ packages: ['react'], autoCleanup: 1 })).toBe( + false, + ); + }); +});