@percy/logger 1.31.14-beta.0 → 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/index.js CHANGED
@@ -19,6 +19,15 @@ Object.defineProperties(logger, {
19
19
  query: {
20
20
  value: (...args) => logger.instance.query(...args)
21
21
  },
22
+ snapshotLogs: {
23
+ value: (...args) => logger.instance.snapshotLogs(...args)
24
+ },
25
+ evictSnapshot: {
26
+ value: (...args) => logger.instance.evictSnapshot(...args)
27
+ },
28
+ reset: {
29
+ value: (...args) => logger.instance.reset(...args)
30
+ },
22
31
  format: {
23
32
  value: (...args) => logger.instance.format(...args)
24
33
  },
package/dist/logger.js CHANGED
@@ -1,3 +1,7 @@
1
+ import fs from 'fs';
2
+ import { tmpdir } from 'os';
3
+ import { join, dirname } from 'path';
4
+ import { randomBytes } from 'crypto';
1
5
  import { colors } from './utils.js';
2
6
  const LINE_PAD_REGEXP = /^(\n*)(.*?)(\n*)$/s;
3
7
  const URL_REGEXP = /https?:\/\/[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:;%_+.~#?&//=[\]]*)/i;
@@ -7,30 +11,55 @@ const LOG_LEVELS = {
7
11
  warn: 2,
8
12
  error: 3
9
13
  };
14
+ const FLUSH_AT_ENTRIES = 500;
15
+ const FLUSH_TIMER_MS = 100;
16
+ const READ_CHUNK_BYTES = 64 * 1024;
10
17
 
11
- // A PercyLogger instance retains logs in-memory for quick lookups while also writing log
12
- // messages to stdout and stderr depending on the log level and debug string.
18
+ // Hooks latch + active-instance set kept on the `process` object via Symbol.for
19
+ // so that they are shared across module copies (the ESM loader-mock path used
20
+ // by tests creates fresh module instances; a module-scoped variable would let
21
+ // each one register its own listener and accumulate into MaxListenersWarning).
22
+ // Using a Set rather than a single pointer also handles transitional states
23
+ // where two PercyLogger instances are alive at once (e.g. test setups that
24
+ // don't reset between cases) — both files get cleaned at exit.
25
+ const EXIT_HOOKS_INSTALLED = Symbol.for('@percy/logger.exitHooksInstalled');
26
+ const ACTIVE_INSTANCES = Symbol.for('@percy/logger.activeInstances');
27
+
28
+ // A PercyLogger writes logs to stdout/stderr and persists every entry to a
29
+ // JSONL file under os.tmpdir()/percy-logs/<pid>/, keeping resident memory
30
+ // bounded across long builds. Falls back to an unbounded in-memory Set if
31
+ // disk is unavailable (or if the rollback env var PERCY_LOGS_IN_MEMORY is set).
13
32
  export class PercyLogger {
14
- // default log level
15
33
  level = 'info';
16
-
17
- // namespace regular expressions used to determine which debug logs to write
18
34
  namespaces = {
19
35
  include: [/^.*?$/],
20
36
  exclude: [/^ci$/, /^sdk$/]
21
37
  };
22
-
23
- // in-memory store for logs and meta info
24
- messages = new Set();
25
-
26
- // track deprecations to limit noisy logging
27
38
  deprecations = new Set();
28
39
 
29
- // static vars can be overriden for testing
40
+ // disk-backed store state
41
+ diskMode = 'disk';
42
+ diskPath = null;
43
+ diskSize = 0;
44
+ writeBuffer = [];
45
+ flushTimer = null;
46
+ // snapshotLogs cache: Map<key, entry[]>. Bounded by # of un-evicted snapshot
47
+ // keys at any moment; evictSnapshot() drops them. Entries logged AFTER the
48
+ // eviction repopulate the cache through the next _refreshCache delta. Pre-
49
+ // eviction entries are restored on retry via the pendingFullScan re-scan in
50
+ // snapshotLogs (preserves master's `messages` Set retain-everything semantics
51
+ // for retried snapshots).
52
+ cache = new Map();
53
+ cacheCursor = 0;
54
+ pendingFullScan = new Set();
55
+ fallback = null;
56
+ // Lazy Map<key, entry[]> index over fallback Set. Populated on first
57
+ // snapshotLogs() call and maintained by _record. Avoids O(N²) scans when
58
+ // PERCY_LOGS_IN_MEMORY=1 is the active mode for a long-running build.
59
+ fallbackByKey = null;
60
+ writeFailureWarned = false;
30
61
  static stdout = process.stdout;
31
62
  static stderr = process.stderr;
32
-
33
- // Handles setting env var values and returns a singleton
34
63
  constructor() {
35
64
  let {
36
65
  instance = this
@@ -40,18 +69,27 @@ export class PercyLogger {
40
69
  } else if (process.env.PERCY_LOGLEVEL) {
41
70
  instance.loglevel(process.env.PERCY_LOGLEVEL);
42
71
  }
72
+
73
+ // If the rollback / test env var is set, flip to memory mode immediately so
74
+ // log() never goes through the disk buffer at all. Drain any entries that
75
+ // were already queued in disk mode so they aren't stranded after the flip.
76
+ // Note: PERCY_LOGS_IN_MEMORY is only consulted here at construction time;
77
+ // setting or unsetting it later has no effect because subsequent
78
+ // `new Logger()` returns the cached singleton — tests that need to flip
79
+ // mode mid-process must `delete logger.constructor.instance` first.
80
+ if (process.env.PERCY_LOGS_IN_MEMORY === '1' && instance.diskMode === 'disk' && !instance.diskPath) {
81
+ instance.diskMode = 'memory';
82
+ instance.fallback ?? (instance.fallback = new Set());
83
+ /* istanbul ignore if: only triggered when env=1 is set after logs have already buffered */
84
+ if (instance.writeBuffer.length) instance._drainBufferToMemory();
85
+ }
43
86
  this.constructor.instance = instance;
44
87
  return instance;
45
88
  }
46
-
47
- // Change log level at any time or return the current log level
48
89
  loglevel(level) {
49
90
  if (level) this.level = level;
50
91
  return this.level;
51
92
  }
52
-
53
- // Change namespaces by generating an array of namespace regular expressions from a
54
- // comma separated debug string
55
93
  debug(namespaces) {
56
94
  if (this.namespaces.string === namespaces) return;
57
95
  this.namespaces.string = namespaces;
@@ -72,8 +110,6 @@ export class PercyLogger {
72
110
  exclude: []
73
111
  });
74
112
  }
75
-
76
- // Creates a new log group and returns level specific functions for logging
77
113
  group(name) {
78
114
  return Object.keys(LOG_LEVELS).reduce((group, level) => Object.assign(group, {
79
115
  [level]: this.log.bind(this, name, level)
@@ -88,12 +124,101 @@ export class PercyLogger {
88
124
  });
89
125
  }
90
126
 
91
- // Query for a set of logs by filtering the in-memory store
127
+ // Returns matching entries. The semantics differ by mode:
128
+ // - memory mode: returns the live entry refs from the fallback Set; mutations
129
+ // to entry.message (e.g. redactSecrets in percy.js sendBuildLogs) persist
130
+ // in the Set. This mirrors master's `messages` contract.
131
+ // - disk mode: streams a fresh JSONL pass per call and returns freshly-parsed
132
+ // copies. Mutations are local to the caller and never reach disk —
133
+ // intentional, since disk-backed redaction would require a rewrite.
134
+ // Production consumers (sendBuildLogs) only depend on the array returned by
135
+ // redactSecrets, not the mutation side-effect, so both modes are correct.
92
136
  query(filter) {
93
- return Array.from(this.messages).filter(filter);
137
+ if (this.diskMode === 'memory') {
138
+ return Array.from(this.fallback).filter(filter);
139
+ }
140
+ this._flushSync();
141
+ if (this.diskMode === 'memory') {
142
+ return Array.from(this.fallback).filter(filter);
143
+ }
144
+ return this._scanDisk(filter);
94
145
  }
95
146
 
96
- // Formats messages before they are logged to stdio
147
+ // Returns entries tagged with the given snapshot meta. In disk mode, reads
148
+ // only the disk delta since the last call to amortize the work in defer
149
+ // mode (snapshots accumulate; logs route through the cache lazily). On
150
+ // retry — when evictSnapshot was called and snapshotLogs is invoked again
151
+ // for the same meta — pre-eviction entries are recovered from a full
152
+ // disk scan so the per-snapshot log resource includes both attempts.
153
+ // Returns a shallow copy so callers can mutate without corrupting the cache.
154
+ snapshotLogs(meta) {
155
+ let key = this._snapshotKey({
156
+ snapshot: meta
157
+ });
158
+ if (!key) return [];
159
+ if (this.diskMode === 'memory') {
160
+ return [...this._filterFallback(key)];
161
+ }
162
+ this._flushSync();
163
+ /* istanbul ignore if: defensive — _flushSync only flips mode via _fallbackToMemory, which our snapshotLogs tests don't exercise mid-call */
164
+ if (this.diskMode === 'memory') {
165
+ return [...this._filterFallback(key)];
166
+ }
167
+ this._refreshCache();
168
+
169
+ // Retry path: this key was previously evicted. The incremental cursor has
170
+ // already advanced past its prior entries, so a delta-only refresh would
171
+ // miss them. Re-scan the entire JSONL once for this key to restore parity
172
+ // with master (where `messages = new Set()` retained every entry).
173
+ if (this.pendingFullScan.has(key)) {
174
+ this.pendingFullScan.delete(key);
175
+ let full = this._scanDisk(e => this._snapshotKey(e === null || e === void 0 ? void 0 : e.meta) === key);
176
+ // If full.length is 0 the key already has no cache entry (evictSnapshot
177
+ // deleted it) — leave it absent so cache.get returns undefined below.
178
+ if (full.length) this.cache.set(key, full);
179
+ }
180
+ let cached = this.cache.get(key);
181
+ return cached ? [...cached] : [];
182
+ }
183
+ evictSnapshot(meta) {
184
+ let key = this._snapshotKey({
185
+ snapshot: meta
186
+ });
187
+ if (!key) return;
188
+ this.cache.delete(key);
189
+ // Mark for full-disk rescan on the next snapshotLogs(meta) — needed so a
190
+ // retry/re-discovery flow recovers the pre-eviction entries (master
191
+ // parity). Cleared once consumed to keep the rescan one-shot per evict.
192
+ this.pendingFullScan.add(key);
193
+ }
194
+
195
+ // Resets all logger state. Cleans up the disk file; next log will lazily reinit.
196
+ reset() {
197
+ var _process$ACTIVE_INSTA;
198
+ // Why: discard buffered entries before _cleanup — between tests, the old
199
+ // diskPath may reference a file from a prior mockfs volume that no longer
200
+ // exists in the real fs. Letting _flushSync run would trip ENOENT and
201
+ // emit the disk-fallback warning into the next test's captured stderr.
202
+ this.writeBuffer = [];
203
+ /* istanbul ignore if: defensive — the only code path that schedules a
204
+ timer also drains via query() before reset() in tests */
205
+ if (this.flushTimer) {
206
+ clearTimeout(this.flushTimer);
207
+ this.flushTimer = null;
208
+ }
209
+ this._cleanup();
210
+ (_process$ACTIVE_INSTA = process[ACTIVE_INSTANCES]) === null || _process$ACTIVE_INSTA === void 0 || _process$ACTIVE_INSTA.delete(this);
211
+ this.diskPath = null;
212
+ this.diskSize = 0;
213
+ this.cache.clear();
214
+ this.cacheCursor = 0;
215
+ this.pendingFullScan.clear();
216
+ this.fallback = null;
217
+ this.fallbackByKey = null;
218
+ this.diskMode = 'disk';
219
+ this.writeFailureWarned = false;
220
+ this.deprecations = new Set();
221
+ }
97
222
  format(debug, level, message, elapsed) {
98
223
  let color = (n, m) => this.isTTY ? colors[n](m) : m;
99
224
  let begin,
@@ -101,47 +226,30 @@ export class PercyLogger {
101
226
  suffix = '';
102
227
  let label = 'percy';
103
228
  if (arguments.length === 1) {
104
- // format(message)
105
229
  [debug, message] = [null, debug];
106
230
  } else if (arguments.length === 2) {
107
- // format(debug, message)
108
231
  [level, message] = [null, level];
109
232
  }
110
-
111
- // do not format leading or trailing newlines
112
233
  [, begin, message, end] = message.match(LINE_PAD_REGEXP);
113
-
114
- // include debug information
115
234
  if (this.level === 'debug') {
116
235
  if (debug) label += `:${debug}`;
117
-
118
- // include elapsed time since last log
119
236
  if (elapsed != null) {
120
237
  suffix = ' ' + color('grey', `(${elapsed}ms)`);
121
238
  }
122
239
  }
123
-
124
- // add colors
125
240
  label = color('magenta', label);
126
241
  if (level === 'error') {
127
- // red errors
128
242
  message = color('red', message);
129
243
  } else if (level === 'warn') {
130
- // yellow warnings
131
244
  message = color('yellow', message);
132
245
  } else if (level === 'info' || level === 'debug') {
133
- // blue info and debug URLs
134
246
  message = message.replace(URL_REGEXP, color('blue', '$&'));
135
247
  }
136
248
  return `${begin}[${label}] ${message}${suffix}${end}`;
137
249
  }
138
-
139
- // True if stdout is a TTY interface
140
250
  get isTTY() {
141
251
  return !!this.constructor.stdout.isTTY;
142
252
  }
143
-
144
- // Replaces the current line with a log message
145
253
  progress(debug, message, persist) {
146
254
  if (!this.shouldLog(debug, 'info')) return;
147
255
  let {
@@ -158,27 +266,17 @@ export class PercyLogger {
158
266
  persist
159
267
  };
160
268
  }
161
-
162
- // Returns true or false if the level and debug group can write messages to stdio
163
269
  shouldLog(debug, level) {
164
270
  return LOG_LEVELS[level] != null && LOG_LEVELS[level] >= LOG_LEVELS[this.level] && !this.namespaces.exclude.some(ns => ns.test(debug)) && this.namespaces.include.some(ns => ns.test(debug));
165
271
  }
166
-
167
- // Ensures that deprecation messages are not logged more than once
168
272
  deprecated(debug, message, meta) {
169
273
  if (this.deprecations.has(message)) return;
170
274
  this.deprecations.add(message);
171
275
  this.log(debug, 'warn', `Warning: ${message}`, meta);
172
276
  }
173
-
174
- // Generic log method accepts a debug group, log level, log message, and optional meta
175
- // information to store with the message and other info
176
277
  log(debug, level, message, meta = {}) {
177
- // message might be an error-like object
178
278
  let err = typeof message !== 'string' && (level === 'debug' || level === 'error');
179
279
  err && (err = message.message ? Error.prototype.toString.call(message) : message.toString());
180
-
181
- // save log entries
182
280
  let timestamp = Date.now();
183
281
  message = err ? message.stack || err : message.toString();
184
282
  let entry = {
@@ -189,11 +287,8 @@ export class PercyLogger {
189
287
  timestamp,
190
288
  error: !!err
191
289
  };
192
- this.messages.add(entry);
193
-
194
- // maybe write the message to stdio
290
+ this._record(entry);
195
291
  if (this.shouldLog(debug, level)) {
196
- // unless the loglevel is debug, write shorter error messages
197
292
  if (err && this.level !== 'debug') message = err;
198
293
  this.write({
199
294
  ...entry,
@@ -202,8 +297,6 @@ export class PercyLogger {
202
297
  this.lastlog = timestamp;
203
298
  }
204
299
  }
205
-
206
- // Writes a log entry to stdio based on the loglevel
207
300
  write({
208
301
  debug,
209
302
  level,
@@ -219,8 +312,6 @@ export class PercyLogger {
219
312
  stdout,
220
313
  stderr
221
314
  } = this.constructor;
222
-
223
- // clear any logged progress
224
315
  if (progress) {
225
316
  stdout.cursorTo(0);
226
317
  stdout.clearLine(0);
@@ -228,5 +319,283 @@ export class PercyLogger {
228
319
  (level === 'info' ? stdout : stderr).write(msg + '\n');
229
320
  if (!((_this$_progress = this._progress) !== null && _this$_progress !== void 0 && _this$_progress.persist)) delete this._progress;else if (progress) stdout.write(progress.message);
230
321
  }
322
+
323
+ // ── internals ───────────────────────────────────────────────────────────────
324
+
325
+ _filterFallback(key) {
326
+ if (!this.fallbackByKey) {
327
+ // Lazy build: O(N) once, then O(1) per call. Keeps PERCY_LOGS_IN_MEMORY=1
328
+ // mode usable for 10k-snapshot builds where snapshotLogs is called per
329
+ // snapshot.
330
+ this.fallbackByKey = new Map();
331
+ for (let entry of this.fallback) {
332
+ let k = this._snapshotKey(entry === null || entry === void 0 ? void 0 : entry.meta);
333
+ if (!k) continue;
334
+ let arr = this.fallbackByKey.get(k);
335
+ if (!arr) this.fallbackByKey.set(k, arr = []);
336
+ arr.push(entry);
337
+ }
338
+ }
339
+ return this.fallbackByKey.get(key) || [];
340
+ }
341
+ _record(entry) {
342
+ if (this.diskMode === 'memory') {
343
+ this.fallback.add(entry);
344
+ // Maintain the lazy index incrementally if it exists. If it hasn't been
345
+ // built yet, _filterFallback will scan fallback once on next call.
346
+ if (this.fallbackByKey) {
347
+ var _entry;
348
+ let k = this._snapshotKey((_entry = entry) === null || _entry === void 0 ? void 0 : _entry.meta);
349
+ if (k) {
350
+ let arr = this.fallbackByKey.get(k);
351
+ if (!arr) this.fallbackByKey.set(k, arr = []);
352
+ arr.push(entry);
353
+ }
354
+ }
355
+ return;
356
+ }
357
+ let line;
358
+ try {
359
+ line = JSON.stringify(entry) + '\n';
360
+ } catch {
361
+ var _entry$meta;
362
+ // Why: circular references in meta would otherwise kill this log call.
363
+ // Preserve meta.snapshot so the entry still routes via snapshotLogs.
364
+ let safeMeta = {
365
+ unserializable: true
366
+ };
367
+ if ((_entry$meta = entry.meta) !== null && _entry$meta !== void 0 && _entry$meta.snapshot) safeMeta.snapshot = entry.meta.snapshot;
368
+ entry = {
369
+ ...entry,
370
+ meta: safeMeta
371
+ };
372
+ line = JSON.stringify(entry) + '\n';
373
+ }
374
+ let length = Buffer.byteLength(line, 'utf8');
375
+ this.writeBuffer.push({
376
+ line,
377
+ length
378
+ });
379
+ this._scheduleFlush();
380
+ if (this.writeBuffer.length >= FLUSH_AT_ENTRIES) this._flushSync();
381
+ }
382
+ _snapshotKey(meta) {
383
+ let s = meta === null || meta === void 0 ? void 0 : meta.snapshot;
384
+ if (!s || !s.testCase && !s.name) return null;
385
+ // NUL byte separator — `|` collides on legitimate names like
386
+ // ('a|b','c') vs ('a','b|c'); NUL is forbidden in test/snapshot names.
387
+ return `${s.testCase || ''}\x00${s.name || ''}`;
388
+ }
389
+ _scheduleFlush() {
390
+ var _this$flushTimer$unre, _this$flushTimer;
391
+ if (this.flushTimer) return;
392
+ this.flushTimer = setTimeout(() => {
393
+ this.flushTimer = null;
394
+ this._flushSync();
395
+ }, FLUSH_TIMER_MS);
396
+ (_this$flushTimer$unre = (_this$flushTimer = this.flushTimer).unref) === null || _this$flushTimer$unre === void 0 || _this$flushTimer$unre.call(_this$flushTimer);
397
+ }
398
+ _flushSync() {
399
+ if (this.flushTimer) {
400
+ clearTimeout(this.flushTimer);
401
+ this.flushTimer = null;
402
+ }
403
+ if (!this.writeBuffer.length) return;
404
+ this._ensureDiskInit();
405
+ if (this.diskMode !== 'disk') {
406
+ this._drainBufferToMemory();
407
+ return;
408
+ }
409
+ let chunk = '';
410
+ let written = 0;
411
+ for (let item of this.writeBuffer) {
412
+ chunk += item.line;
413
+ written += item.length;
414
+ }
415
+ try {
416
+ fs.appendFileSync(this.diskPath, chunk);
417
+ } catch (err) {
418
+ this._fallbackToMemory(err);
419
+ return;
420
+ }
421
+ this.diskSize += written;
422
+ this.writeBuffer = [];
423
+ }
424
+ _drainBufferToMemory() {
425
+ /* istanbul ignore next: defensive — fallback is always set before drain runs */
426
+ if (!this.fallback) this.fallback = new Set();
427
+ for (let {
428
+ line
429
+ } of this.writeBuffer) {
430
+ /* istanbul ignore next: defensive — entries are JSON.stringify'd by us */
431
+ try {
432
+ this.fallback.add(JSON.parse(line.replace(/\n$/, '')));
433
+ } catch {/* skip */}
434
+ }
435
+ this.writeBuffer = [];
436
+ }
437
+ _ensureDiskInit() {
438
+ if (this.diskPath || this.diskMode !== 'disk') return;
439
+ try {
440
+ // Per-pid subdir keeps concurrent percy processes (CI matrix, parallel
441
+ // test workers, npx invocations) from clobbering each other's files.
442
+ let dir = join(tmpdir(), 'percy-logs', String(process.pid));
443
+ fs.mkdirSync(dir, {
444
+ recursive: true
445
+ });
446
+ this.diskPath = join(dir, `${Date.now()}-${randomBytes(8).toString('hex')}.jsonl`);
447
+ fs.writeFileSync(this.diskPath, '');
448
+ this._installExitHooks();
449
+ } catch {
450
+ this.diskMode = 'memory';
451
+ /* istanbul ignore next: defensive — fallback may already be set */
452
+ this.fallback ?? (this.fallback = new Set());
453
+ this.diskPath = null;
454
+ }
455
+ }
456
+
457
+ // Reads the disk delta into the snapshotLogs cache, grouped by snapshotKey.
458
+ // Streams in 64KB chunks so a long defer-mode build draining hundreds of MB
459
+ // at end-of-build doesn't allocate a single huge buffer.
460
+ _refreshCache() {
461
+ if (this.cacheCursor >= this.diskSize) return;
462
+ let fd = fs.openSync(this.diskPath, 'r');
463
+ try {
464
+ let buf = Buffer.alloc(READ_CHUNK_BYTES);
465
+ let offset = this.cacheCursor;
466
+ let partial = '';
467
+ while (offset < this.diskSize) {
468
+ let toRead = Math.min(READ_CHUNK_BYTES, this.diskSize - offset);
469
+ let n = fs.readSync(fd, buf, 0, toRead, offset);
470
+ offset += n;
471
+ let lines = (partial + buf.slice(0, n).toString('utf8')).split('\n');
472
+ partial = lines.pop();
473
+ for (let line of lines) {
474
+ var _entry2;
475
+ let entry;
476
+ /* istanbul ignore next: defensive — entries are JSON.stringify'd by us */
477
+ try {
478
+ entry = JSON.parse(line);
479
+ } catch {
480
+ continue;
481
+ }
482
+ let key = this._snapshotKey((_entry2 = entry) === null || _entry2 === void 0 ? void 0 : _entry2.meta);
483
+ if (!key) continue;
484
+ let arr = this.cache.get(key);
485
+ if (!arr) this.cache.set(key, arr = []);
486
+ arr.push(entry);
487
+ }
488
+ }
489
+ this.cacheCursor = this.diskSize;
490
+ } finally {
491
+ fs.closeSync(fd);
492
+ }
493
+ }
494
+
495
+ // Streams the JSONL once and returns matching entries. Each call parses
496
+ // afresh — no parsed-entry cache, so RSS at upload time stays bounded by
497
+ // the size of the filtered result rather than the total log volume.
498
+ _scanDisk(filter) {
499
+ if (!this.diskPath || this.diskSize === 0) return [];
500
+ let result = [];
501
+ let fd = fs.openSync(this.diskPath, 'r');
502
+ try {
503
+ let buf = Buffer.alloc(READ_CHUNK_BYTES);
504
+ let offset = 0;
505
+ let partial = '';
506
+ while (offset < this.diskSize) {
507
+ let toRead = Math.min(READ_CHUNK_BYTES, this.diskSize - offset);
508
+ let n = fs.readSync(fd, buf, 0, toRead, offset);
509
+ offset += n;
510
+ let lines = (partial + buf.slice(0, n).toString('utf8')).split('\n');
511
+ partial = lines.pop();
512
+ for (let line of lines) {
513
+ /* istanbul ignore next: defensive — entries are JSON.stringify'd by us */
514
+ try {
515
+ let entry = JSON.parse(line);
516
+ if (filter(entry)) result.push(entry);
517
+ } catch {/* skip */}
518
+ }
519
+ }
520
+ } finally {
521
+ fs.closeSync(fd);
522
+ }
523
+ return result;
524
+ }
525
+ _fallbackToMemory(err) {
526
+ /* istanbul ignore else: latch — only fires once per build */
527
+ if (!this.writeFailureWarned) {
528
+ this.writeFailureWarned = true;
529
+ PercyLogger.stderr.write(`[percy] logger: disk write failed (${(err === null || err === void 0 ? void 0 : err.code) || (err === null || err === void 0 ? void 0 : err.message) || 'unknown'}), falling back to in-memory\n`);
530
+ }
531
+
532
+ // Read whatever we already wrote to disk into the fallback Set so /logs
533
+ // upload still includes everything from before the failure.
534
+ let existing = [];
535
+ if (this.diskPath && this.diskSize > 0) {
536
+ /* istanbul ignore next: defensive — _scanDisk handles its own errors */
537
+ try {
538
+ existing = this._scanDisk(() => true);
539
+ } catch {/* tolerate */}
540
+ }
541
+ this.diskMode = 'memory';
542
+ /* istanbul ignore next: defensive — fallback may already be set */
543
+ this.fallback ?? (this.fallback = new Set());
544
+ for (let entry of existing) this.fallback.add(entry);
545
+ this._drainBufferToMemory();
546
+
547
+ /* istanbul ignore else: latch — diskPath always set on first fallback */
548
+ if (this.diskPath) {
549
+ /* istanbul ignore next: defensive — best-effort cleanup */
550
+ try {
551
+ fs.unlinkSync(this.diskPath);
552
+ } catch {/* tolerate */}
553
+ this.diskPath = null;
554
+ }
555
+ this.diskSize = 0;
556
+ this.cache.clear();
557
+ this.cacheCursor = 0;
558
+ this.pendingFullScan.clear();
559
+ this.fallbackByKey = null;
560
+ }
561
+ _installExitHooks() {
562
+ var _process;
563
+ let active = (_process = process)[ACTIVE_INSTANCES] ?? (_process[ACTIVE_INSTANCES] = new Set());
564
+ active.add(this);
565
+ /* istanbul ignore if: latch — only the first install per process */
566
+ if (process[EXIT_HOOKS_INSTALLED]) return;
567
+ process[EXIT_HOOKS_INSTALLED] = true;
568
+ let cleanup = () => {
569
+ for (let logger of process[ACTIVE_INSTANCES]) logger._cleanup();
570
+ };
571
+ process.once('exit', cleanup);
572
+ process.once('beforeExit', cleanup);
573
+ // Why: SIGINT/SIGTERM are intentionally not handled. The CLI runtime
574
+ // already installs its own signal listeners; adding ours pushes past
575
+ // the default 10-listener limit and trips MaxListenersExceededWarning
576
+ // in downstream test suites. On Ctrl-C / runner kill our JSONL is left
577
+ // in os.tmpdir()/percy-logs/<pid>/ which the OS cleans, and the
578
+ // pid-scoped subdir prevents concurrent runs from colliding.
579
+ }
580
+ _cleanup() {
581
+ /* istanbul ignore next: defensive — flush should not throw */
582
+ try {
583
+ this._flushSync();
584
+ } catch {/* tolerate */}
585
+ if (this.diskPath) {
586
+ let dir = dirname(this.diskPath);
587
+ /* istanbul ignore next: defensive — best-effort */
588
+ try {
589
+ fs.unlinkSync(this.diskPath);
590
+ } catch {/* tolerate */}
591
+ // Best-effort rmdir of the per-pid subdir so long-lived runners don't
592
+ // accumulate empty directories. Fails harmlessly if peer instances of
593
+ // the same pid still hold files there.
594
+ /* istanbul ignore next: defensive — best-effort */
595
+ try {
596
+ fs.rmdirSync(dir);
597
+ } catch {/* tolerate */}
598
+ }
599
+ }
231
600
  }
232
601
  export default PercyLogger;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/logger",
3
- "version": "1.31.14-beta.0",
3
+ "version": "1.31.14-beta.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,5 +32,5 @@
32
32
  "test": "node ../../scripts/test",
33
33
  "test:coverage": "yarn test --coverage"
34
34
  },
35
- "gitHead": "a87281473a9f5cb69a3030845cc4d6b4b81509b0"
35
+ "gitHead": "dd6957822cc94d460fd9c043a44cc4c9c5fcba23"
36
36
  }
package/test/helpers.js CHANGED
@@ -43,6 +43,14 @@ const helpers = {
43
43
  },
44
44
 
45
45
  async mock(options = {}) {
46
+ // Default to memory mode for downstream packages whose test setups don't
47
+ // explicitly select a mode. Logger tests that want disk mode set
48
+ // PERCY_LOGS_IN_MEMORY explicitly before calling mock(); the value here
49
+ // is only applied when the env hasn't already been pinned.
50
+ if (!('PERCY_LOGS_IN_MEMORY' in process.env)) {
51
+ process.env.PERCY_LOGS_IN_MEMORY = '1';
52
+ }
53
+
46
54
  helpers.reset();
47
55
 
48
56
  if (options.level) {
@@ -78,8 +86,13 @@ const helpers = {
78
86
  },
79
87
 
80
88
  reset(soft) {
81
- if (soft) logger.loglevel('info');
82
- else delete logger.constructor.instance;
89
+ if (soft) {
90
+ logger.loglevel('info');
91
+ } else {
92
+ // tear down the prior instance's disk artifacts before swapping it out
93
+ try { logger.constructor.instance?.reset(); } catch { /* tolerate */ }
94
+ delete logger.constructor.instance;
95
+ }
83
96
 
84
97
  helpers.stdout.length = 0;
85
98
  helpers.stderr.length = 0;
@@ -92,7 +105,7 @@ const helpers = {
92
105
  },
93
106
 
94
107
  dump() {
95
- let msgs = Array.from(logger.instance.messages);
108
+ let msgs = logger.instance.query(() => true);
96
109
  if (!msgs.length) return;
97
110
 
98
111
  let log = m => process.env.__PERCY_BROWSERIFIED__ ? (