@percy/core 1.31.13 → 1.31.14-beta.1

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/percy.js CHANGED
@@ -12,11 +12,12 @@ 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';
18
19
  import { gatherSnapshots, createSnapshotsQueue, validateSnapshotOptions } from './snapshot.js';
19
- import { discoverSnapshotResources, createDiscoveryQueue } from './discovery.js';
20
+ import { discoverSnapshotResources, createDiscoveryQueue, RESOURCE_CACHE_KEY, CACHE_STATS_KEY, DISK_SPILL_KEY } from './discovery.js';
20
21
  import Monitoring from '@percy/monitoring';
21
22
  import { WaitForJob } from './wait-for-job.js';
22
23
  const MAX_SUGGESTION_CALLS = 10;
@@ -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
- if (error.code === 'EADDRINUSE') {
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,10 +420,76 @@ 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();
432
+ // sendBuildLogs goes first — it's the primary egress. cache_summary is
433
+ // analytics, ordered after so a slow pager hop cannot delay the logs.
374
434
  await this.sendBuildLogs();
435
+ await this.sendCacheSummary();
436
+ }
437
+ }
438
+
439
+ // Single egress point for cache-tier telemetry. Used by sendCacheSummary
440
+ // (awaited at stop) and discovery's fire-and-forget eviction event. Returns
441
+ // early if no build is associated, swallows pager rejections — telemetry
442
+ // loss must never fail a build.
443
+ async sendCacheTelemetry(message, extra) {
444
+ var _this$build2;
445
+ if (!((_this$build2 = this.build) !== null && _this$build2 !== void 0 && _this$build2.id)) return;
446
+ try {
447
+ await this.client.sendBuildEvents(this.build.id, {
448
+ message,
449
+ cliVersion: this.client.cliVersion,
450
+ clientInfo: this.clientInfo,
451
+ extra
452
+ });
453
+ } catch (err) {
454
+ this.log.debug(`${message} telemetry failed`, err);
455
+ }
456
+ }
457
+
458
+ // Cache-usage summary fired at stop. The whole method is wrapped — the
459
+ // contract is "telemetry must never fail percy.stop()", which covers the
460
+ // payload-construction block as well as the egress.
461
+ async sendCacheSummary() {
462
+ try {
463
+ var _stats$finalDiskStats;
464
+ const cache = this[RESOURCE_CACHE_KEY];
465
+ const stats = this[CACHE_STATS_KEY];
466
+ if (!cache || !stats) return;
467
+ const cacheStats = typeof cache.stats === 'object' ? cache.stats : null;
468
+ // diskStore is destroyed by discovery 'end' before this runs, so fall
469
+ // back to the snapshot captured in stats.finalDiskStats.
470
+ const diskStore = this[DISK_SPILL_KEY];
471
+ const diskSnap = (diskStore === null || diskStore === void 0 ? void 0 : diskStore.stats) ?? stats.finalDiskStats;
472
+ const diskReady = diskStore ? diskStore.ready : !!((_stats$finalDiskStats = stats.finalDiskStats) !== null && _stats$finalDiskStats !== void 0 && _stats$finalDiskStats.ready);
473
+ await this.sendCacheTelemetry('cache_summary', {
474
+ cache_budget_ram_mb: stats.effectiveMaxCacheRamMB,
475
+ hits: (cacheStats === null || cacheStats === void 0 ? void 0 : cacheStats.hits) ?? 0,
476
+ misses: (cacheStats === null || cacheStats === void 0 ? void 0 : cacheStats.misses) ?? 0,
477
+ evictions: (cacheStats === null || cacheStats === void 0 ? void 0 : cacheStats.evictions) ?? 0,
478
+ peak_bytes: (cacheStats === null || cacheStats === void 0 ? void 0 : cacheStats.peakBytes) ?? stats.unsetModeBytes,
479
+ final_bytes: cache.calculatedSize ?? stats.unsetModeBytes,
480
+ entry_count: cache.size ?? 0,
481
+ oversize_skipped: stats.oversizeSkipped,
482
+ disk_spill_enabled: diskReady,
483
+ disk_spilled_count: (diskSnap === null || diskSnap === void 0 ? void 0 : diskSnap.spilled) ?? 0,
484
+ disk_restored_count: (diskSnap === null || diskSnap === void 0 ? void 0 : diskSnap.restored) ?? 0,
485
+ disk_spill_failures: (diskSnap === null || diskSnap === void 0 ? void 0 : diskSnap.spillFailures) ?? 0,
486
+ disk_read_failures: (diskSnap === null || diskSnap === void 0 ? void 0 : diskSnap.readFailures) ?? 0,
487
+ disk_peak_bytes: (diskSnap === null || diskSnap === void 0 ? void 0 : diskSnap.peakBytes) ?? 0,
488
+ disk_final_bytes: (diskSnap === null || diskSnap === void 0 ? void 0 : diskSnap.currentBytes) ?? 0,
489
+ disk_final_entries: (diskSnap === null || diskSnap === void 0 ? void 0 : diskSnap.entries) ?? 0
490
+ });
491
+ } catch (err) {
492
+ this.log.debug('cache_summary build failed', err);
375
493
  }
376
494
  }
377
495
  checkAndUpdateConcurrency() {
@@ -429,10 +547,10 @@ export class Percy {
429
547
  // snapshots. Once asset discovery has completed for the provided snapshots, the queued task will
430
548
  // resolve and an upload task will be queued separately.
431
549
  snapshot(options, snapshotPromise = {}) {
432
- var _this$build2;
550
+ var _this$build3;
433
551
  if (this.readyState !== 1) {
434
552
  throw new Error('Not running');
435
- } else if ((_this$build2 = this.build) !== null && _this$build2 !== void 0 && _this$build2.error) {
553
+ } else if ((_this$build3 = this.build) !== null && _this$build3 !== void 0 && _this$build3.error) {
436
554
  throw new Error(this.build.error);
437
555
  } else if (Array.isArray(options)) {
438
556
  return yieldAll(options.map(o => this.yield.snapshot(o, snapshotPromise)));
@@ -662,7 +780,7 @@ export class Percy {
662
780
  async sendBuildLogs() {
663
781
  if (!process.env.PERCY_TOKEN) return;
664
782
  try {
665
- var _this$build3, _this$build4, _this$build5, _this$build6;
783
+ var _this$build4, _this$build5, _this$build6, _this$build7;
666
784
  const logsObject = {
667
785
  clilogs: logger.query(log => !['ci'].includes(log.debug))
668
786
  };
@@ -674,10 +792,10 @@ export class Percy {
674
792
  logsObject.cilogs = redactedContent;
675
793
  }
676
794
  const content = base64encode(Pako.gzip(JSON.stringify(logsObject)));
677
- const referenceId = (_this$build3 = this.build) !== null && _this$build3 !== void 0 && _this$build3.id ? `build_${(_this$build4 = this.build) === null || _this$build4 === void 0 ? void 0 : _this$build4.id}` : (_this$build5 = this.build) === null || _this$build5 === void 0 ? void 0 : _this$build5.id;
795
+ const referenceId = (_this$build4 = this.build) !== null && _this$build4 !== void 0 && _this$build4.id ? `build_${(_this$build5 = this.build) === null || _this$build5 === void 0 ? void 0 : _this$build5.id}` : (_this$build6 = this.build) === null || _this$build6 === void 0 ? void 0 : _this$build6.id;
678
796
  const eventObject = {
679
797
  content: content,
680
- build_id: (_this$build6 = this.build) === null || _this$build6 === void 0 ? void 0 : _this$build6.id,
798
+ build_id: (_this$build7 = this.build) === null || _this$build7 === void 0 ? void 0 : _this$build7.id,
681
799
  reference_id: referenceId,
682
800
  service_name: 'cli',
683
801
  base64encoded: true
@@ -750,9 +868,9 @@ export class Percy {
750
868
  const newAllowedDomains = Array.from(processedHosts).filter(domain => !autoConfiguredHosts.has(domain));
751
869
  const hasNewDomains = newAllowedDomains.length > 0 || newErrorHosts.size > 0;
752
870
  try {
753
- var _this$build7;
871
+ var _this$build8;
754
872
  await this.client.updateProjectDomainConfig({
755
- buildId: (_this$build7 = this.build) === null || _this$build7 === void 0 ? void 0 : _this$build7.id,
873
+ buildId: (_this$build8 = this.build) === null || _this$build8 === void 0 ? void 0 : _this$build8.id,
756
874
  allowedDomains: Array.from(processedHosts),
757
875
  errorDomains: Array.from(newErrorHosts)
758
876
  });
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
- close() {
181
- return new Promise(resolve => {
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
- super.close(resolve);
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
- network.log.debug(`Domain validation: ${hostname} validated as BLOCKED - ${result === null || result === void 0 ? void 0 : result.reason}`, network.meta);
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
- network.log.warn(`Domain validation: Failed to validate ${hostname} - ${error.message}`, network.meta);
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.13",
3
+ "version": "1.31.14-beta.1",
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": "latest"
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.13",
47
- "@percy/config": "1.31.13",
48
- "@percy/dom": "1.31.13",
49
- "@percy/logger": "1.31.13",
50
- "@percy/monitoring": "1.31.13",
51
- "@percy/webdriver-utils": "1.31.13",
46
+ "@percy/client": "1.31.14-beta.1",
47
+ "@percy/config": "1.31.14-beta.1",
48
+ "@percy/dom": "1.31.14-beta.1",
49
+ "@percy/logger": "1.31.14-beta.1",
50
+ "@percy/monitoring": "1.31.14-beta.1",
51
+ "@percy/webdriver-utils": "1.31.14-beta.1",
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.13"
65
+ "@percy/cli-doctor": "1.31.14-beta.1"
66
66
  },
67
- "gitHead": "7d28705b323836680b22f96e961ed5f39f09a56b"
67
+ "gitHead": "dd6957822cc94d460fd9c043a44cc4c9c5fcba23"
68
68
  }
@@ -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
  });