browser-commander 0.27.0 → 0.29.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.
Files changed (93) hide show
  1. package/.jscpd-baseline.json +5 -19
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +23 -9
  4. package/eslint.config.js +11 -0
  5. package/package.json +5 -2
  6. package/src/browser/browser-profile-files.js +5 -2
  7. package/src/browser/connector.js +215 -36
  8. package/src/browser/launch-options.js +2 -1
  9. package/src/browser/launch-restrictions.json +33 -2
  10. package/src/browser/launcher.js +26 -1
  11. package/src/browser/persistent-session.js +432 -0
  12. package/src/browser/profile-directory.js +20 -0
  13. package/src/browser/real-browser.js +7 -2
  14. package/src/browser/restrictions.js +13 -4
  15. package/src/browser/session-adoption.js +96 -0
  16. package/src/browser/session-watchdog.js +71 -0
  17. package/src/browser/session-worker.js +82 -0
  18. package/src/browser/webdriver.js +4 -0
  19. package/src/capture/encoding.js +327 -0
  20. package/src/capture/errors.js +12 -0
  21. package/src/capture/files.js +9 -0
  22. package/src/capture/index.js +5 -0
  23. package/src/capture/recording.js +136 -0
  24. package/src/capture/screenshot.js +222 -0
  25. package/src/capture/video.js +186 -0
  26. package/src/capture/viewport.js +68 -0
  27. package/src/capture/worker.js +22 -0
  28. package/src/cli/args.js +69 -1
  29. package/src/cli/commands.js +116 -2
  30. package/src/cli/high-level-methods.js +47 -3
  31. package/src/cli/sessions.js +14 -3
  32. package/src/core/commander-features.js +110 -0
  33. package/src/core/constants.js +1 -0
  34. package/src/core/navigation-manager.js +4 -2
  35. package/src/core/navigation-readiness.js +30 -2
  36. package/src/core/navigation-safety.js +52 -0
  37. package/src/core/page-trigger-manager.js +280 -156
  38. package/src/core/readiness.js +10 -2
  39. package/src/elements/locators.js +26 -32
  40. package/src/elements/selectors.js +37 -81
  41. package/src/elements/visibility.js +32 -10
  42. package/src/factory.js +13 -12
  43. package/src/index.js +4 -0
  44. package/src/interactions/click.js +44 -0
  45. package/src/interactions/overlays.js +73 -0
  46. package/src/traces/bundle.js +55 -8
  47. package/src/traces/checkpoints.js +103 -0
  48. package/src/traces/links.js +157 -2
  49. package/src/traces/mutation-stream.js +9 -2
  50. package/src/traces/network.js +309 -0
  51. package/src/traces/page-capture.js +203 -68
  52. package/src/traces/reader.js +8 -10
  53. package/src/traces/recorder.js +253 -247
  54. package/src/traces/redaction.js +89 -6
  55. package/src/traces/render.js +168 -0
  56. package/src/traces/rolling-bundle.js +240 -0
  57. package/src/traces/rolling.js +77 -0
  58. package/src/traces/start-cleanup.js +15 -0
  59. package/src/traces/stop.js +160 -0
  60. package/src/traces/storage.js +43 -0
  61. package/src/utilities/subprocess.js +32 -0
  62. package/tests/e2e/cli.e2e.test.js +20 -1
  63. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  64. package/tests/e2e/issue-160.e2e.test.js +291 -0
  65. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
  66. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
  67. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
  68. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
  69. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
  70. package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
  71. package/tests/unit/browser/issue-160-connector.test.js +157 -0
  72. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  73. package/tests/unit/browser/launcher.test.js +5 -1
  74. package/tests/unit/browser/persistent-session.test.js +59 -0
  75. package/tests/unit/browser/quiet-ui.test.js +16 -0
  76. package/tests/unit/browser/tab-selection.test.js +38 -0
  77. package/tests/unit/capture/capture.test.js +52 -0
  78. package/tests/unit/capture/cleanup.test.js +24 -0
  79. package/tests/unit/capture/viewport.test.js +168 -0
  80. package/tests/unit/cli/dispatcher.test.js +25 -0
  81. package/tests/unit/core/interrupted-navigation.test.js +32 -0
  82. package/tests/unit/core/readiness.test.js +21 -0
  83. package/tests/unit/core/trigger-readiness.test.js +39 -0
  84. package/tests/unit/core/trigger-stop-race.test.js +80 -0
  85. package/tests/unit/elements/visible-count.test.js +24 -0
  86. package/tests/unit/elements/wait-navigation.test.js +26 -0
  87. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  88. package/tests/unit/interactions/overlays.test.js +58 -0
  89. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  90. package/tests/unit/traces/bundle.test.js +16 -0
  91. package/tests/unit/traces/issue-160-privacy.test.js +110 -0
  92. package/tests/unit/traces/issue-160-rotation.test.js +140 -0
  93. package/tests/unit/traces/network.test.js +149 -0
@@ -28,10 +28,12 @@ export function captureSnapshotInPage(options) {
28
28
  const {
29
29
  redactSelectors = [],
30
30
  redactAttributes = [],
31
+ useDefaults = true,
31
32
  redacted = '[redacted]',
32
33
  html: wantHtml = true,
33
34
  liveControlState = true,
34
35
  openShadowRoots = true,
36
+ ignoreSelectors = [],
35
37
  maxHtmlBytes = 0,
36
38
  } = options || {};
37
39
 
@@ -120,6 +122,13 @@ export function captureSnapshotInPage(options) {
120
122
  // `<input>` otherwise shows the value the server sent, not the one the user
121
123
  // typed.
122
124
  const carryLiveState = (original, copy, secret) => {
125
+ if (
126
+ useDefaults &&
127
+ original.localName === 'meta' &&
128
+ /token|csrf|xsrf|secret/i.test(original.getAttribute('name') || '')
129
+ ) {
130
+ copy.setAttribute('content', redacted);
131
+ }
123
132
  for (const attribute of redactAttributes) {
124
133
  if (copy.hasAttribute && copy.hasAttribute(attribute)) {
125
134
  copy.setAttribute(attribute, redacted);
@@ -147,35 +156,96 @@ export function captureSnapshotInPage(options) {
147
156
  } else if (secret) {
148
157
  copy.textContent = redacted;
149
158
  }
150
- if (original.shadowRoot && openShadowRoots) {
159
+ if (!secret && original.shadowRoot && openShadowRoots) {
151
160
  // Shadow content is not serialized by `outerHTML`; a declarative
152
161
  // template keeps it in the same document the viewer renders.
153
162
  const template = document.createElement('template');
154
163
  template.setAttribute('shadowrootmode', 'open');
155
- template.innerHTML = original.shadowRoot.innerHTML;
164
+ for (const child of original.shadowRoot.childNodes) {
165
+ {
166
+ const cloned = cloneWithState(child);
167
+ if (cloned) {
168
+ template.content.appendChild(cloned);
169
+ }
170
+ }
171
+ }
156
172
  copy.appendChild(template);
157
173
  }
158
174
  };
159
175
 
176
+ const cloneWithState = (original) => {
177
+ if (
178
+ original.nodeType === 1 &&
179
+ ignoreSelectors.some((selector) => original.matches(selector))
180
+ ) {
181
+ return null;
182
+ }
183
+ const copy = original.cloneNode(false);
184
+ if (original.nodeType !== 1) {
185
+ return copy;
186
+ }
187
+ const secret = isSecret(original);
188
+ if (!secret) {
189
+ for (const child of original.childNodes) {
190
+ {
191
+ const cloned = cloneWithState(child);
192
+ if (cloned) {
193
+ copy.appendChild(cloned);
194
+ }
195
+ }
196
+ }
197
+ }
198
+ carryLiveState(original, copy, secret);
199
+ return copy;
200
+ };
201
+
160
202
  let html = null;
161
203
  let truncated = false;
162
204
  if (wantHtml) {
163
- const clone = document.documentElement.cloneNode(true);
164
- const originals = document.documentElement.querySelectorAll('*');
165
- const copies = clone.querySelectorAll('*');
166
- for (let index = 0; index < originals.length; index++) {
167
- const copy = copies[index];
168
- if (!copy) {
169
- break;
170
- }
171
- carryLiveState(originals[index], copy, isSecret(originals[index]));
205
+ const clone = cloneWithState(document.documentElement);
206
+
207
+ html = `<!DOCTYPE html>\n${clone?.outerHTML ?? ''}`;
208
+ const encoded = new globalThis.TextEncoder().encode(html);
209
+ if (maxHtmlBytes > 0 && encoded.length > maxHtmlBytes) {
210
+ const marker = '<!-- trace HTML truncated -->';
211
+ const prefix = new globalThis.TextDecoder().decode(
212
+ encoded.slice(0, Math.max(0, maxHtmlBytes - marker.length - 3))
213
+ );
214
+ html = `${prefix}${marker}`;
215
+ truncated = true;
172
216
  }
217
+ }
173
218
 
174
- html = `<!DOCTYPE html>\n${clone.outerHTML}`;
175
- if (maxHtmlBytes > 0 && html.length > maxHtmlBytes) {
176
- html = html.slice(0, maxHtmlBytes);
177
- truncated = true;
219
+ const visibleText = [];
220
+ const collectText = (node) => {
221
+ if (node.nodeType === 3 && node.parentElement) {
222
+ const parent = node.parentElement;
223
+ if (
224
+ !parent.getClientRects().length ||
225
+ window.getComputedStyle(parent).visibility === 'hidden' ||
226
+ ignoreSelectors.some((selector) => parent.closest(selector))
227
+ ) {
228
+ return;
229
+ }
230
+ const secret = redactSelectors.some((selector) =>
231
+ parent.closest(selector)
232
+ );
233
+ if (node.nodeValue.trim()) {
234
+ visibleText.push(
235
+ `${cssPath(parent)}: ${secret ? redacted : node.nodeValue.trim()}`
236
+ );
237
+ }
238
+ } else {
239
+ for (const child of node.childNodes ?? []) {
240
+ collectText(child);
241
+ }
242
+ if (node.shadowRoot && openShadowRoots) {
243
+ collectText(node.shadowRoot);
244
+ }
178
245
  }
246
+ };
247
+ if (options?.captureText) {
248
+ collectText(document.documentElement);
179
249
  }
180
250
 
181
251
  return {
@@ -190,6 +260,7 @@ export function captureSnapshotInPage(options) {
190
260
  ? cssPath(document.activeElement)
191
261
  : null,
192
262
  controls,
263
+ ...(options?.captureText ? { text: visibleText.join('\n') } : {}),
193
264
  frames: [...document.querySelectorAll('iframe, frame')].map((frame) => ({
194
265
  path: cssPath(frame),
195
266
  src: frame.getAttribute('src'),
@@ -223,8 +294,11 @@ export function installMutationRecorderInPage(options) {
223
294
  const {
224
295
  globalName = '__browserCommanderTrace__',
225
296
  redactSelectors = [],
297
+ redactAttributes = [],
298
+ useDefaults = true,
226
299
  redacted = '[redacted]',
227
300
  maxQueued = 5000,
301
+ ignoreSelectors = [],
228
302
  liveState = true,
229
303
  } = options || {};
230
304
 
@@ -237,20 +311,18 @@ export function installMutationRecorderInPage(options) {
237
311
  queue: [],
238
312
  dropped: 0,
239
313
  sequence: 0,
240
- // Every document gets its own name, so a batch drained from an iframe is
241
- // never mistaken for one drained from the page that holds it.
242
314
  frameId: `frame-${Math.random().toString(36).slice(2, 10)}`,
243
315
  frameName: window.name || null,
244
316
  mainFrame: window.top === window,
245
317
  };
246
318
 
247
- const isSecret = (node) => {
319
+ const matchesAny = (node, selectors) => {
248
320
  const element =
249
321
  node && node.nodeType === 1 ? node : node && node.parentElement;
250
322
  if (!element || !element.matches) {
251
323
  return false;
252
324
  }
253
- return redactSelectors.some((selector) => {
325
+ return selectors.some((selector) => {
254
326
  try {
255
327
  return element.matches(selector) || element.closest(selector) !== null;
256
328
  } catch {
@@ -258,6 +330,43 @@ export function installMutationRecorderInPage(options) {
258
330
  }
259
331
  });
260
332
  };
333
+ const isSecret = (node) =>
334
+ matchesAny(node, redactSelectors) ||
335
+ (useDefaults &&
336
+ node?.localName === 'meta' &&
337
+ /token|csrf|xsrf|secret|password/i.test(node.getAttribute('name') ?? ''));
338
+ const isIgnored = (node) => matchesAny(node, ignoreSelectors);
339
+ const sanitized = (node) => {
340
+ const clone = node.cloneNode(true);
341
+ for (const selector of ignoreSelectors) {
342
+ for (const element of clone.querySelectorAll(selector)) {
343
+ element.remove();
344
+ }
345
+ }
346
+ for (const selector of redactSelectors) {
347
+ for (const element of clone.querySelectorAll(selector)) {
348
+ element.textContent = redacted;
349
+ if (element.hasAttribute('value')) {
350
+ element.setAttribute('value', redacted);
351
+ }
352
+ }
353
+ }
354
+ for (const element of [clone, ...clone.querySelectorAll('*')]) {
355
+ for (const attribute of redactAttributes) {
356
+ if (element.hasAttribute(attribute)) {
357
+ element.setAttribute(attribute, redacted);
358
+ }
359
+ }
360
+ if (
361
+ useDefaults &&
362
+ element.localName === 'meta' &&
363
+ /token|csrf|xsrf|secret/i.test(element.getAttribute('name') || '')
364
+ ) {
365
+ element.setAttribute('content', redacted);
366
+ }
367
+ }
368
+ return clone;
369
+ };
261
370
 
262
371
  // A path the viewer can look the node up by: replay needs to find the same
263
372
  // element again in a parsed copy of the checkpoint.
@@ -268,11 +377,9 @@ export function installMutationRecorderInPage(options) {
268
377
  steps.unshift(`#${at.id}`);
269
378
  break;
270
379
  }
271
- const siblings = at.parentElement
272
- ? [...at.parentElement.children].filter(
273
- (c) => c.localName === at.localName
274
- )
275
- : [];
380
+ const siblings = [...(at.parentElement?.children ?? [])].filter(
381
+ (c) => c.localName === at.localName
382
+ );
276
383
  const nth =
277
384
  siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(at) + 1})` : '';
278
385
  steps.unshift(at.localName + nth);
@@ -288,12 +395,16 @@ export function installMutationRecorderInPage(options) {
288
395
  };
289
396
 
290
397
  const describe = (node) => {
291
- if (!node) {
398
+ if (!node || isIgnored(node)) {
292
399
  return null;
293
400
  }
294
401
  if (node.nodeType === 3) {
295
402
  return {
296
403
  type: 'text',
404
+ visible: node.parentElement
405
+ ? node.parentElement.getClientRects().length > 0 &&
406
+ window.getComputedStyle(node.parentElement).visibility !== 'hidden'
407
+ : true,
297
408
  path: pathOf(node.parentElement),
298
409
  text: isSecret(node) ? redacted : String(node.nodeValue).slice(0, 2000),
299
410
  };
@@ -301,12 +412,36 @@ export function installMutationRecorderInPage(options) {
301
412
  if (node.nodeType !== 1) {
302
413
  return { type: 'node', nodeType: node.nodeType };
303
414
  }
415
+ const clone = sanitized(node);
304
416
  return {
305
417
  type: 'element',
306
418
  tag: node.localName,
307
419
  id: node.id || null,
308
420
  path: pathOf(node),
309
- html: isSecret(node) ? redacted : String(node.outerHTML).slice(0, 4000),
421
+ html: isSecret(node) ? redacted : String(clone.outerHTML).slice(0, 4000),
422
+ text: isSecret(node)
423
+ ? redacted
424
+ : String(clone.textContent).slice(0, 2000),
425
+ visible:
426
+ node.getClientRects().length > 0 &&
427
+ window.getComputedStyle(node).visibility !== 'hidden',
428
+ };
429
+ };
430
+
431
+ // Mutation targets identify an existing node. Only additions need markup.
432
+ const identify = (node) => {
433
+ if (!node || isIgnored(node)) {
434
+ return null;
435
+ }
436
+ const element = node.nodeType === 1 ? node : node.parentElement;
437
+ return {
438
+ type: node.nodeType === 3 ? 'text' : 'element',
439
+ path: pathOf(element),
440
+ tag: element?.localName,
441
+ id: element?.id || null,
442
+ visible:
443
+ Boolean(element?.getClientRects().length) &&
444
+ window.getComputedStyle(element).visibility !== 'hidden',
310
445
  };
311
446
  };
312
447
 
@@ -364,41 +499,45 @@ export function installMutationRecorderInPage(options) {
364
499
  return;
365
500
  }
366
501
  push({
367
- records: records.map((record) => {
368
- const entry = {
369
- kind: record.type,
370
- target: describe(record.target),
371
- };
372
- if (record.type === 'attributes') {
373
- entry.attribute = record.attributeName;
374
- const secret =
375
- isSecret(record.target) ||
376
- redactSelectors.includes(record.attributeName);
377
- entry.before = secret ? redacted : record.oldValue;
378
- entry.after =
379
- secret || !record.target.getAttribute
502
+ records: records
503
+ .filter((record) => !isIgnored(record.target))
504
+ .map((record) => {
505
+ const entry = {
506
+ kind: record.type,
507
+ target: identify(record.target),
508
+ };
509
+ if (record.type === 'attributes') {
510
+ entry.attribute = record.attributeName;
511
+ const secret =
512
+ isSecret(record.target) ||
513
+ redactAttributes.includes(record.attributeName) ||
514
+ (useDefaults &&
515
+ /token|csrf|xsrf|secret|password/i.test(record.attributeName));
516
+ entry.before = secret ? redacted : record.oldValue;
517
+ entry.after =
518
+ secret || !record.target.getAttribute
519
+ ? redacted
520
+ : record.target.getAttribute(record.attributeName);
521
+ } else if (record.type === 'characterData') {
522
+ entry.before = isSecret(record.target) ? redacted : record.oldValue;
523
+ entry.after = isSecret(record.target)
380
524
  ? redacted
381
- : record.target.getAttribute(record.attributeName);
382
- } else if (record.type === 'characterData') {
383
- entry.before = isSecret(record.target) ? redacted : record.oldValue;
384
- entry.after = isSecret(record.target)
385
- ? redacted
386
- : record.target.nodeValue;
387
- } else {
388
- // Where a node went is as much of the change as what it was:
389
- // appending every addition to the end replays an insertion before a
390
- // sibling in the wrong place, and drops moves and removals entirely.
391
- entry.added = [...record.addedNodes].map((node) =>
392
- describeAt(node, record)
393
- );
394
- entry.removed = [...record.removedNodes].map((node) =>
395
- describeAt(node, record)
396
- );
397
- entry.previous = describe(record.previousSibling);
398
- entry.next = describe(record.nextSibling);
399
- }
400
- return entry;
401
- }),
525
+ : record.target.nodeValue;
526
+ } else {
527
+ // Where a node went is as much of the change as what it was:
528
+ // appending every addition to the end replays an insertion before a
529
+ // sibling in the wrong place, and drops moves and removals entirely.
530
+ entry.added = [...record.addedNodes]
531
+ .filter((node) => !isIgnored(node))
532
+ .map((node) => describeAt(node, record));
533
+ entry.removed = [...record.removedNodes]
534
+ .filter((node) => !isIgnored(node))
535
+ .map((node) => describeAt(node, record));
536
+ entry.previous = identify(record.previousSibling);
537
+ entry.next = identify(record.nextSibling);
538
+ }
539
+ return entry;
540
+ }),
402
541
  });
403
542
  });
404
543
 
@@ -420,7 +559,6 @@ export function installMutationRecorderInPage(options) {
420
559
  // into a field and then leaving it fires `input` for each keystroke and
421
560
  // `change` for the value it ended at, and a checkbox fires both for the
422
561
  // one click. Replaying a state that is already on screen shows nothing, so
423
- // a repeat of what this element last reported is not recorded.
424
562
  const repeats = (element, property, after) => {
425
563
  const seen = lastLive.get(element) || {};
426
564
  const asText = JSON.stringify(after === undefined ? null : after);
@@ -435,16 +573,13 @@ export function installMutationRecorderInPage(options) {
435
573
  const liveEntry = (target, property, after, before) => ({
436
574
  kind: 'live-state',
437
575
  property,
438
- target: describe(target),
576
+ target: identify(target),
439
577
  before: before === undefined ? null : before,
440
578
  after,
441
579
  });
442
580
 
443
581
  const pushLive = (entry) => {
444
- // Scrolling fires far more often than it changes anything worth
445
- // replaying, so consecutive scrolls of one element collapse into the
446
- // position it ended at. Every other property keeps each step, because
447
- // "stepwise replay must show each state" (issue #93).
582
+ // Consecutive scrolls collapse; other state changes retain each step.
448
583
  const last = state.queue[state.queue.length - 1];
449
584
  const only = last && last.records.length === 1 ? last.records[0] : null;
450
585
  if (
@@ -470,7 +605,7 @@ export function installMutationRecorderInPage(options) {
470
605
 
471
606
  const onValue = (event) => {
472
607
  const element = event.target;
473
- if (!element || element.nodeType !== 1) {
608
+ if (!element || element.nodeType !== 1 || isIgnored(element)) {
474
609
  return;
475
610
  }
476
611
  if (element.type === 'checkbox' || element.type === 'radio') {
@@ -517,7 +652,7 @@ export function installMutationRecorderInPage(options) {
517
652
  pushLive({
518
653
  kind: 'live-state',
519
654
  property: 'scroll',
520
- target: scrolling ? describe(scrolling) : null,
655
+ target: scrolling ? identify(scrolling) : null,
521
656
  before: null,
522
657
  after: scrolling
523
658
  ? { top: scrolling.scrollTop, left: scrolling.scrollLeft }
@@ -7,8 +7,8 @@
7
7
  * than rejected.
8
8
  */
9
9
 
10
- import fs from 'node:fs/promises';
11
10
  import path from 'node:path';
11
+ import { readTraceText } from './storage.js';
12
12
  import {
13
13
  assertReadableManifest,
14
14
  createManifest,
@@ -18,15 +18,8 @@ import {
18
18
  TRACE_OUTCOME,
19
19
  } from './schema.js';
20
20
 
21
- async function readIfPresent(file) {
22
- try {
23
- return await fs.readFile(file, 'utf8');
24
- } catch (error) {
25
- if (error.code === 'ENOENT') {
26
- return null;
27
- }
28
- throw error;
29
- }
21
+ function readIfPresent(file) {
22
+ return readTraceText(file);
30
23
  }
31
24
 
32
25
  /**
@@ -64,6 +57,11 @@ export function parseNdjson(body) {
64
57
  */
65
58
  export async function readTrace(bundlePath) {
66
59
  const root = path.resolve(bundlePath);
60
+ const segments = await readIfPresent(path.join(root, 'segments.json'));
61
+ if (segments) {
62
+ const { readRollingTrace } = await import('./rolling.js');
63
+ return readRollingTrace(root, JSON.parse(segments), readTrace);
64
+ }
67
65
  const manifestBody = await readIfPresent(
68
66
  path.join(root, TRACE_FILES.MANIFEST)
69
67
  );