diff --git a/README.md b/README.md index 8e86c6b..56df61c 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,30 @@ inup [options] -i, --ignore Ignore packages (comma-separated, glob supported) --max-depth Maximum scan depth for package discovery (default: 10) --package-manager Force package manager (npm, yarn, pnpm, bun) +--json Print a machine-readable JSON report and exit (read-only) +-c, --check Exit non-zero if updates exist, without writing (for CI) --debug Write verbose debug logs ``` +## CI & Scripting + +`inup` runs headless automatically when stdout isn't a TTY or `$CI` is set, so it never hangs in a +pipeline waiting on the interactive UI. Both `--json` and `--check` are **read-only** — they report, +they never edit `package.json` or install. + +```bash +inup --check # exit 1 if anything is outdated → fails the build +inup --json | jq # structured drift report for dashboards/bots +inup | cat # plain line-based report when piped to a log +``` + +Each reported package carries its health signals: `deprecated` (npm deprecation message), `enginesNode` +(declared `engines.node`), and `vulnerability` (known advisories on the currently-installed version, +from one bulk `npm audit`-style request). The summary includes a `vulnerable` count. + +Output hygiene: with `--json`, stdout carries **only** the JSON document; all progress and warnings go +to stderr. Exit codes: `0` up to date, `1` updates exist (`--check`), `2` error. + ## Keyboard Shortcuts diff --git a/src/cli.ts b/src/cli.ts index cebf097..5a4ab57 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,8 @@ export interface CliOptions { debug?: boolean color?: boolean saveExact?: boolean + json?: boolean + check?: boolean } export async function runCli(options: CliOptions): Promise { @@ -34,15 +36,22 @@ export async function runCli(options: CliOptions): Promise { enableDebugLogging() } - const gitState = getGitWorkingTreeState(cwd) - if (gitState.isRepo && gitState.isDirty) { - const shouldProceed = await TerminalInput.promptForImmediateConfirmation( - `${chalk.yellow('Warning:')} dirty working tree. Proceed anyway? ${chalk.dim('[y/N]')} `, - false - ) - if (!shouldProceed) { - console.log(chalk.yellow('Upgrade cancelled.')) - return + // Headless when piped, in CI, or when a non-interactive flag is set. The TUI only renders in + // interactive mode; everything else routes through the read-only headless report path. + const interactive = !!process.stdout.isTTY && !process.env.CI && !options.json && !options.check + + // The dirty-tree prompt would hang without a TTY; headless is read-only anyway, so skip it. + if (interactive) { + const gitState = getGitWorkingTreeState(cwd) + if (gitState.isRepo && gitState.isDirty) { + const shouldProceed = await TerminalInput.promptForImmediateConfirmation( + `${chalk.yellow('Warning:')} dirty working tree. Proceed anyway? ${chalk.dim('[y/N]')} `, + false + ) + if (!shouldProceed) { + console.log(chalk.yellow('Upgrade cancelled.')) + return + } } } @@ -74,8 +83,11 @@ export async function runCli(options: CliOptions): Promise { process.exit(1) } - // Check for updates in the background (non-blocking) - const updateCheckPromise = checkForUpdateAsync(PACKAGE_NAME, PACKAGE_VERSION) + // Check for updates in the background (non-blocking). Interactive only — keeps headless stdout + // clean and avoids a lingering fetch handle in CI. + const updateCheckPromise = interactive + ? checkForUpdateAsync(PACKAGE_NAME, PACKAGE_VERSION) + : undefined // Validate package manager if provided let packageManager: PackageManager | undefined @@ -103,6 +115,11 @@ export async function runCli(options: CliOptions): Promise { debug: options.debug || process.env.INUP_DEBUG === '1', saveExact: options.saveExact ?? false, }) + if (!interactive) { + await upgrader.runHeadless({ json: options.json, check: options.check }) + return + } + await upgrader.run() // After the main flow completes, check if there's an update available @@ -150,6 +167,8 @@ program .option('--debug', 'write verbose debug log to /tmp/inup-debug-YYYY-MM-DD.log') .option('--no-color', 'disable colored output (also respects NO_COLOR / FORCE_COLOR)') .option('--save-exact', 'write exact versions instead of preserving the range prefix (^/~)') + .option('--json', 'print a machine-readable JSON report and exit (non-interactive, read-only)') + .option('-c, --check', 'exit non-zero if updates exist, without writing (for CI; read-only)') .action(runCli) // Handle uncaught errors gracefully diff --git a/src/core/upgrade-runner.ts b/src/core/upgrade-runner.ts index bd0f064..be6b0e7 100644 --- a/src/core/upgrade-runner.ts +++ b/src/core/upgrade-runner.ts @@ -3,6 +3,9 @@ import { PackageDetector } from './package-detector' import { InteractiveUI } from '../interactive-ui' import { PackageUpgrader } from './upgrader' import { + HeadlessOptions, + HeadlessReport, + HeadlessReportEntry, PackageInfo, PackageLoadProgress, PackageSelectionState, @@ -11,6 +14,8 @@ import { PackageManagerInfo, } from '../types' import { PackageManagerDetector } from '../services/package-manager-detector' +import { fetchVulnerabilities } from '../services' +import { createVulnerabilitySummary } from '../ui/presenters/vulnerability' import { ConsoleUtils } from '../ui/utils' import { getPerformanceTracker } from '../features/debug' @@ -184,6 +189,124 @@ export class UpgradeRunner { } } + /** + * Non-interactive entry point: resolve the outdated list without rendering the + * TUI, then either emit a JSON report (--json) or a plain line-based report. + * With --check, sets a non-zero exit code when updates exist so CI can gate on it. + * Read-only: never writes package.json or installs. + */ + public async runHeadless(options: HeadlessOptions): Promise { + try { + this.checkPrerequisites() + + const packages = await this.detector.getOutdatedPackages() + const outdated = this.detector.getOutdatedPackagesOnly(packages) + + // Enrich with security advisories (one bulk request, best-effort) — the same audit the + // TUI runs on `s`, so --json/--check carry the vuln signal CI cares about. + await this.enrichWithVulnerabilities(outdated) + + if (options.json) { + // stdout is reserved for the JSON document only. + console.log(JSON.stringify(this.buildHeadlessReport(packages, outdated), null, 2)) + } else { + this.printPlainReport(outdated) + } + + // Exit 1 only means "updates exist" (like `prettier --check`); 2 is reserved for errors. + if (options.check && outdated.length > 0) { + process.exitCode = 1 + } + } catch (error) { + console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`)) + process.exit(2) + } + } + + private buildHeadlessReport(all: PackageInfo[], outdated: PackageInfo[]): HeadlessReport { + return { + summary: { + total: all.length, + outdated: outdated.length, + major: outdated.filter((pkg) => pkg.hasMajorUpdate).length, + vulnerable: outdated.filter((pkg) => (pkg.vulnerability?.count ?? 0) > 0).length, + }, + outdated: outdated.map((pkg) => { + const entry: HeadlessReportEntry = { + name: pkg.name, + current: pkg.currentVersion, + range: pkg.rangeVersion, + latest: pkg.latestVersion, + type: pkg.type, + packageJsonPath: pkg.packageJsonPath, + hasMajorUpdate: pkg.hasMajorUpdate, + } + if (pkg.deprecated) entry.deprecated = pkg.deprecated + if (pkg.enginesNode) entry.enginesNode = pkg.enginesNode + if (pkg.vulnerability) entry.vulnerability = pkg.vulnerability + return entry + }), + } + } + + /** + * Attach known security advisories to the outdated packages in place. Audits each package's + * currently-installed specifier (matching the interactive audit) via one bulk request. + * Best-effort: `fetchVulnerabilities` swallows network errors and returns an empty map, so a + * failed audit never blocks the report. + */ + private async enrichWithVulnerabilities(outdated: PackageInfo[]): Promise { + if (outdated.length === 0) return + + // The bulk advisory API is keyed by package name (one version per name), so dedupe by name. + const versions = new Map() + for (const pkg of outdated) { + if (!versions.has(pkg.name)) versions.set(pkg.name, pkg.currentVersion) + } + + const advisories = await fetchVulnerabilities(versions) + if (advisories.size === 0) return + + for (const pkg of outdated) { + const found = advisories.get(pkg.name) + if (!found || found.vulnerabilities.length === 0 || !found.highestSeverity) continue + pkg.vulnerability = createVulnerabilitySummary( + undefined, + found.vulnerabilities.map((item) => ({ + id: item.id, + title: item.title, + severity: item.severity, + url: item.url, + })), + found.highestSeverity + ) + } + } + + private printPlainReport(outdated: PackageInfo[]): void { + if (outdated.length === 0) { + console.log('All dependencies are up to date — no upgrades needed.') + return + } + + for (const pkg of outdated) { + const major = pkg.hasMajorUpdate ? ' (major)' : '' + const vuln = + pkg.vulnerability && pkg.vulnerability.count > 0 + ? ` [vuln: ${pkg.vulnerability.count} ${pkg.vulnerability.highestSeverity}]` + : '' + const deprecated = pkg.deprecated ? ' [deprecated]' : '' + console.log( + `${pkg.name} ${pkg.currentVersion} → ${pkg.latestVersion} [${pkg.type}]${major}${vuln}${deprecated}` + ) + } + + const fileCount = new Set(outdated.map((pkg) => pkg.packageJsonPath)).size + const vulnerable = outdated.filter((pkg) => (pkg.vulnerability?.count ?? 0) > 0).length + const vulnNote = vulnerable > 0 ? ` — ${vulnerable} with known vulnerabilities` : '' + console.log(`\n${outdated.length} package(s) outdated across ${fileCount} file(s)${vulnNote}.`) + } + private checkPrerequisites(): void { // Check if package.json exists if (!this.detector.hasPackageJson()) { diff --git a/src/services/package-manager-detector.ts b/src/services/package-manager-detector.ts index 35a15a8..edac493 100644 --- a/src/services/package-manager-detector.ts +++ b/src/services/package-manager-detector.ts @@ -65,8 +65,8 @@ export class PackageManagerDetector { return fromLockFile } - // 3. Fallback to npm - console.log( + // 3. Fallback to npm. Warn on stderr so it never corrupts --json output on stdout. + console.error( chalk.yellow( '⚠️ No package manager detected. Defaulting to npm. Consider adding a "packageManager" field to your package.json.' ) @@ -129,7 +129,8 @@ export class PackageManagerDetector { // If multiple lock files, use most recently modified if (existingLocks.length > 1) { - console.log( + // stderr so it never corrupts --json output on stdout. + console.error( chalk.yellow( '⚠️ Multiple lock files detected. Using most recently modified. Consider cleaning up unused lock files.' ) diff --git a/src/types/domain.ts b/src/types/domain.ts index fc80d92..ad6903d 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -85,6 +85,34 @@ export interface UpgradeOptions extends VulnerabilityDisplayOptions { saveExact?: boolean // Write bare versions instead of preserving the range prefix (^/~) } +export interface HeadlessOptions { + json?: boolean // Emit a machine-readable JSON report on stdout + check?: boolean // Exit non-zero when updates exist (CI gate) +} + +export interface HeadlessReportEntry { + name: string + current: string // Raw specifier from package.json (with ^/~ prefix) + range: string // Latest version satisfying the current range + latest: string // Absolute latest version + type: DependencyType + packageJsonPath: string + hasMajorUpdate: boolean + deprecated?: string // npm deprecation message for the latest version, if any + enginesNode?: string // declared engines.node range for the latest version, if any + vulnerability?: VulnerabilitySummary +} + +export interface HeadlessReport { + summary: { + total: number // Packages scanned + outdated: number // Packages with an available update + major: number // Of the outdated, how many are a major bump + vulnerable: number // Of the outdated, how many have ≥1 known advisory on the current version + } + outdated: HeadlessReportEntry[] +} + export interface PackageJson { dependencies?: Record devDependencies?: Record diff --git a/src/ui/utils/cursor.ts b/src/ui/utils/cursor.ts index 8cb7243..35a9ad8 100644 --- a/src/ui/utils/cursor.ts +++ b/src/ui/utils/cursor.ts @@ -74,16 +74,20 @@ export const ConsoleUtils = { LINE_WIDTH: 80, /** - * Show a progress message on the current line (overwrites previous content) + * Show a progress message on the current line (overwrites previous content). + * Written to stderr so stdout stays clean for --json / piped output, and only + * when stderr is a TTY — the \r animation is just noise in a redirected log. */ showProgress(message: string): void { - process.stdout.write(`\r${' '.repeat(ConsoleUtils.LINE_WIDTH)}\r${message}`) + if (!process.stderr.isTTY) return + process.stderr.write(`\r${' '.repeat(ConsoleUtils.LINE_WIDTH)}\r${message}`) }, /** * Clear the current progress line */ clearProgress(): void { - process.stdout.write('\r' + ' '.repeat(ConsoleUtils.LINE_WIDTH) + '\r') + if (!process.stderr.isTTY) return + process.stderr.write('\r' + ' '.repeat(ConsoleUtils.LINE_WIDTH) + '\r') }, } diff --git a/test/unit/cli.test.ts b/test/unit/cli.test.ts index a1ef5ec..6f57112 100644 --- a/test/unit/cli.test.ts +++ b/test/unit/cli.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ upgradeRunnerRun: vi.fn(), + upgradeRunnerRunHeadless: vi.fn(), loadProjectConfig: vi.fn(), checkForUpdateAsync: vi.fn(), getGitWorkingTreeState: vi.fn(), @@ -11,6 +12,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('../../src/index', () => ({ UpgradeRunner: class { run = mocks.upgradeRunnerRun + runHeadless = mocks.upgradeRunnerRunHeadless }, })) @@ -38,15 +40,31 @@ vi.mock('../../src/ui/utils/terminal-input', () => ({ import { runCli } from '../../src/cli' +// The dirty-tree preflight is an interactive-only concern; force a TTY (and clear $CI) so these +// tests exercise the interactive branch regardless of where the suite runs. +const originalIsTTY = process.stdout.isTTY +const originalCI = process.env.CI +const setInteractive = (interactive: boolean) => + Object.defineProperty(process.stdout, 'isTTY', { value: interactive, configurable: true }) + describe('CLI git dirty preflight', () => { beforeEach(() => { vi.clearAllMocks() + setInteractive(true) + delete process.env.CI mocks.loadProjectConfig.mockReturnValue({}) mocks.checkForUpdateAsync.mockResolvedValue(null) mocks.getGitWorkingTreeState.mockReturnValue({ isRepo: false, isDirty: false }) mocks.promptForImmediateConfirmation.mockResolvedValue(true) mocks.upgradeRunnerRun.mockResolvedValue(undefined) + mocks.upgradeRunnerRunHeadless.mockResolvedValue(undefined) + }) + + afterEach(() => { + Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true }) + if (originalCI === undefined) delete process.env.CI + else process.env.CI = originalCI }) it('aborts before running upgrades when repo is dirty and user declines', async () => { @@ -127,3 +145,54 @@ describe('CLI git dirty preflight', () => { expect(mocks.upgradeRunnerRun).not.toHaveBeenCalled() }) }) + +describe('CLI headless routing', () => { + beforeEach(() => { + vi.clearAllMocks() + setInteractive(true) + delete process.env.CI + + mocks.loadProjectConfig.mockReturnValue({}) + mocks.checkForUpdateAsync.mockResolvedValue(null) + // A dirty repo to prove the preflight is skipped (would hang) on the headless path. + mocks.getGitWorkingTreeState.mockReturnValue({ isRepo: true, isDirty: true }) + mocks.promptForImmediateConfirmation.mockResolvedValue(true) + mocks.upgradeRunnerRun.mockResolvedValue(undefined) + mocks.upgradeRunnerRunHeadless.mockResolvedValue(undefined) + }) + + afterEach(() => { + Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true }) + if (originalCI === undefined) delete process.env.CI + else process.env.CI = originalCI + }) + + it('routes to runHeadless (and skips the TUI + dirty prompt) when not a TTY', async () => { + setInteractive(false) + + await runCli({ dir: '/repo', exclude: '', ignore: '', maxDepth: '10' }) + + expect(mocks.upgradeRunnerRunHeadless).toHaveBeenCalledWith({ + json: undefined, + check: undefined, + }) + expect(mocks.upgradeRunnerRun).not.toHaveBeenCalled() + expect(mocks.promptForImmediateConfirmation).not.toHaveBeenCalled() + }) + + it('routes to runHeadless when $CI is set even in a TTY', async () => { + process.env.CI = '1' + + await runCli({ dir: '/repo', exclude: '', ignore: '', maxDepth: '10' }) + + expect(mocks.upgradeRunnerRunHeadless).toHaveBeenCalledTimes(1) + expect(mocks.upgradeRunnerRun).not.toHaveBeenCalled() + }) + + it('routes to runHeadless with json/check flags even in a TTY', async () => { + await runCli({ dir: '/repo', exclude: '', ignore: '', maxDepth: '10', json: true, check: true }) + + expect(mocks.upgradeRunnerRunHeadless).toHaveBeenCalledWith({ json: true, check: true }) + expect(mocks.upgradeRunnerRun).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/core/headless-runner.test.ts b/test/unit/core/headless-runner.test.ts new file mode 100644 index 0000000..3a7d96e --- /dev/null +++ b/test/unit/core/headless-runner.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getOutdatedPackages: vi.fn(), + getOutdatedPackagesOnly: vi.fn(), + hasPackageJson: vi.fn(), + detectPackageManager: vi.fn(), + fetchVulnerabilities: vi.fn(), +})) + +vi.mock('../../../src/core/package-detector', () => ({ + PackageDetector: class { + getOutdatedPackages = mocks.getOutdatedPackages + getOutdatedPackagesOnly = mocks.getOutdatedPackagesOnly + hasPackageJson = mocks.hasPackageJson + }, +})) + +vi.mock('../../../src/services', () => ({ + fetchVulnerabilities: mocks.fetchVulnerabilities, +})) + +vi.mock('../../../src/interactive-ui', () => ({ + InteractiveUI: class {}, +})) + +vi.mock('../../../src/core/upgrader', () => ({ + PackageUpgrader: class {}, +})) + +vi.mock('../../../src/services/package-manager-detector', () => ({ + PackageManagerDetector: { + detect: mocks.detectPackageManager, + getInfo: mocks.detectPackageManager, + }, +})) + +import { UpgradeRunner } from '../../../src/core/upgrade-runner' + +const OUTDATED = { + name: 'axios', + currentVersion: '^0.27.0', + rangeVersion: '0.27.2', + latestVersion: '1.16.1', + type: 'dependencies', + packageJsonPath: '/repo/package.json', + isOutdated: true, + hasRangeUpdate: true, + hasMajorUpdate: true, + deprecated: 'use the platform fetch instead', + enginesNode: '>=14', +} + +const UP_TO_DATE = { + name: 'left-pad', + currentVersion: '^1.3.0', + rangeVersion: '1.3.0', + latestVersion: '1.3.0', + type: 'dependencies', + packageJsonPath: '/repo/package.json', + isOutdated: false, + hasRangeUpdate: false, + hasMajorUpdate: false, +} + +describe('UpgradeRunner.runHeadless', () => { + const originalExitCode = process.exitCode + + beforeEach(() => { + vi.clearAllMocks() + mocks.hasPackageJson.mockReturnValue(true) + mocks.detectPackageManager.mockReturnValue({ + name: 'npm', + displayName: 'npm', + lockFile: 'package-lock.json', + workspaceFile: null, + installCommand: 'npm install', + color: null, + }) + // Real-ish filter so the report's "total vs outdated" split is exercised. + mocks.getOutdatedPackagesOnly.mockImplementation((pkgs: any[]) => + pkgs.filter((p) => p.isOutdated) + ) + mocks.getOutdatedPackages.mockResolvedValue([OUTDATED, UP_TO_DATE]) + // No advisories by default; one test overrides this. + mocks.fetchVulnerabilities.mockResolvedValue(new Map()) + }) + + afterEach(() => { + process.exitCode = originalExitCode + }) + + it('--json prints a single, valid JSON document with the documented shape', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await new UpgradeRunner({ cwd: '/repo' }).runHeadless({ json: true }) + + // stdout carries the JSON and nothing else. + expect(logSpy).toHaveBeenCalledTimes(1) + const report = JSON.parse(logSpy.mock.calls[0][0] as string) + + expect(report.summary).toEqual({ total: 2, outdated: 1, major: 1, vulnerable: 0 }) + expect(report.outdated).toHaveLength(1) + expect(report.outdated[0]).toMatchObject({ + name: 'axios', + current: '^0.27.0', + range: '0.27.2', + latest: '1.16.1', + type: 'dependencies', + packageJsonPath: '/repo/package.json', + hasMajorUpdate: true, + deprecated: 'use the platform fetch instead', + enginesNode: '>=14', + }) + // Optional fields are omitted when absent, not emitted as null/undefined. + expect('vulnerability' in report.outdated[0]).toBe(false) + + logSpy.mockRestore() + }) + + it('--json includes security advisories from the audit and counts them in the summary', async () => { + mocks.fetchVulnerabilities.mockResolvedValue( + new Map([ + [ + 'axios', + { + packageName: 'axios', + highestSeverity: 'high', + vulnerabilities: [ + { + id: 1234, + title: 'Server-Side Request Forgery', + severity: 'high', + url: 'https://github.com/advisories/GHSA-test', + vulnerable_versions: '<1.0.0', + }, + ], + }, + ], + ]) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await new UpgradeRunner({ cwd: '/repo' }).runHeadless({ json: true }) + + // The audit checks the currently-installed specifier. + expect(mocks.fetchVulnerabilities).toHaveBeenCalledWith(new Map([['axios', '^0.27.0']])) + + const report = JSON.parse(logSpy.mock.calls[0][0] as string) + expect(report.summary.vulnerable).toBe(1) + expect(report.outdated[0].vulnerability).toMatchObject({ + count: 1, + highestSeverity: 'high', + detailsUrl: 'https://github.com/advisories/GHSA-test', + advisories: [{ id: 1234, severity: 'high' }], + }) + + logSpy.mockRestore() + }) + + it('--check sets a non-zero exit code when updates exist', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + process.exitCode = 0 + + await new UpgradeRunner({ cwd: '/repo' }).runHeadless({ check: true }) + + expect(process.exitCode).toBe(1) + logSpy.mockRestore() + }) + + it('--check leaves the exit code at 0 when everything is up to date', async () => { + mocks.getOutdatedPackages.mockResolvedValue([UP_TO_DATE]) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + process.exitCode = 0 + + await new UpgradeRunner({ cwd: '/repo' }).runHeadless({ check: true }) + + expect(process.exitCode).toBe(0) + logSpy.mockRestore() + }) + + it('with no flags prints a plain line-based report and recap, no exit code', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + process.exitCode = 0 + + await new UpgradeRunner({ cwd: '/repo' }).runHeadless({}) + + const lines = logSpy.mock.calls.map((c) => String(c[0])) + expect(lines.some((l) => l.includes('axios') && l.includes('→'))).toBe(true) + expect(lines.some((l) => /outdated across 1 file/.test(l))).toBe(true) + expect(process.exitCode).toBe(0) + logSpy.mockRestore() + }) + + it('exits 2 on error (no package.json)', async () => { + mocks.hasPackageJson.mockReturnValue(false) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as any) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await new UpgradeRunner({ cwd: '/no-pkg' }).runHeadless({ json: true }) + + expect(exitSpy).toHaveBeenCalledWith(2) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('No package.json')) + exitSpy.mockRestore() + errorSpy.mockRestore() + }) +}) diff --git a/test/unit/ui/cursor.test.ts b/test/unit/ui/cursor.test.ts new file mode 100644 index 0000000..17bdd89 --- /dev/null +++ b/test/unit/ui/cursor.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ConsoleUtils } from '../../../src/ui/utils' + +// Progress is cosmetic feedback. It must never touch stdout (which is reserved for the +// --json / plain report), and it must stay silent when stderr is redirected to a log. +describe('ConsoleUtils progress output hygiene', () => { + const originalIsTTY = process.stderr.isTTY + + const setStderrTTY = (value: boolean) => + Object.defineProperty(process.stderr, 'isTTY', { value, configurable: true }) + + afterEach(() => { + Object.defineProperty(process.stderr, 'isTTY', { + value: originalIsTTY, + configurable: true, + }) + vi.restoreAllMocks() + }) + + it('writes progress to stderr and never to stdout when stderr is a TTY', () => { + setStderrTTY(true) + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + ConsoleUtils.showProgress('🔍 scanning') + ConsoleUtils.clearProgress() + + expect(errSpy).toHaveBeenCalled() + expect(outSpy).not.toHaveBeenCalled() + }) + + it('suppresses progress entirely when stderr is not a TTY', () => { + setStderrTTY(false) + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + ConsoleUtils.showProgress('🔍 scanning') + ConsoleUtils.clearProgress() + + expect(errSpy).not.toHaveBeenCalled() + expect(outSpy).not.toHaveBeenCalled() + }) +})