@percy/cli-command
Advanced tools
+207
-10
| import logger from '@percy/logger'; | ||
| import PercyConfig from '@percy/config'; | ||
| import { set, del } from '@percy/config/utils'; | ||
| import { generatePromise, AbortController, AbortError } from '@percy/core/utils'; | ||
| import { generatePromise, AbortController, AbortError, redactSecrets } from '@percy/core/utils'; | ||
| import * as CoreConfig from '@percy/core/config'; | ||
@@ -10,2 +10,123 @@ import * as builtInFlags from './flags.js'; | ||
| // Module-level shutdown state for graceful drain on | ||
| // SIGINT/SIGTERM. Per-run signal handlers (registered in | ||
| // runCommandWithContext below) delegate here so the state is accessible | ||
| // to commands via ctx.shutdown without prop-drilling. | ||
| // | ||
| // The state is intentionally module-level (not per-runner) so that a | ||
| // process-wide `process.on('exit')` cleanup can read it. Tests reset | ||
| // via the exported `_resetShutdownForTest()` helper. | ||
| let shutdownState = { | ||
| signal: null, | ||
| // 'SIGINT' / 'SIGTERM' once received, null otherwise | ||
| forced: false, | ||
| // escalates on second signal or 30s drain timeout | ||
| drainTimer: null, | ||
| hardExitTimer: null | ||
| }; | ||
| // Tracks the active context so the global unhandled-rejection handler | ||
| // can flag the run as failed without requiring the command to plumb | ||
| // state through. Reset between runs (and between tests). | ||
| let activeContext = null; | ||
| const DEFAULT_DRAIN_MS = 30_000; | ||
| const HARD_EXIT_AFTER_FORCE_MS = 5_000; | ||
| // POSIX-conventional signal exit codes: 128 + signal number. | ||
| const EXIT_CODE_SIGINT = 130; | ||
| const EXIT_CODE_SIGTERM = 143; | ||
| const SIGNAL_EXIT_CODES = { | ||
| SIGINT: EXIT_CODE_SIGINT, | ||
| SIGTERM: EXIT_CODE_SIGTERM | ||
| }; | ||
| // Begin or escalate drain. Idempotent on the same signal. | ||
| function beginShutdown(signal) { | ||
| // Only SIGINT/SIGTERM trigger drain semantics (origin scope). | ||
| // Other signals fall through to the per-run AbortController without | ||
| // setting drain state. Defensive: SIGHUP/USR1/USR2 are also bound | ||
| // by the existing handler in runCommandWithContext for legacy | ||
| // behavior, so this guard catches them — but exercising this branch | ||
| // would emit a real SIGHUP/USR* in tests, which interferes with the | ||
| // Jasmine runner under nyc instrumentation. | ||
| /* istanbul ignore if */ | ||
| if (signal !== 'SIGINT' && signal !== 'SIGTERM') return; | ||
| if (shutdownState.signal) { | ||
| // Second signal: escalate to forced and arm hard-exit fallback in | ||
| // case the in-flight stop hangs. | ||
| shutdownState.forced = true; | ||
| /* istanbul ignore next: timer guard against doubled escalation, | ||
| and the inner setTimeout callback only fires when percy.stop | ||
| hangs after the second signal — a 5s wait that is impractical | ||
| to test reliably under nyc instrumentation. The double-signal | ||
| behavior up to and including `forced=true` is verified by the | ||
| shutdown.forced test in cli-command/test/shutdown.test.js. */ | ||
| if (!shutdownState.hardExitTimer) { | ||
| shutdownState.hardExitTimer = setTimeout(() => process.exit(SIGNAL_EXIT_CODES[signal]), HARD_EXIT_AFTER_FORCE_MS).unref(); | ||
| } | ||
| logger('cli').error(`${signal} received again; force-exiting.`); | ||
| return; | ||
| } | ||
| shutdownState.signal = signal; | ||
| logger('cli').warn(`${signal} received, draining (press Ctrl-C again to force)...`); | ||
| // 30s drain budget: if percy.stop(false) hasn't completed, escalate | ||
| // to forced. Subsequent stop calls (or the hard-exit timer) take it | ||
| // from there. Coverage exclusion: testing this branch requires | ||
| // either a real 30s wait or jasmine.clock(), which conflicts with | ||
| // the runner's await-of-microtask-yields under nyc instrumentation. | ||
| // The behavior is exercised end-to-end by the second-signal force | ||
| // path in the same suite. | ||
| shutdownState.drainTimer = setTimeout(/* istanbul ignore next */ | ||
| () => { | ||
| shutdownState.forced = true; | ||
| }, DEFAULT_DRAIN_MS).unref(); | ||
| } | ||
| // Global handlers for unhandled rejection / uncaught exception. The | ||
| // stack is routed through redactSecrets because CDP rejections can | ||
| // include serialized page-script bodies, Authorization headers, or | ||
| // cookie strings. | ||
| function onUnhandled(label, err) { | ||
| let stackOrMsg; | ||
| /* istanbul ignore next: defensive — `err` is virtually always an | ||
| Error with a stack; the else and `??` fallback handle bare | ||
| `Promise.reject('string')` and similar exotic shapes. */ | ||
| if (err && (err.stack || err.message)) { | ||
| stackOrMsg = redactSecrets(err.stack ?? err.message); | ||
| } else { | ||
| stackOrMsg = redactSecrets(String(err)); | ||
| } | ||
| logger('cli').error(`${label}: ${stackOrMsg}`); | ||
| /* istanbul ignore else: activeContext is null only between runs */ | ||
| if (activeContext) activeContext.runFailed = true; | ||
| } | ||
| // Attach process-wide handlers exactly once per Node process. Repeated | ||
| // invocations of the command runner (e.g., back-to-back tests) reuse | ||
| // the same handlers. | ||
| let _processHandlersAttached = false; | ||
| function ensureProcessHandlers() { | ||
| if (_processHandlersAttached) return; | ||
| process.on('unhandledRejection', err => onUnhandled('Unhandled promise rejection', err)); | ||
| /* istanbul ignore next: uncaughtException is a defensive backstop; | ||
| synthesizing one in a test crashes Jasmine before assertions run. | ||
| The handler delegates to the same `onUnhandled` function that the | ||
| unhandledRejection path covers in shutdown.test.js. */ | ||
| process.on('uncaughtException', err => onUnhandled('Uncaught exception', err)); | ||
| _processHandlersAttached = true; | ||
| } | ||
| // Test-only: reset module-level state between specs. Without this, | ||
| // shutdownState.signal stuck from one spec leaks into the next. | ||
| export function _resetShutdownForTest() { | ||
| if (shutdownState.drainTimer) clearTimeout(shutdownState.drainTimer); | ||
| if (shutdownState.hardExitTimer) clearTimeout(shutdownState.hardExitTimer); | ||
| shutdownState = { | ||
| signal: null, | ||
| forced: false, | ||
| drainTimer: null, | ||
| hardExitTimer: null | ||
| }; | ||
| activeContext = null; | ||
| } | ||
| // Copies a command definition and adds built-in flags and config options. | ||
@@ -76,2 +197,20 @@ function withBuiltIns(definition) { | ||
| async function runCommandWithContext(parsed) { | ||
| // Reset shutdown state at the start of each run so | ||
| // that a `process.emit('SIGINT')` left over from a previous spec | ||
| // does not leak `shutdownState.signal` into a fresh test run. In | ||
| // production (one runner invocation per Node process), this is a | ||
| // no-op the first time around. Defensive: tests reset via the | ||
| // exported `_resetShutdownForTest()` helper so the auto-reset | ||
| // branch here only fires in edge cases. | ||
| /* istanbul ignore if */ | ||
| if (shutdownState.signal || shutdownState.forced) { | ||
| if (shutdownState.drainTimer) clearTimeout(shutdownState.drainTimer); | ||
| if (shutdownState.hardExitTimer) clearTimeout(shutdownState.hardExitTimer); | ||
| shutdownState = { | ||
| signal: null, | ||
| forced: false, | ||
| drainTimer: null, | ||
| hardExitTimer: null | ||
| }; | ||
| } | ||
| let { | ||
@@ -85,2 +224,5 @@ command, | ||
| // include flags, args, argv, logger, exit helper, and env info | ||
| // ctx.shutdown exposes the module-level shutdown state to commands so | ||
| // they can call `percy.stop(ctx.shutdown.forced)` | ||
| // for graceful-on-first-signal, force-on-second-signal behavior. | ||
| let context = { | ||
@@ -91,3 +233,5 @@ flags, | ||
| log, | ||
| exit | ||
| exit, | ||
| shutdown: shutdownState, | ||
| runFailed: false | ||
| }; | ||
@@ -97,2 +241,5 @@ let env = context.env = process.env; | ||
| let def = command.definition; | ||
| // Track this run for the global unhandled-rejection handler. | ||
| activeContext = context; | ||
| ensureProcessHandlers(); | ||
@@ -128,9 +275,18 @@ // automatically include a preconfigured percy instance | ||
| // process signals will abort | ||
| // process signals will abort. SIGINT/SIGTERM also engage the | ||
| // module-level shutdown state for drain semantics; the | ||
| // existing AbortError unwind path is preserved unchanged so commands | ||
| // that already catch AbortError keep working. AbortController.abort | ||
| // is idempotent — re-entry on a second SIGINT during the same run | ||
| // is benign for the controller and required for the drain | ||
| // escalation in beginShutdown. | ||
| let ctrl = new AbortController(); | ||
| let signals = ['SIGUSR1', 'SIGUSR2', 'SIGTERM', 'SIGINT', 'SIGHUP'].map(signal => { | ||
| let handler = () => ctrl.abort(new AbortError(signal, { | ||
| signal, | ||
| exitCode: 0 | ||
| })); | ||
| let handler = () => { | ||
| beginShutdown(signal); | ||
| ctrl.abort(new AbortError(signal, { | ||
| signal, | ||
| exitCode: 0 | ||
| })); | ||
| }; | ||
| handler.off = () => process.off(signal, handler); | ||
@@ -142,6 +298,30 @@ process.on(signal, handler); | ||
| // run the command callback with context and cleanup handlers after | ||
| await generatePromise(command.callback(context), ctrl.signal, error => { | ||
| try { | ||
| await generatePromise(command.callback(context), ctrl.signal, error => { | ||
| for (let handler of signals) handler.off(); | ||
| if (error) throw error; | ||
| }); | ||
| } finally { | ||
| // Belt-and-suspenders: ensure handlers are removed even on paths | ||
| // where generatePromise's cleanup callback didn't fire, so | ||
| // back-to-back test runs don't accumulate listeners. | ||
| for (let handler of signals) handler.off(); | ||
| if (error) throw error; | ||
| }); | ||
| // Clear active context so a subsequent unhandled rejection (e.g. | ||
| // from a leaked promise after this command completed) is not | ||
| // attributed to it. Defensive: `activeContext === context` is | ||
| // always true on normal flow — the guard only matters if a | ||
| // nested runner or test isolation issue swapped activeContext. | ||
| /* istanbul ignore else */ | ||
| if (activeContext === context) activeContext = null; | ||
| } | ||
| // If a global unhandled rejection fired during this run (and the | ||
| // command did not itself throw), fail loudly at | ||
| // the end so CI does not see a green build. Pre-existing thrown | ||
| // errors are preserved by the fact that we only reach here on | ||
| // success. | ||
| if (context.runFailed) { | ||
| throw Object.assign(new Error('Run failed: see preceding logs for details'), { | ||
| exitCode: 1 | ||
| }); | ||
| } | ||
| } | ||
@@ -181,2 +361,19 @@ | ||
| // Signal-driven shutdown — when SIGINT/SIGTERM was received | ||
| // during this run, set the signal-derived exit code | ||
| // (130 SIGINT / 143 SIGTERM) and return. We deliberately set | ||
| // `process.exitCode` and unwind cleanly rather than calling | ||
| // `process.exit()`, so the surrounding catch's finally block (and | ||
| // the lockfile's `process.on('exit')` handler) still run. The | ||
| // event loop drains naturally because the unref'd drain/hard-exit | ||
| // timers don't keep it alive. Tests with `exitOnError: false` | ||
| // preserve the legacy clean-resolution behavior because | ||
| // AbortError carries exitCode:0 and the gate below is skipped. | ||
| if (shutdownState.signal && err.signal && definition.exitOnError) { | ||
| let signalCode = SIGNAL_EXIT_CODES[shutdownState.signal]; | ||
| let percyExitWithZeroOnError = process.env.PERCY_EXIT_WITH_ZERO_ON_ERROR === 'true'; | ||
| process.exitCode = percyExitWithZeroOnError ? 0 : signalCode; | ||
| return; | ||
| } | ||
| // exit when appropriate | ||
@@ -183,0 +380,0 @@ if (err.exitCode !== 0) { |
+1
-1
@@ -1,2 +0,2 @@ | ||
| export { default, command } from './command.js'; | ||
| export { default, command, _resetShutdownForTest } from './command.js'; | ||
| export { legacyCommand, legacyFlags as flags } from './legacy.js'; | ||
@@ -3,0 +3,0 @@ // export common packages to avoid dependency resolution issues |
+6
-6
| { | ||
| "name": "@percy/cli-command", | ||
| "version": "1.31.13", | ||
| "version": "1.31.14-beta.0", | ||
| "license": "MIT", | ||
@@ -12,3 +12,3 @@ "repository": { | ||
| "access": "public", | ||
| "tag": "latest" | ||
| "tag": "beta" | ||
| }, | ||
@@ -40,7 +40,7 @@ "files": [ | ||
| "dependencies": { | ||
| "@percy/config": "1.31.13", | ||
| "@percy/core": "1.31.13", | ||
| "@percy/logger": "1.31.13" | ||
| "@percy/config": "1.31.14-beta.0", | ||
| "@percy/core": "1.31.14-beta.0", | ||
| "@percy/logger": "1.31.14-beta.0" | ||
| }, | ||
| "gitHead": "7d28705b323836680b22f96e961ed5f39f09a56b" | ||
| "gitHead": "a87281473a9f5cb69a3030845cc4d6b4b81509b0" | ||
| } |
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
61514
17.5%1246
17.99%2
100%7
16.67%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated