🚀. Socket Launch Week Day 2:Introducing Manifest Alerts.Learn more
Sign In

@percy/cli-command

Package Overview
Dependencies
Maintainers
1
Versions
363
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@percy/cli-command - npm Package Compare versions

Comparing version
1.31.13
to
1.31.14-beta.0
+207
-10
dist/command.js
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

{
"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"
}