@percy/cli-command 1.31.13-beta.0 → 1.31.14-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/command.js CHANGED
@@ -1,12 +1,133 @@
1
1
  import logger from '@percy/logger';
2
2
  import PercyConfig from '@percy/config';
3
3
  import { set, del } from '@percy/config/utils';
4
- import { generatePromise, AbortController, AbortError } from '@percy/core/utils';
4
+ import { generatePromise, AbortController, AbortError, redactSecrets } from '@percy/core/utils';
5
5
  import * as CoreConfig from '@percy/core/config';
6
6
  import * as builtInFlags from './flags.js';
7
7
  import formatHelp from './help.js';
8
8
  import parse from './parse.js';
9
9
 
10
+ // Module-level shutdown state for graceful drain on
11
+ // SIGINT/SIGTERM. Per-run signal handlers (registered in
12
+ // runCommandWithContext below) delegate here so the state is accessible
13
+ // to commands via ctx.shutdown without prop-drilling.
14
+ //
15
+ // The state is intentionally module-level (not per-runner) so that a
16
+ // process-wide `process.on('exit')` cleanup can read it. Tests reset
17
+ // via the exported `_resetShutdownForTest()` helper.
18
+ let shutdownState = {
19
+ signal: null,
20
+ // 'SIGINT' / 'SIGTERM' once received, null otherwise
21
+ forced: false,
22
+ // escalates on second signal or 30s drain timeout
23
+ drainTimer: null,
24
+ hardExitTimer: null
25
+ };
26
+
27
+ // Tracks the active context so the global unhandled-rejection handler
28
+ // can flag the run as failed without requiring the command to plumb
29
+ // state through. Reset between runs (and between tests).
30
+ let activeContext = null;
31
+ const DEFAULT_DRAIN_MS = 30_000;
32
+ const HARD_EXIT_AFTER_FORCE_MS = 5_000;
33
+ // POSIX-conventional signal exit codes: 128 + signal number.
34
+ const EXIT_CODE_SIGINT = 130;
35
+ const EXIT_CODE_SIGTERM = 143;
36
+ const SIGNAL_EXIT_CODES = {
37
+ SIGINT: EXIT_CODE_SIGINT,
38
+ SIGTERM: EXIT_CODE_SIGTERM
39
+ };
40
+
41
+ // Begin or escalate drain. Idempotent on the same signal.
42
+ function beginShutdown(signal) {
43
+ // Only SIGINT/SIGTERM trigger drain semantics (origin scope).
44
+ // Other signals fall through to the per-run AbortController without
45
+ // setting drain state. Defensive: SIGHUP/USR1/USR2 are also bound
46
+ // by the existing handler in runCommandWithContext for legacy
47
+ // behavior, so this guard catches them — but exercising this branch
48
+ // would emit a real SIGHUP/USR* in tests, which interferes with the
49
+ // Jasmine runner under nyc instrumentation.
50
+ /* istanbul ignore if */
51
+ if (signal !== 'SIGINT' && signal !== 'SIGTERM') return;
52
+ if (shutdownState.signal) {
53
+ // Second signal: escalate to forced and arm hard-exit fallback in
54
+ // case the in-flight stop hangs.
55
+ shutdownState.forced = true;
56
+ /* istanbul ignore next: timer guard against doubled escalation,
57
+ and the inner setTimeout callback only fires when percy.stop
58
+ hangs after the second signal — a 5s wait that is impractical
59
+ to test reliably under nyc instrumentation. The double-signal
60
+ behavior up to and including `forced=true` is verified by the
61
+ shutdown.forced test in cli-command/test/shutdown.test.js. */
62
+ if (!shutdownState.hardExitTimer) {
63
+ shutdownState.hardExitTimer = setTimeout(() => process.exit(SIGNAL_EXIT_CODES[signal]), HARD_EXIT_AFTER_FORCE_MS).unref();
64
+ }
65
+ logger('cli').error(`${signal} received again; force-exiting.`);
66
+ return;
67
+ }
68
+ shutdownState.signal = signal;
69
+ logger('cli').warn(`${signal} received, draining (press Ctrl-C again to force)...`);
70
+ // 30s drain budget: if percy.stop(false) hasn't completed, escalate
71
+ // to forced. Subsequent stop calls (or the hard-exit timer) take it
72
+ // from there. Coverage exclusion: testing this branch requires
73
+ // either a real 30s wait or jasmine.clock(), which conflicts with
74
+ // the runner's await-of-microtask-yields under nyc instrumentation.
75
+ // The behavior is exercised end-to-end by the second-signal force
76
+ // path in the same suite.
77
+ shutdownState.drainTimer = setTimeout(/* istanbul ignore next */
78
+ () => {
79
+ shutdownState.forced = true;
80
+ }, DEFAULT_DRAIN_MS).unref();
81
+ }
82
+
83
+ // Global handlers for unhandled rejection / uncaught exception. The
84
+ // stack is routed through redactSecrets because CDP rejections can
85
+ // include serialized page-script bodies, Authorization headers, or
86
+ // cookie strings.
87
+ function onUnhandled(label, err) {
88
+ let stackOrMsg;
89
+ /* istanbul ignore next: defensive — `err` is virtually always an
90
+ Error with a stack; the else and `??` fallback handle bare
91
+ `Promise.reject('string')` and similar exotic shapes. */
92
+ if (err && (err.stack || err.message)) {
93
+ stackOrMsg = redactSecrets(err.stack ?? err.message);
94
+ } else {
95
+ stackOrMsg = redactSecrets(String(err));
96
+ }
97
+ logger('cli').error(`${label}: ${stackOrMsg}`);
98
+ /* istanbul ignore else: activeContext is null only between runs */
99
+ if (activeContext) activeContext.runFailed = true;
100
+ }
101
+
102
+ // Attach process-wide handlers exactly once per Node process. Repeated
103
+ // invocations of the command runner (e.g., back-to-back tests) reuse
104
+ // the same handlers.
105
+ let _processHandlersAttached = false;
106
+ function ensureProcessHandlers() {
107
+ if (_processHandlersAttached) return;
108
+ process.on('unhandledRejection', err => onUnhandled('Unhandled promise rejection', err));
109
+ /* istanbul ignore next: uncaughtException is a defensive backstop;
110
+ synthesizing one in a test crashes Jasmine before assertions run.
111
+ The handler delegates to the same `onUnhandled` function that the
112
+ unhandledRejection path covers in shutdown.test.js. */
113
+ process.on('uncaughtException', err => onUnhandled('Uncaught exception', err));
114
+ _processHandlersAttached = true;
115
+ }
116
+
117
+ // Test-only: reset module-level state between specs. Without this,
118
+ // shutdownState.signal stuck from one spec leaks into the next.
119
+ export function _resetShutdownForTest() {
120
+ if (shutdownState.drainTimer) clearTimeout(shutdownState.drainTimer);
121
+ if (shutdownState.hardExitTimer) clearTimeout(shutdownState.hardExitTimer);
122
+ shutdownState = {
123
+ signal: null,
124
+ forced: false,
125
+ drainTimer: null,
126
+ hardExitTimer: null
127
+ };
128
+ activeContext = null;
129
+ }
130
+
10
131
  // Copies a command definition and adds built-in flags and config options.
11
132
  function withBuiltIns(definition) {
12
133
  let def = {
@@ -72,6 +193,24 @@ function exit(exitCode, reason = '', shouldOverrideExitCode = true) {
72
193
  // Runs the parsed command callback with a contextual argument consisting of specific parsed input
73
194
  // and other common command helpers and properties.
74
195
  async function runCommandWithContext(parsed) {
196
+ // Reset shutdown state at the start of each run so
197
+ // that a `process.emit('SIGINT')` left over from a previous spec
198
+ // does not leak `shutdownState.signal` into a fresh test run. In
199
+ // production (one runner invocation per Node process), this is a
200
+ // no-op the first time around. Defensive: tests reset via the
201
+ // exported `_resetShutdownForTest()` helper so the auto-reset
202
+ // branch here only fires in edge cases.
203
+ /* istanbul ignore if */
204
+ if (shutdownState.signal || shutdownState.forced) {
205
+ if (shutdownState.drainTimer) clearTimeout(shutdownState.drainTimer);
206
+ if (shutdownState.hardExitTimer) clearTimeout(shutdownState.hardExitTimer);
207
+ shutdownState = {
208
+ signal: null,
209
+ forced: false,
210
+ drainTimer: null,
211
+ hardExitTimer: null
212
+ };
213
+ }
75
214
  let {
76
215
  command,
77
216
  flags,
@@ -80,16 +219,24 @@ async function runCommandWithContext(parsed) {
80
219
  log
81
220
  } = parsed;
82
221
  // include flags, args, argv, logger, exit helper, and env info
222
+ // ctx.shutdown exposes the module-level shutdown state to commands so
223
+ // they can call `percy.stop(ctx.shutdown.forced)`
224
+ // for graceful-on-first-signal, force-on-second-signal behavior.
83
225
  let context = {
84
226
  flags,
85
227
  args,
86
228
  argv,
87
229
  log,
88
- exit
230
+ exit,
231
+ shutdown: shutdownState,
232
+ runFailed: false
89
233
  };
90
234
  let env = context.env = process.env;
91
235
  let pkg = command.packageInformation;
92
236
  let def = command.definition;
237
+ // Track this run for the global unhandled-rejection handler.
238
+ activeContext = context;
239
+ ensureProcessHandlers();
93
240
 
94
241
  // automatically include a preconfigured percy instance
95
242
  if (def.percy) {
@@ -120,23 +267,56 @@ async function runCommandWithContext(parsed) {
120
267
  });
121
268
  }
122
269
 
123
- // process signals will abort
270
+ // process signals will abort. SIGINT/SIGTERM also engage the
271
+ // module-level shutdown state for drain semantics; the
272
+ // existing AbortError unwind path is preserved unchanged so commands
273
+ // that already catch AbortError keep working. AbortController.abort
274
+ // is idempotent — re-entry on a second SIGINT during the same run
275
+ // is benign for the controller and required for the drain
276
+ // escalation in beginShutdown.
124
277
  let ctrl = new AbortController();
125
278
  let signals = ['SIGUSR1', 'SIGUSR2', 'SIGTERM', 'SIGINT', 'SIGHUP'].map(signal => {
126
- let handler = () => ctrl.abort(new AbortError(signal, {
127
- signal,
128
- exitCode: 0
129
- }));
279
+ let handler = () => {
280
+ beginShutdown(signal);
281
+ ctrl.abort(new AbortError(signal, {
282
+ signal,
283
+ exitCode: 0
284
+ }));
285
+ };
130
286
  handler.off = () => process.off(signal, handler);
131
287
  process.on(signal, handler);
132
288
  return handler;
133
289
  });
134
290
 
135
291
  // run the command callback with context and cleanup handlers after
136
- await generatePromise(command.callback(context), ctrl.signal, error => {
292
+ try {
293
+ await generatePromise(command.callback(context), ctrl.signal, error => {
294
+ for (let handler of signals) handler.off();
295
+ if (error) throw error;
296
+ });
297
+ } finally {
298
+ // Belt-and-suspenders: ensure handlers are removed even on paths
299
+ // where generatePromise's cleanup callback didn't fire, so
300
+ // back-to-back test runs don't accumulate listeners.
137
301
  for (let handler of signals) handler.off();
138
- if (error) throw error;
139
- });
302
+ // Clear active context so a subsequent unhandled rejection (e.g.
303
+ // from a leaked promise after this command completed) is not
304
+ // attributed to it. Defensive: `activeContext === context` is
305
+ // always true on normal flow — the guard only matters if a
306
+ // nested runner or test isolation issue swapped activeContext.
307
+ /* istanbul ignore else */
308
+ if (activeContext === context) activeContext = null;
309
+ }
310
+ // If a global unhandled rejection fired during this run (and the
311
+ // command did not itself throw), fail loudly at
312
+ // the end so CI does not see a green build. Pre-existing thrown
313
+ // errors are preserved by the fact that we only reach here on
314
+ // success.
315
+ if (context.runFailed) {
316
+ throw Object.assign(new Error('Run failed: see preceding logs for details'), {
317
+ exitCode: 1
318
+ });
319
+ }
140
320
  }
141
321
 
142
322
  // Returns a command runner function that when run will parse provided command-line options and run
@@ -171,6 +351,23 @@ export function command(name, definition, callback) {
171
351
  if (err.exitCode === 0) log.warn(err.message);else log.error(err);
172
352
  }
173
353
 
354
+ // Signal-driven shutdown — when SIGINT/SIGTERM was received
355
+ // during this run, set the signal-derived exit code
356
+ // (130 SIGINT / 143 SIGTERM) and return. We deliberately set
357
+ // `process.exitCode` and unwind cleanly rather than calling
358
+ // `process.exit()`, so the surrounding catch's finally block (and
359
+ // the lockfile's `process.on('exit')` handler) still run. The
360
+ // event loop drains naturally because the unref'd drain/hard-exit
361
+ // timers don't keep it alive. Tests with `exitOnError: false`
362
+ // preserve the legacy clean-resolution behavior because
363
+ // AbortError carries exitCode:0 and the gate below is skipped.
364
+ if (shutdownState.signal && err.signal && definition.exitOnError) {
365
+ let signalCode = SIGNAL_EXIT_CODES[shutdownState.signal];
366
+ let percyExitWithZeroOnError = process.env.PERCY_EXIT_WITH_ZERO_ON_ERROR === 'true';
367
+ process.exitCode = percyExitWithZeroOnError ? 0 : signalCode;
368
+ return;
369
+ }
370
+
174
371
  // exit when appropriate
175
372
  if (err.exitCode !== 0) {
176
373
  err.exitCode ?? (err.exitCode = 1);
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { default, command } from './command.js';
1
+ export { default, command, _resetShutdownForTest } from './command.js';
2
2
  export { legacyCommand, legacyFlags as flags } from './legacy.js';
3
3
  // export common packages to avoid dependency resolution issues
4
4
  export { default as PercyConfig } from '@percy/config';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/cli-command",
3
- "version": "1.31.13-beta.0",
3
+ "version": "1.31.14-beta.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,9 +36,9 @@
36
36
  "test:coverage": "yarn test --coverage"
37
37
  },
38
38
  "dependencies": {
39
- "@percy/config": "1.31.13-beta.0",
40
- "@percy/core": "1.31.13-beta.0",
41
- "@percy/logger": "1.31.13-beta.0"
39
+ "@percy/config": "1.31.14-beta.0",
40
+ "@percy/core": "1.31.14-beta.0",
41
+ "@percy/logger": "1.31.14-beta.0"
42
42
  },
43
- "gitHead": "69c6ad10d5724455cebf57f2043adc30559c3b31"
43
+ "gitHead": "a87281473a9f5cb69a3030845cc4d6b4b81509b0"
44
44
  }