browser-commander 0.27.0 → 0.28.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 (75) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +4 -0
  3. package/eslint.config.js +11 -0
  4. package/package.json +5 -2
  5. package/src/browser/connector.js +117 -13
  6. package/src/browser/launch-options.js +2 -1
  7. package/src/browser/launch-restrictions.json +33 -2
  8. package/src/browser/launcher.js +26 -1
  9. package/src/browser/persistent-session.js +360 -0
  10. package/src/browser/profile-directory.js +20 -0
  11. package/src/browser/real-browser.js +7 -2
  12. package/src/browser/restrictions.js +13 -4
  13. package/src/browser/session-watchdog.js +71 -0
  14. package/src/browser/session-worker.js +70 -0
  15. package/src/browser/webdriver.js +4 -0
  16. package/src/capture/encoding.js +327 -0
  17. package/src/capture/errors.js +12 -0
  18. package/src/capture/files.js +9 -0
  19. package/src/capture/index.js +5 -0
  20. package/src/capture/recording.js +136 -0
  21. package/src/capture/screenshot.js +222 -0
  22. package/src/capture/video.js +186 -0
  23. package/src/capture/viewport.js +68 -0
  24. package/src/capture/worker.js +22 -0
  25. package/src/cli/args.js +66 -1
  26. package/src/cli/commands.js +113 -2
  27. package/src/cli/high-level-methods.js +40 -3
  28. package/src/cli/sessions.js +11 -3
  29. package/src/core/commander-features.js +105 -0
  30. package/src/core/constants.js +1 -0
  31. package/src/core/navigation-manager.js +4 -2
  32. package/src/core/navigation-readiness.js +30 -2
  33. package/src/core/navigation-safety.js +51 -0
  34. package/src/core/page-trigger-manager.js +115 -30
  35. package/src/core/readiness.js +10 -2
  36. package/src/elements/locators.js +26 -32
  37. package/src/elements/selectors.js +37 -81
  38. package/src/elements/visibility.js +32 -10
  39. package/src/factory.js +13 -12
  40. package/src/index.js +2 -0
  41. package/src/interactions/click.js +44 -0
  42. package/src/interactions/overlays.js +28 -0
  43. package/src/traces/bundle.js +14 -5
  44. package/src/traces/checkpoints.js +99 -0
  45. package/src/traces/links.js +117 -1
  46. package/src/traces/mutation-stream.js +1 -0
  47. package/src/traces/network.js +257 -0
  48. package/src/traces/page-capture.js +143 -52
  49. package/src/traces/reader.js +8 -10
  50. package/src/traces/recorder.js +221 -245
  51. package/src/traces/render.js +168 -0
  52. package/src/traces/rolling.js +180 -0
  53. package/src/traces/start-cleanup.js +15 -0
  54. package/src/traces/stop.js +159 -0
  55. package/src/traces/storage.js +43 -0
  56. package/src/utilities/subprocess.js +32 -0
  57. package/tests/e2e/cli.e2e.test.js +20 -1
  58. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  59. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  60. package/tests/unit/browser/launcher.test.js +5 -1
  61. package/tests/unit/browser/persistent-session.test.js +59 -0
  62. package/tests/unit/browser/quiet-ui.test.js +16 -0
  63. package/tests/unit/browser/tab-selection.test.js +38 -0
  64. package/tests/unit/capture/capture.test.js +52 -0
  65. package/tests/unit/capture/cleanup.test.js +24 -0
  66. package/tests/unit/capture/viewport.test.js +168 -0
  67. package/tests/unit/cli/dispatcher.test.js +25 -0
  68. package/tests/unit/core/readiness.test.js +21 -0
  69. package/tests/unit/core/trigger-readiness.test.js +39 -0
  70. package/tests/unit/elements/visible-count.test.js +24 -0
  71. package/tests/unit/elements/wait-navigation.test.js +26 -0
  72. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  73. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  74. package/tests/unit/traces/bundle.test.js +16 -0
  75. package/tests/unit/traces/network.test.js +97 -0
@@ -32,6 +32,7 @@ export function captureSnapshotInPage(options) {
32
32
  html: wantHtml = true,
33
33
  liveControlState = true,
34
34
  openShadowRoots = true,
35
+ ignoreSelectors = [],
35
36
  maxHtmlBytes = 0,
36
37
  } = options || {};
37
38
 
@@ -147,37 +148,93 @@ export function captureSnapshotInPage(options) {
147
148
  } else if (secret) {
148
149
  copy.textContent = redacted;
149
150
  }
150
- if (original.shadowRoot && openShadowRoots) {
151
+ if (!secret && original.shadowRoot && openShadowRoots) {
151
152
  // Shadow content is not serialized by `outerHTML`; a declarative
152
153
  // template keeps it in the same document the viewer renders.
153
154
  const template = document.createElement('template');
154
155
  template.setAttribute('shadowrootmode', 'open');
155
- template.innerHTML = original.shadowRoot.innerHTML;
156
+ for (const child of original.shadowRoot.childNodes) {
157
+ {
158
+ const cloned = cloneWithState(child);
159
+ if (cloned) {
160
+ template.content.appendChild(cloned);
161
+ }
162
+ }
163
+ }
156
164
  copy.appendChild(template);
157
165
  }
158
166
  };
159
167
 
168
+ const cloneWithState = (original) => {
169
+ if (
170
+ original.nodeType === 1 &&
171
+ ignoreSelectors.some((selector) => original.matches(selector))
172
+ ) {
173
+ return null;
174
+ }
175
+ const copy = original.cloneNode(false);
176
+ if (original.nodeType !== 1) {
177
+ return copy;
178
+ }
179
+ const secret = isSecret(original);
180
+ if (!secret) {
181
+ for (const child of original.childNodes) {
182
+ {
183
+ const cloned = cloneWithState(child);
184
+ if (cloned) {
185
+ copy.appendChild(cloned);
186
+ }
187
+ }
188
+ }
189
+ }
190
+ carryLiveState(original, copy, secret);
191
+ return copy;
192
+ };
193
+
160
194
  let html = null;
161
195
  let truncated = false;
162
196
  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]));
172
- }
197
+ const clone = cloneWithState(document.documentElement);
173
198
 
174
- html = `<!DOCTYPE html>\n${clone.outerHTML}`;
199
+ html = `<!DOCTYPE html>\n${clone?.outerHTML ?? ''}`;
175
200
  if (maxHtmlBytes > 0 && html.length > maxHtmlBytes) {
176
201
  html = html.slice(0, maxHtmlBytes);
177
202
  truncated = true;
178
203
  }
179
204
  }
180
205
 
206
+ const visibleText = [];
207
+ const collectText = (node) => {
208
+ if (node.nodeType === 3 && node.parentElement) {
209
+ const parent = node.parentElement;
210
+ if (
211
+ !parent.getClientRects().length ||
212
+ window.getComputedStyle(parent).visibility === 'hidden' ||
213
+ ignoreSelectors.some((selector) => parent.closest(selector))
214
+ ) {
215
+ return;
216
+ }
217
+ const secret = redactSelectors.some((selector) =>
218
+ parent.closest(selector)
219
+ );
220
+ if (node.nodeValue.trim()) {
221
+ visibleText.push(
222
+ `${cssPath(parent)}: ${secret ? redacted : node.nodeValue.trim()}`
223
+ );
224
+ }
225
+ } else {
226
+ for (const child of node.childNodes ?? []) {
227
+ collectText(child);
228
+ }
229
+ if (node.shadowRoot && openShadowRoots) {
230
+ collectText(node.shadowRoot);
231
+ }
232
+ }
233
+ };
234
+ if (options?.captureText) {
235
+ collectText(document.documentElement);
236
+ }
237
+
181
238
  return {
182
239
  html,
183
240
  state: {
@@ -190,6 +247,7 @@ export function captureSnapshotInPage(options) {
190
247
  ? cssPath(document.activeElement)
191
248
  : null,
192
249
  controls,
250
+ ...(options?.captureText ? { text: visibleText.join('\n') } : {}),
193
251
  frames: [...document.querySelectorAll('iframe, frame')].map((frame) => ({
194
252
  path: cssPath(frame),
195
253
  src: frame.getAttribute('src'),
@@ -225,6 +283,7 @@ export function installMutationRecorderInPage(options) {
225
283
  redactSelectors = [],
226
284
  redacted = '[redacted]',
227
285
  maxQueued = 5000,
286
+ ignoreSelectors = [],
228
287
  liveState = true,
229
288
  } = options || {};
230
289
 
@@ -244,13 +303,13 @@ export function installMutationRecorderInPage(options) {
244
303
  mainFrame: window.top === window,
245
304
  };
246
305
 
247
- const isSecret = (node) => {
306
+ const matchesAny = (node, selectors) => {
248
307
  const element =
249
308
  node && node.nodeType === 1 ? node : node && node.parentElement;
250
309
  if (!element || !element.matches) {
251
310
  return false;
252
311
  }
253
- return redactSelectors.some((selector) => {
312
+ return selectors.some((selector) => {
254
313
  try {
255
314
  return element.matches(selector) || element.closest(selector) !== null;
256
315
  } catch {
@@ -258,6 +317,25 @@ export function installMutationRecorderInPage(options) {
258
317
  }
259
318
  });
260
319
  };
320
+ const isSecret = (node) => matchesAny(node, redactSelectors);
321
+ const isIgnored = (node) => matchesAny(node, ignoreSelectors);
322
+ const sanitized = (node) => {
323
+ const clone = node.cloneNode(true);
324
+ for (const selector of ignoreSelectors) {
325
+ for (const element of clone.querySelectorAll(selector)) {
326
+ element.remove();
327
+ }
328
+ }
329
+ for (const selector of redactSelectors) {
330
+ for (const element of clone.querySelectorAll(selector)) {
331
+ element.textContent = redacted;
332
+ if (element.hasAttribute('value')) {
333
+ element.setAttribute('value', redacted);
334
+ }
335
+ }
336
+ }
337
+ return clone;
338
+ };
261
339
 
262
340
  // A path the viewer can look the node up by: replay needs to find the same
263
341
  // element again in a parsed copy of the checkpoint.
@@ -288,12 +366,16 @@ export function installMutationRecorderInPage(options) {
288
366
  };
289
367
 
290
368
  const describe = (node) => {
291
- if (!node) {
369
+ if (!node || isIgnored(node)) {
292
370
  return null;
293
371
  }
294
372
  if (node.nodeType === 3) {
295
373
  return {
296
374
  type: 'text',
375
+ visible: node.parentElement
376
+ ? node.parentElement.getClientRects().length > 0 &&
377
+ window.getComputedStyle(node.parentElement).visibility !== 'hidden'
378
+ : true,
297
379
  path: pathOf(node.parentElement),
298
380
  text: isSecret(node) ? redacted : String(node.nodeValue).slice(0, 2000),
299
381
  };
@@ -301,12 +383,19 @@ export function installMutationRecorderInPage(options) {
301
383
  if (node.nodeType !== 1) {
302
384
  return { type: 'node', nodeType: node.nodeType };
303
385
  }
386
+ const clone = sanitized(node);
304
387
  return {
305
388
  type: 'element',
306
389
  tag: node.localName,
307
390
  id: node.id || null,
308
391
  path: pathOf(node),
309
- html: isSecret(node) ? redacted : String(node.outerHTML).slice(0, 4000),
392
+ html: isSecret(node) ? redacted : String(clone.outerHTML).slice(0, 4000),
393
+ text: isSecret(node)
394
+ ? redacted
395
+ : String(clone.textContent).slice(0, 2000),
396
+ visible:
397
+ node.getClientRects().length > 0 &&
398
+ window.getComputedStyle(node).visibility !== 'hidden',
310
399
  };
311
400
  };
312
401
 
@@ -364,41 +453,43 @@ export function installMutationRecorderInPage(options) {
364
453
  return;
365
454
  }
366
455
  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
456
+ records: records
457
+ .filter((record) => !isIgnored(record.target))
458
+ .map((record) => {
459
+ const entry = {
460
+ kind: record.type,
461
+ target: describe(record.target),
462
+ };
463
+ if (record.type === 'attributes') {
464
+ entry.attribute = record.attributeName;
465
+ const secret =
466
+ isSecret(record.target) ||
467
+ redactSelectors.includes(record.attributeName);
468
+ entry.before = secret ? redacted : record.oldValue;
469
+ entry.after =
470
+ secret || !record.target.getAttribute
471
+ ? redacted
472
+ : record.target.getAttribute(record.attributeName);
473
+ } else if (record.type === 'characterData') {
474
+ entry.before = isSecret(record.target) ? redacted : record.oldValue;
475
+ entry.after = isSecret(record.target)
380
476
  ? 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
- }),
477
+ : record.target.nodeValue;
478
+ } else {
479
+ // Where a node went is as much of the change as what it was:
480
+ // appending every addition to the end replays an insertion before a
481
+ // sibling in the wrong place, and drops moves and removals entirely.
482
+ entry.added = [...record.addedNodes]
483
+ .filter((node) => !isIgnored(node))
484
+ .map((node) => describeAt(node, record));
485
+ entry.removed = [...record.removedNodes]
486
+ .filter((node) => !isIgnored(node))
487
+ .map((node) => describeAt(node, record));
488
+ entry.previous = describe(record.previousSibling);
489
+ entry.next = describe(record.nextSibling);
490
+ }
491
+ return entry;
492
+ }),
402
493
  });
403
494
  });
404
495
 
@@ -470,7 +561,7 @@ export function installMutationRecorderInPage(options) {
470
561
 
471
562
  const onValue = (event) => {
472
563
  const element = event.target;
473
- if (!element || element.nodeType !== 1) {
564
+ if (!element || element.nodeType !== 1 || isIgnored(element)) {
474
565
  return;
475
566
  }
476
567
  if (element.type === 'checkbox' || element.type === 'radio') {
@@ -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
  );