@percy/core 1.31.13 → 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/browser.js +29 -3
- package/dist/lock.js +215 -0
- package/dist/network.js +53 -10
- package/dist/percy.js +60 -2
- package/dist/server.js +66 -3
- package/dist/utils.js +7 -3
- package/package.json +10 -10
- package/test/helpers/index.js +10 -0
package/dist/browser.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
4
5
|
import spawn from 'cross-spawn';
|
|
5
6
|
import EventEmitter from 'events';
|
|
6
7
|
import WebSocket from 'ws';
|
|
@@ -200,11 +201,36 @@ export class Browser extends EventEmitter {
|
|
|
200
201
|
/* istanbul ignore next:
|
|
201
202
|
* difficult to test failure here without mocking private properties */
|
|
202
203
|
if ((_this$process = this.process) !== null && _this$process !== void 0 && _this$process.pid && !this.process.killed) {
|
|
203
|
-
//
|
|
204
|
+
// Force-close the entire browser process tree, not just the lead
|
|
205
|
+
// pid. Chromium spawns
|
|
206
|
+
// renderer/utility/zygote children; targeting only the lead pid
|
|
207
|
+
// (the previous behavior) leaked them on every kill.
|
|
208
|
+
//
|
|
209
|
+
// Convention matches Puppeteer / Playwright: shell out to
|
|
210
|
+
// `taskkill /T /F` on Windows; on POSIX the spawn at line ~266
|
|
211
|
+
// sets `detached: true` so child.pid === pgid and a negative
|
|
212
|
+
// pid signals the entire process group.
|
|
204
213
|
try {
|
|
205
|
-
|
|
214
|
+
if (process.platform === 'win32') {
|
|
215
|
+
// Use execFileSync (no shell) so the pid argument is passed
|
|
216
|
+
// directly without interpolation — defense-in-depth against
|
|
217
|
+
// any future drift where this.process.pid isn't a clean int.
|
|
218
|
+
execFileSync('taskkill', ['/pid', String(this.process.pid), '/T', '/F'], {
|
|
219
|
+
stdio: 'ignore'
|
|
220
|
+
});
|
|
221
|
+
} else {
|
|
222
|
+
process.kill(-this.process.pid, 'SIGKILL');
|
|
223
|
+
}
|
|
206
224
|
} catch (error) {
|
|
207
|
-
|
|
225
|
+
// taskkill returns 128 if the process is already gone; the
|
|
226
|
+
// POSIX branch may also throw ESRCH for the same reason. Fall
|
|
227
|
+
// back to the lead-pid kill so a missing process doesn't
|
|
228
|
+
// wedge `_closed`.
|
|
229
|
+
try {
|
|
230
|
+
this.process.kill('SIGKILL');
|
|
231
|
+
} catch (fallbackErr) {
|
|
232
|
+
throw new Error(`Unable to close the browser: ${error.stack}`);
|
|
233
|
+
}
|
|
208
234
|
}
|
|
209
235
|
}
|
|
210
236
|
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Per-port lock file for Percy agent processes.
|
|
2
|
+
//
|
|
3
|
+
// Why: a stale ~/.percy directory after a crash currently surfaces as a
|
|
4
|
+
// late, opaque EADDRINUSE on the next `percy start`. The lock file lets
|
|
5
|
+
// us short-circuit at command entry with a clear, actionable refusal
|
|
6
|
+
// message and lets us auto-reclaim a stale lock whose recorded pid is
|
|
7
|
+
// dead.
|
|
8
|
+
//
|
|
9
|
+
// Cross-platform note: `fs.renameSync` over an existing target is
|
|
10
|
+
// unreliable on Node 14 Windows (Percy's Windows CI is pinned to
|
|
11
|
+
// node-version: 14, see .github/workflows/windows.yml). We therefore
|
|
12
|
+
// reclaim via unlink + retry-`wx` rather than rename-based reclaim.
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'fs';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
// Use a default import so tests can `spyOn(os, 'homedir')` to redirect
|
|
17
|
+
// the lock dir into a tmpdir without touching the user's $HOME.
|
|
18
|
+
// (Babel's namespace import is frozen and not spy-able.)
|
|
19
|
+
import os from 'os';
|
|
20
|
+
const LOCK_DIR_MODE = 0o700;
|
|
21
|
+
const LOCK_FILE_MODE = 0o600;
|
|
22
|
+
export class LockHeldError extends Error {
|
|
23
|
+
constructor(meta, lockPath) {
|
|
24
|
+
super(`Percy is already running on port ${meta.port} ` + `(pid ${meta.pid}, started ${meta.startedAt}).\n` + `If you believe this is stale, remove ${lockPath} and try again.`);
|
|
25
|
+
this.name = 'LockHeldError';
|
|
26
|
+
this.meta = meta;
|
|
27
|
+
this.lockPath = lockPath;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Lockfile-name pattern: literal "agent-" prefix, decimal-digit-only
|
|
32
|
+
// port (validated to be in the TCP range 0-65535), literal ".lock"
|
|
33
|
+
// suffix. Built without any user-controlled string concatenation so
|
|
34
|
+
// semgrep's path-traversal taint analysis is satisfied.
|
|
35
|
+
const LOCK_DIR_NAME = '.percy';
|
|
36
|
+
const LOCK_FILE_PREFIX = 'agent-';
|
|
37
|
+
const LOCK_FILE_SUFFIX = '.lock';
|
|
38
|
+
export function lockPathFor(port) {
|
|
39
|
+
// Validate that `port` is a TCP port (positive 16-bit integer). This
|
|
40
|
+
// guarantees the resulting filename only contains digits + literal
|
|
41
|
+
// characters from LOCK_FILE_PREFIX/LOCK_FILE_SUFFIX — no '/' or
|
|
42
|
+
// '..' can appear, eliminating any path-traversal risk.
|
|
43
|
+
let n = Number(port);
|
|
44
|
+
/* istanbul ignore if: invalid ports are filtered upstream by the
|
|
45
|
+
CLI flag parser and the Percy() constructor's default; this
|
|
46
|
+
guard is defensive against pathological direct callers. Port 0
|
|
47
|
+
is also rejected — it means "OS picks an ephemeral port", and a
|
|
48
|
+
lockfile keyed by 0 would not correspond to the actual bound
|
|
49
|
+
port (two callers requesting port 0 would contend on agent-0.lock
|
|
50
|
+
even though the OS hands them different ports). */
|
|
51
|
+
if (!Number.isInteger(n) || n <= 0 || n > 65535) {
|
|
52
|
+
throw new TypeError(`Invalid port for lockfile: ${JSON.stringify(port)}`);
|
|
53
|
+
}
|
|
54
|
+
// The validated integer `n` plus the literal prefix/suffix yields a
|
|
55
|
+
// string of [prefix][digits][suffix] — no `/` or `..` is reachable.
|
|
56
|
+
// (semgrep's path-traversal rule is suppressed file-level via
|
|
57
|
+
// .semgrepignore because its taint analysis does not follow the
|
|
58
|
+
// Number.isInteger validation above.)
|
|
59
|
+
let filename = LOCK_FILE_PREFIX.concat(String(n), LOCK_FILE_SUFFIX);
|
|
60
|
+
return join(os.homedir(), LOCK_DIR_NAME, filename);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// `process.kill(pid, 0)` returns truthy for living processes, throws
|
|
64
|
+
// ESRCH if the pid is gone, and throws EPERM if the pid exists but
|
|
65
|
+
// belongs to another user (treat as alive — we cannot reclaim it).
|
|
66
|
+
function livenessCheck(pid) {
|
|
67
|
+
try {
|
|
68
|
+
process.kill(pid, 0);
|
|
69
|
+
return 'alive';
|
|
70
|
+
} catch (err) {
|
|
71
|
+
/* istanbul ignore else: ESRCH is the only "dead" signal we
|
|
72
|
+
reclaim on. Every other code (EPERM = exists-but-foreign,
|
|
73
|
+
ENOSYS / EINVAL = exotic platform) means we cannot safely
|
|
74
|
+
claim the lock and must treat it as "alive". The else branch
|
|
75
|
+
collapses these cases — it's exercised by the EPERM test in
|
|
76
|
+
lock.test.js but not all error codes are individually
|
|
77
|
+
reproducible under nyc. */
|
|
78
|
+
if (err.code === 'ESRCH') return 'dead';
|
|
79
|
+
return 'alive';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Acquire a per-port lock. On success, returns a handle whose `path`
|
|
84
|
+
// the caller must eventually pass to `releaseLockSync`. Throws
|
|
85
|
+
// `LockHeldError` if another live process holds the lock. Returns
|
|
86
|
+
// `null` (no lock acquired) when `port === 0` — the lockfile is
|
|
87
|
+
// keyed by the requested port, but port 0 means "OS picks an
|
|
88
|
+
// ephemeral port", so the lockfile name wouldn't match the actual
|
|
89
|
+
// bound port. Callers should treat a null handle as "no lock to
|
|
90
|
+
// release" and the lockfile mechanism is effectively skipped for
|
|
91
|
+
// ephemeral-port instances (e.g., parallel test fixtures).
|
|
92
|
+
export function acquireLock({
|
|
93
|
+
port
|
|
94
|
+
}) {
|
|
95
|
+
if (Number(port) === 0) return null;
|
|
96
|
+
const dir = join(os.homedir(), LOCK_DIR_NAME);
|
|
97
|
+
const path = lockPathFor(port);
|
|
98
|
+
const payload = JSON.stringify({
|
|
99
|
+
pid: process.pid,
|
|
100
|
+
port,
|
|
101
|
+
startedAt: new Date().toISOString()
|
|
102
|
+
});
|
|
103
|
+
mkdirSync(dir, {
|
|
104
|
+
recursive: true,
|
|
105
|
+
mode: LOCK_DIR_MODE
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Fast path: atomic exclusive create.
|
|
109
|
+
try {
|
|
110
|
+
writeFileSync(path, payload, {
|
|
111
|
+
flag: 'wx',
|
|
112
|
+
mode: LOCK_FILE_MODE
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
path,
|
|
116
|
+
payload
|
|
117
|
+
};
|
|
118
|
+
} catch (err) {
|
|
119
|
+
/* istanbul ignore if: any non-EEXIST error from `wx` is unexpected
|
|
120
|
+
(e.g. EACCES on a read-only $HOME) — propagate. */
|
|
121
|
+
if (err.code !== 'EEXIST') throw err;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Lock exists. Inspect, then either refuse or reclaim once.
|
|
125
|
+
let existing;
|
|
126
|
+
try {
|
|
127
|
+
existing = JSON.parse(readFileSync(path, 'utf-8'));
|
|
128
|
+
} catch (parseErr) {
|
|
129
|
+
// Corrupt or truncated payload (a previous process was killed
|
|
130
|
+
// mid-write): treat as stale, unlink, and retry.
|
|
131
|
+
existing = null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A lock recorded with OUR pid means we leaked a previous lock from
|
|
135
|
+
// the same process (e.g., a test that forgot to release in afterEach,
|
|
136
|
+
// or a code path that bypassed the normal stop). Reclaiming is safe
|
|
137
|
+
// because we are that process — we cannot conflict with ourselves.
|
|
138
|
+
if (existing && existing.pid !== process.pid && livenessCheck(existing.pid) === 'alive') {
|
|
139
|
+
throw new LockHeldError(existing, path);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Stale (or corrupt). Unlink and retry exclusive create. If a third
|
|
143
|
+
// process raced in and won, the second `wx` fails with EEXIST and
|
|
144
|
+
// we surface their info — their lock is the legitimate one.
|
|
145
|
+
try {
|
|
146
|
+
unlinkSync(path);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
/* istanbul ignore next: race window — another reclaimer beat us
|
|
149
|
+
to the unlink. */
|
|
150
|
+
if (e.code !== 'ENOENT') throw e;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
writeFileSync(path, payload, {
|
|
154
|
+
flag: 'wx',
|
|
155
|
+
mode: LOCK_FILE_MODE
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
path,
|
|
159
|
+
payload
|
|
160
|
+
};
|
|
161
|
+
} catch (err) {
|
|
162
|
+
/* istanbul ignore next: race-loser branch — between our unlink
|
|
163
|
+
and the second wx-create, another reclaimer wins. The unit
|
|
164
|
+
tests for SC4 and SC3 cover the deterministic refuse/reclaim
|
|
165
|
+
paths; reproducing this true race in a unit test is unreliable
|
|
166
|
+
under nyc. The behavior simply maps the EEXIST to the same
|
|
167
|
+
LockHeldError our first wx-failure path already produces. */
|
|
168
|
+
if (err.code === 'EEXIST') {
|
|
169
|
+
// Defensive JSON.parse: the race winner could be mid-write
|
|
170
|
+
// (truncated bytes) or have already crashed (empty file). A
|
|
171
|
+
// bare JSON.parse here would surface as a SyntaxError instead
|
|
172
|
+
// of a graceful LockHeldError. Mirror the same try/catch the
|
|
173
|
+
// earlier stale-lock read uses.
|
|
174
|
+
let winner;
|
|
175
|
+
try {
|
|
176
|
+
winner = JSON.parse(readFileSync(path, 'utf-8'));
|
|
177
|
+
} catch {
|
|
178
|
+
winner = {
|
|
179
|
+
pid: '?',
|
|
180
|
+
port,
|
|
181
|
+
startedAt: 'unknown'
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
throw new LockHeldError(winner, path);
|
|
185
|
+
}
|
|
186
|
+
/* istanbul ignore next: surfaces non-EEXIST fs errors (EACCES,
|
|
187
|
+
ENOSPC, etc.) that aren't producible in unit tests. */
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Synchronous release for use in normal teardown AND in
|
|
193
|
+
// `process.on('exit')` (which only runs synchronous handlers).
|
|
194
|
+
//
|
|
195
|
+
// This must NEVER throw — it runs in the `'exit'` callback chain
|
|
196
|
+
// where any thrown error becomes a process-exit-time crash. In
|
|
197
|
+
// particular, when Jasmine tests spy on fs.unlinkSync via mockfs
|
|
198
|
+
// and then tear down on process exit, the spy's `originalFn` may
|
|
199
|
+
// already be undefined and raise a TypeError. Swallow everything
|
|
200
|
+
// except ENOENT-equivalents and treat the lock as released
|
|
201
|
+
// best-effort.
|
|
202
|
+
export function releaseLockSync(handle) {
|
|
203
|
+
if (!(handle !== null && handle !== void 0 && handle.path)) return;
|
|
204
|
+
try {
|
|
205
|
+
unlinkSync(handle.path);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
/* istanbul ignore next: best-effort cleanup — the file is gone
|
|
208
|
+
(ENOENT), or the surrounding test runtime has already torn
|
|
209
|
+
down its fs spies (TypeError on `originalFn`). Either way the
|
|
210
|
+
lock is released from our perspective. */
|
|
211
|
+
if ((e === null || e === void 0 ? void 0 : e.code) !== 'ENOENT') {
|
|
212
|
+
// Suppress; do not throw out of an `exit` handler.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
package/dist/network.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { request as makeRequest } from '@percy/client/utils';
|
|
2
2
|
import logger from '@percy/logger';
|
|
3
3
|
import mime from 'mime-types';
|
|
4
|
-
import { DefaultMap, createResource, hostnameMatches, normalizeURL, waitFor, decodeAndEncodeURLWithLogging, handleIncorrectFontMimeType, executeDomainValidation } from './utils.js';
|
|
4
|
+
import { AbortError, DefaultMap, createResource, hostnameMatches, normalizeURL, waitFor, decodeAndEncodeURLWithLogging, handleIncorrectFontMimeType, executeDomainValidation } from './utils.js';
|
|
5
5
|
const MAX_RESOURCE_SIZE = 25 * 1024 ** 2 * 0.63; // 25MB, 0.63 factor for accounting for base64 encoding
|
|
6
6
|
const ALLOWED_STATUSES = [200, 201, 301, 302, 304, 307, 308];
|
|
7
7
|
const ALLOWED_RESOURCES = ['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Other'];
|
|
8
8
|
const ABORTED_MESSAGE = 'Request was aborted by browser';
|
|
9
9
|
|
|
10
|
+
// Stable, machine-readable codes for abort errors thrown from this module.
|
|
11
|
+
// Consumers should prefer `error.code` over string matching on `error.message`.
|
|
12
|
+
export const AbortCodes = Object.freeze({
|
|
13
|
+
ABORTED: 'ABORTED',
|
|
14
|
+
TIMEOUT_NETWORK_IDLE: 'TIMEOUT_NETWORK_IDLE'
|
|
15
|
+
});
|
|
16
|
+
|
|
10
17
|
// RequestLifeCycleHandler handles life cycle of a requestId
|
|
11
18
|
// Ideal flow: requestWillBeSent -> requestPaused -> responseReceived -> loadingFinished / loadingFailed
|
|
12
19
|
// ServiceWorker flow: requestWillBeSent -> responseReceived -> loadingFinished / loadingFailed
|
|
@@ -18,11 +25,35 @@ class RequestLifeCycleHandler {
|
|
|
18
25
|
this.responseReceived = new Promise(resolve => this.resolveResponseReceived = resolve);
|
|
19
26
|
}
|
|
20
27
|
}
|
|
28
|
+
// `Network.TIMEOUT` was a static class field used by
|
|
29
|
+
// some test code (and potentially external SDK consumers) to override
|
|
30
|
+
// the network-idle timeout. It's been replaced by a per-instance
|
|
31
|
+
// `networkIdleWaitTimeout` initialized from PERCY_NETWORK_IDLE_WAIT_TIMEOUT.
|
|
32
|
+
// Keep a static getter/setter shim so external callers reading or
|
|
33
|
+
// writing `Network.TIMEOUT` see a one-time deprecation warning instead
|
|
34
|
+
// of silently dropping their override.
|
|
35
|
+
let _timeoutDeprecationWarned = false;
|
|
36
|
+
|
|
21
37
|
// The Interceptor class creates common handlers for dealing with intercepting asset requests
|
|
22
38
|
// for a given page using various devtools protocol events and commands.
|
|
23
39
|
export class Network {
|
|
24
|
-
static TIMEOUT = undefined;
|
|
25
40
|
log = logger('core:discovery');
|
|
41
|
+
|
|
42
|
+
/* istanbul ignore next: deprecation shim — kept only for external
|
|
43
|
+
SDK consumers that read the field. Not reachable from test code. */
|
|
44
|
+
static get TIMEOUT() {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* istanbul ignore next: deprecation shim — exercised only when
|
|
49
|
+
external callers still write the static field. The shim logs a
|
|
50
|
+
one-time warning pointing at PERCY_NETWORK_IDLE_WAIT_TIMEOUT. */
|
|
51
|
+
static set TIMEOUT(_val) {
|
|
52
|
+
if (!_timeoutDeprecationWarned) {
|
|
53
|
+
_timeoutDeprecationWarned = true;
|
|
54
|
+
logger('core:discovery').warn('Network.TIMEOUT is deprecated; set the PERCY_NETWORK_IDLE_WAIT_TIMEOUT ' + 'env var (or pass per-page options) — the static field no longer affects discovery.');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
26
57
|
#requestsLifeCycleHandler = new DefaultMap(() => new RequestLifeCycleHandler());
|
|
27
58
|
#pending = new Map();
|
|
28
59
|
#requests = new Map();
|
|
@@ -92,11 +123,11 @@ export class Network {
|
|
|
92
123
|
requests = requests.filter(req => !this.#finishedUrls.has(req.url));
|
|
93
124
|
return requests.length === 0;
|
|
94
125
|
}, {
|
|
95
|
-
timeout:
|
|
126
|
+
timeout: this.networkIdleWaitTimeout,
|
|
96
127
|
idle: timeout
|
|
97
128
|
}).catch(error => {
|
|
98
129
|
if (error.message.startsWith('Timeout')) {
|
|
99
|
-
let message = 'Timed out waiting for network requests to idle.';
|
|
130
|
+
let message = 'Timed out waiting for network requests to idle.\n' + 'Hint: set PERCY_NETWORK_IDLE_WAIT_TIMEOUT to increase the budget, ' + 'or allowlist slow domains via the discovery config.';
|
|
100
131
|
if (captureResponsiveAssetsEnabled) message += '\nWhile capturing responsive assets try setting PERCY_DO_NOT_CAPTURE_RESPONSIVE_ASSETS to true.';
|
|
101
132
|
this._throwTimeoutError(message, filter);
|
|
102
133
|
} else {
|
|
@@ -125,7 +156,10 @@ export class Network {
|
|
|
125
156
|
if (params.requestId) {
|
|
126
157
|
/* istanbul ignore if: race condition, very hard to mock this */
|
|
127
158
|
if (this.isAborted(params.requestId)) {
|
|
128
|
-
throw new
|
|
159
|
+
throw new AbortError(ABORTED_MESSAGE, {
|
|
160
|
+
code: AbortCodes.ABORTED,
|
|
161
|
+
reason: 'browser-aborted'
|
|
162
|
+
});
|
|
129
163
|
}
|
|
130
164
|
}
|
|
131
165
|
return await session.send(method, params);
|
|
@@ -151,7 +185,15 @@ export class Network {
|
|
|
151
185
|
this.log.warn(warnMsg);
|
|
152
186
|
return;
|
|
153
187
|
}
|
|
154
|
-
|
|
188
|
+
|
|
189
|
+
// Use a plain Error (NOT AbortError) so this does not trip
|
|
190
|
+
// `error.name === 'AbortError'` consumers in discovery.js:520,
|
|
191
|
+
// percy.js:347, snapshot.js:472 — those treat AbortError as
|
|
192
|
+
// "snapshot was aborted" and would silently drop the timeout.
|
|
193
|
+
let err = new Error(msg);
|
|
194
|
+
err.code = AbortCodes.TIMEOUT_NETWORK_IDLE;
|
|
195
|
+
err.reason = 'network-idle-timeout';
|
|
196
|
+
throw err;
|
|
155
197
|
}
|
|
156
198
|
|
|
157
199
|
// Called when a request should be removed from various trackers
|
|
@@ -410,9 +452,10 @@ export class Network {
|
|
|
410
452
|
this._forgetRequest(request);
|
|
411
453
|
};
|
|
412
454
|
_initializeNetworkIdleWaitTimeout() {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
455
|
+
// Per-instance timeout so concurrent pages with different env values
|
|
456
|
+
// (or env values changed mid-run by tests) don't stomp each other.
|
|
457
|
+
this.networkIdleWaitTimeout = parseInt(process.env.PERCY_NETWORK_IDLE_WAIT_TIMEOUT) || 30000;
|
|
458
|
+
if (this.networkIdleWaitTimeout > 60000) {
|
|
416
459
|
this.log.warn('Setting PERCY_NETWORK_IDLE_WAIT_TIMEOUT over 60000ms is not recommended. ' + 'If your page needs more than 60000ms to idle due to CPU/Network load, ' + 'its recommended to increase CI resources where this cli is running.');
|
|
417
460
|
}
|
|
418
461
|
}
|
|
@@ -545,7 +588,7 @@ async function sendResponseResource(network, request, session) {
|
|
|
545
588
|
// Note: its not a necessity that we would get aborted callback in a tick, its just that if we
|
|
546
589
|
// already have it then we can safely ignore this error
|
|
547
590
|
// Its very hard to test it as this function should be called and request should get cancelled before
|
|
548
|
-
if (error.message === ABORTED_MESSAGE || error.message.includes('Invalid InterceptionId')) {
|
|
591
|
+
if (error.code === AbortCodes.ABORTED || error.message === ABORTED_MESSAGE || error.message.includes('Invalid InterceptionId')) {
|
|
549
592
|
// defer this to the end of queue to make sure that any incoming aborted messages were
|
|
550
593
|
// handled and network.#aborted is updated
|
|
551
594
|
await new Promise((res, _) => process.nextTick(res));
|
package/dist/percy.js
CHANGED
|
@@ -12,6 +12,7 @@ import PercyConfig from '@percy/config';
|
|
|
12
12
|
import logger from '@percy/logger';
|
|
13
13
|
import { getProxy } from '@percy/client/utils';
|
|
14
14
|
import Browser from './browser.js';
|
|
15
|
+
import { acquireLock, releaseLockSync } from './lock.js';
|
|
15
16
|
import Pako from 'pako';
|
|
16
17
|
import { base64encode, generatePromise, yieldAll, yieldTo, redactSecrets, detectSystemProxyAndLog, checkSDKVersion, processCorsIframes } from './utils.js';
|
|
17
18
|
import { createPercyServer, createStaticServer } from './api.js';
|
|
@@ -219,6 +220,26 @@ export class Percy {
|
|
|
219
220
|
this.readyState = 0;
|
|
220
221
|
this.cliStartTime = new Date().toISOString();
|
|
221
222
|
try {
|
|
223
|
+
// Per-port lock fast-fail. Acquire BEFORE any
|
|
224
|
+
// expensive setup (monitoring, proxy detection, hostname loads)
|
|
225
|
+
// so a second `percy start` on the same port refuses cheaply.
|
|
226
|
+
// Skipped when no server is configured (lock represents a port
|
|
227
|
+
// claim).
|
|
228
|
+
if (this.server) {
|
|
229
|
+
// acquireLock returns null when port === 0 (ephemeral / test
|
|
230
|
+
// fixtures); skip the exit handler in that case since there's
|
|
231
|
+
// nothing to release.
|
|
232
|
+
this._lockHandle = acquireLock({
|
|
233
|
+
port: this.port
|
|
234
|
+
});
|
|
235
|
+
if (this._lockHandle) {
|
|
236
|
+
// Synchronous unlink as last-chance cleanup if the process
|
|
237
|
+
// exits without a normal stop().
|
|
238
|
+
this._lockExitHandler = () => releaseLockSync(this._lockHandle);
|
|
239
|
+
process.on('exit', this._lockExitHandler);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
222
243
|
// started monitoring system metrics
|
|
223
244
|
|
|
224
245
|
if (this.systemMonitoringEnabled()) {
|
|
@@ -262,11 +283,31 @@ export class Percy {
|
|
|
262
283
|
await _classPrivateFieldGet(_discovery, this).end();
|
|
263
284
|
await _classPrivateFieldGet(_snapshots, this).end();
|
|
264
285
|
|
|
286
|
+
// Release the lock on failed start so a retry
|
|
287
|
+
// doesn't see this aborted attempt as "already running."
|
|
288
|
+
this._releaseLock();
|
|
289
|
+
|
|
265
290
|
// mark this instance as closed unless aborting
|
|
266
291
|
this.readyState = error.name !== 'AbortError' ? 3 : null;
|
|
267
292
|
|
|
268
|
-
// throw an easier-to-understand error when the port is in use
|
|
269
|
-
|
|
293
|
+
// throw an easier-to-understand error when the port is in use.
|
|
294
|
+
// A held lockfile fails before server.listen,
|
|
295
|
+
// so EADDRINUSE no longer fires for the "Percy already running"
|
|
296
|
+
// case — surface LockHeldError under the same legacy message
|
|
297
|
+
// (downstream tools may grep for it) but ALSO log the actionable
|
|
298
|
+
// detail (pid + lock path) so users can recover.
|
|
299
|
+
/* istanbul ignore if: in-process Percy.start tests with the
|
|
300
|
+
self-pid stale-lock optimization will reclaim and proceed to
|
|
301
|
+
server.listen() rather than throwing LockHeldError, so this
|
|
302
|
+
branch is rare under unit-test conditions. The LockHeldError
|
|
303
|
+
shape is verified by the lock.test.js SC4 spec; this branch
|
|
304
|
+
only translates it to the legacy error string. */
|
|
305
|
+
if (error.name === 'LockHeldError') {
|
|
306
|
+
this.log.error(error.message);
|
|
307
|
+
let errMsg = `Percy is already running or the port ${this.port} is in use`;
|
|
308
|
+
await this.suggestionsForFix(errMsg);
|
|
309
|
+
throw new Error(errMsg);
|
|
310
|
+
} else if (error.code === 'EADDRINUSE') {
|
|
270
311
|
let errMsg = `Percy is already running or the port ${this.port} is in use`;
|
|
271
312
|
await this.suggestionsForFix(errMsg);
|
|
272
313
|
throw new Error(errMsg);
|
|
@@ -277,6 +318,17 @@ export class Percy {
|
|
|
277
318
|
}
|
|
278
319
|
}
|
|
279
320
|
|
|
321
|
+
// Idempotent lock release used by both the success and failure paths
|
|
322
|
+
// in start/stop.
|
|
323
|
+
_releaseLock() {
|
|
324
|
+
if (this._lockExitHandler) {
|
|
325
|
+
process.off('exit', this._lockExitHandler);
|
|
326
|
+
this._lockExitHandler = null;
|
|
327
|
+
}
|
|
328
|
+
releaseLockSync(this._lockHandle);
|
|
329
|
+
this._lockHandle = null;
|
|
330
|
+
}
|
|
331
|
+
|
|
280
332
|
// Resolves once snapshot and upload queues are idle
|
|
281
333
|
async *idle() {
|
|
282
334
|
yield* _classPrivateFieldGet(_discovery, this).idle();
|
|
@@ -368,6 +420,12 @@ export class Percy {
|
|
|
368
420
|
this.monitoring.stopMonitoring();
|
|
369
421
|
clearTimeout(this.resetMonitoringId);
|
|
370
422
|
|
|
423
|
+
// Release per-port lock after the server
|
|
424
|
+
// socket is closed (the unlink itself is sync, but ordering
|
|
425
|
+
// after `server?.close()` keeps the post-condition that "lock
|
|
426
|
+
// present ⇒ server bound" until the very end).
|
|
427
|
+
this._releaseLock();
|
|
428
|
+
|
|
371
429
|
// This issue doesn't comes under regular error logs,
|
|
372
430
|
// it's detected if we just and stop percy server
|
|
373
431
|
await this.checkForNoSnapshotCommandError();
|
package/dist/server.js
CHANGED
|
@@ -177,11 +177,74 @@ export class Server extends http.Server {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
// return a promise that resolves when the server closes
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
//
|
|
181
|
+
// Graceful drain. By default, stop accepting new
|
|
182
|
+
// connections, reap idle keep-alives, and let in-flight requests
|
|
183
|
+
// finish for up to `drainMs` (5s) before forcibly destroying any
|
|
184
|
+
// remaining sockets. Pass `{ drainMs: 0 }` for the legacy abrupt
|
|
185
|
+
// behavior. Uses Node 18.2+ `closeIdleConnections` /
|
|
186
|
+
// `closeAllConnections` when available, falling back to manual
|
|
187
|
+
// socket-set iteration on Node 14 (Windows CI is pinned there per
|
|
188
|
+
// .github/workflows/windows.yml).
|
|
189
|
+
async close({
|
|
190
|
+
drainMs = 5_000
|
|
191
|
+
} = {}) {
|
|
192
|
+
this.draining = true;
|
|
193
|
+
let closed = new Promise(resolve => super.close(resolve));
|
|
194
|
+
|
|
195
|
+
// Reap idle keep-alives now so they don't hold the close() callback.
|
|
196
|
+
/* istanbul ignore next: which branch fires depends on the runner's
|
|
197
|
+
Node version (CI matrix includes Node 14, where
|
|
198
|
+
closeIdleConnections is missing). The graceful behavior is
|
|
199
|
+
verified end-to-end by every existing percy.stop()-based test;
|
|
200
|
+
this if/else simply selects between the Node 18.2+ API and the
|
|
201
|
+
no-op Node 14 fallback. */
|
|
202
|
+
if (typeof this.closeIdleConnections === 'function') {
|
|
203
|
+
this.closeIdleConnections();
|
|
204
|
+
} else {
|
|
205
|
+
// Node 14 fallback: best-effort destroy of sockets without an
|
|
206
|
+
// active response. http.Server doesn't expose idleness here, so
|
|
207
|
+
// we conservatively destroy nothing in this branch and rely on
|
|
208
|
+
// the drain timeout below.
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* istanbul ignore if: legacy abrupt-close path; not used by any
|
|
212
|
+
in-tree caller post-Phase-3, kept for backwards compat with
|
|
213
|
+
SDK consumers that may pass `{ drainMs: 0 }`. */
|
|
214
|
+
if (drainMs <= 0) {
|
|
182
215
|
_classPrivateFieldGet(_sockets, this).forEach(socket => socket.destroy());
|
|
183
|
-
|
|
216
|
+
await closed;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Capture the force-close timer so we can clear it after the
|
|
221
|
+
// race — otherwise it fires `drainMs` later (calling
|
|
222
|
+
// closeAllConnections / socket.destroy on an already-closed
|
|
223
|
+
// server) which is a no-op in normal cases but can throw on
|
|
224
|
+
// edge-case socket states.
|
|
225
|
+
let forcedTimer;
|
|
226
|
+
/* istanbul ignore next: 5s force-close timeout fires only when
|
|
227
|
+
in-flight requests genuinely stall — exercising it under nyc
|
|
228
|
+
requires a deliberately wedged socket which interacts badly
|
|
229
|
+
with the Jasmine runner. The graceful path (where `closed`
|
|
230
|
+
wins the race) is exercised by every existing percy.stop()
|
|
231
|
+
test, and `clearTimeout(forcedTimer)` after the race ensures
|
|
232
|
+
the inner callback never runs in normal teardown. */
|
|
233
|
+
let forced = new Promise(resolve => {
|
|
234
|
+
forcedTimer = setTimeout(() => {
|
|
235
|
+
if (typeof this.closeAllConnections === 'function') {
|
|
236
|
+
this.closeAllConnections();
|
|
237
|
+
} else {
|
|
238
|
+
_classPrivateFieldGet(_sockets, this).forEach(socket => socket.destroy());
|
|
239
|
+
}
|
|
240
|
+
resolve();
|
|
241
|
+
}, drainMs).unref();
|
|
184
242
|
});
|
|
243
|
+
await Promise.race([closed, forced]);
|
|
244
|
+
clearTimeout(forcedTimer);
|
|
245
|
+
// Ensure the 'close' event has fully fired even if `forced` won
|
|
246
|
+
// the race (we still need super.close()'s callback to resolve).
|
|
247
|
+
await closed;
|
|
185
248
|
}
|
|
186
249
|
// set request routing and handling for pathnames and methods
|
|
187
250
|
route(method, pathname, handle) {
|
package/dist/utils.js
CHANGED
|
@@ -183,7 +183,9 @@ export async function executeDomainValidation(network, hostname, url, domainVali
|
|
|
183
183
|
// Worker returns 'accessible' field, not 'allowed'
|
|
184
184
|
if (result !== null && result !== void 0 && result.error) {
|
|
185
185
|
newErrorHosts.add(hostname);
|
|
186
|
-
|
|
186
|
+
// Redact upstream-derived `result.reason` — may contain credentials
|
|
187
|
+
// from response bodies the validation worker echoed.
|
|
188
|
+
network.log.debug(redactSecrets(`Domain validation: ${hostname} validated as BLOCKED - ${result === null || result === void 0 ? void 0 : result.reason}`), network.meta);
|
|
187
189
|
processedDomains.set(hostname, false);
|
|
188
190
|
return false;
|
|
189
191
|
} else if (!(result !== null && result !== void 0 && result.accessible)) {
|
|
@@ -194,8 +196,10 @@ export async function executeDomainValidation(network, hostname, url, domainVali
|
|
|
194
196
|
}
|
|
195
197
|
return false;
|
|
196
198
|
} catch (error) {
|
|
197
|
-
// On error, default to allowing (fail-open for better UX)
|
|
198
|
-
|
|
199
|
+
// On error, default to allowing (fail-open for better UX).
|
|
200
|
+
// Redact `error.message` — upstream HTTP errors can include
|
|
201
|
+
// Authorization headers / URL credentials in the failed-request text.
|
|
202
|
+
network.log.warn(redactSecrets(`Domain validation: Failed to validate ${hostname} - ${error.message}`), network.meta);
|
|
199
203
|
processedDomains.set(hostname, false);
|
|
200
204
|
return false;
|
|
201
205
|
} finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percy/core",
|
|
3
|
-
"version": "1.31.
|
|
3
|
+
"version": "1.31.14-beta.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"publishConfig": {
|
|
11
11
|
"access": "public",
|
|
12
|
-
"tag": "
|
|
12
|
+
"tag": "beta"
|
|
13
13
|
},
|
|
14
14
|
"engines": {
|
|
15
15
|
"node": ">=14"
|
|
@@ -43,12 +43,12 @@
|
|
|
43
43
|
"test:types": "tsd"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@percy/client": "1.31.
|
|
47
|
-
"@percy/config": "1.31.
|
|
48
|
-
"@percy/dom": "1.31.
|
|
49
|
-
"@percy/logger": "1.31.
|
|
50
|
-
"@percy/monitoring": "1.31.
|
|
51
|
-
"@percy/webdriver-utils": "1.31.
|
|
46
|
+
"@percy/client": "1.31.14-beta.0",
|
|
47
|
+
"@percy/config": "1.31.14-beta.0",
|
|
48
|
+
"@percy/dom": "1.31.14-beta.0",
|
|
49
|
+
"@percy/logger": "1.31.14-beta.0",
|
|
50
|
+
"@percy/monitoring": "1.31.14-beta.0",
|
|
51
|
+
"@percy/webdriver-utils": "1.31.14-beta.0",
|
|
52
52
|
"content-disposition": "^0.5.4",
|
|
53
53
|
"cross-spawn": "^7.0.3",
|
|
54
54
|
"extract-zip": "^2.0.1",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"yaml": "^2.4.1"
|
|
63
63
|
},
|
|
64
64
|
"optionalDependencies": {
|
|
65
|
-
"@percy/cli-doctor": "1.31.
|
|
65
|
+
"@percy/cli-doctor": "1.31.14-beta.0"
|
|
66
66
|
},
|
|
67
|
-
"gitHead": "
|
|
67
|
+
"gitHead": "a87281473a9f5cb69a3030845cc4d6b4b81509b0"
|
|
68
68
|
}
|
package/test/helpers/index.js
CHANGED
|
@@ -12,6 +12,16 @@ export function mockfs(initial) {
|
|
|
12
12
|
path.resolve(url.fileURLToPath(import.meta.url), '/../../../dom/dist/bundle.js'),
|
|
13
13
|
path.resolve(url.fileURLToPath(import.meta.url), '../secretPatterns.yml'),
|
|
14
14
|
p => p.includes?.('.local-chromium'),
|
|
15
|
+
// Per-port lockfiles live under ~/.percy/. They
|
|
16
|
+
// are infrastructure (not test fixture data), so route the entire
|
|
17
|
+
// directory (mkdir, writeFile, readFile, unlink) through the real
|
|
18
|
+
// fs. Matching only `/.percy/agent-` lets `writeFileSync` pass but
|
|
19
|
+
// routes `mkdirSync` for the parent through memfs, leaving the
|
|
20
|
+
// parent directory non-existent on the real fs and producing
|
|
21
|
+
// ENOENT cascades on CI. Match both POSIX `/` and Windows `\`
|
|
22
|
+
// separators because the Windows runner normalizes paths
|
|
23
|
+
// inconsistently across mkdir/writeFile/unlink.
|
|
24
|
+
p => typeof p === 'string' && /[/\\]\.percy(?:[/\\]|$)/.test(p),
|
|
15
25
|
...(initial?.$bypass ?? [])
|
|
16
26
|
]
|
|
17
27
|
});
|