@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 +207 -10
- package/dist/index.js +1 -1
- package/package.json +5 -5
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 = () =>
|
|
127
|
-
signal
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
40
|
-
"@percy/core": "1.31.
|
|
41
|
-
"@percy/logger": "1.31.
|
|
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": "
|
|
43
|
+
"gitHead": "a87281473a9f5cb69a3030845cc4d6b4b81509b0"
|
|
44
44
|
}
|