browser-debugger-cli 0.15.0 → 0.16.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 (133) hide show
  1. package/.claude/skills/bdg/SKILL.md +2 -1
  2. package/dist/cdp/methodTarget.d.ts +92 -0
  3. package/dist/cdp/methodTarget.js +159 -0
  4. package/dist/cdp/protocol.d.ts +16 -1
  5. package/dist/cdp/protocol.js +21 -0
  6. package/dist/cdp/schema.d.ts +55 -1
  7. package/dist/cdp/schema.js +134 -25
  8. package/dist/cdp/types.d.ts +3 -1
  9. package/dist/commands/cdp.d.ts +38 -1
  10. package/dist/commands/cdp.js +200 -133
  11. package/dist/commands/cleanup.js +18 -4
  12. package/dist/commands/dom/formInteraction.js +8 -4
  13. package/dist/commands/dom/helpers/index.d.ts +4 -4
  14. package/dist/commands/dom/helpers/index.js +3 -3
  15. package/dist/commands/dom/helpers/query.d.ts +2 -2
  16. package/dist/commands/dom/helpers/query.js +2 -2
  17. package/dist/commands/dom/helpers/screenshot.d.ts +21 -26
  18. package/dist/commands/dom/helpers/screenshot.js +50 -668
  19. package/dist/commands/dom/screenshot.js +56 -36
  20. package/dist/commands/optionBehaviors.js +18 -8
  21. package/dist/commands/shared/CommandRunner.d.ts +5 -0
  22. package/dist/commands/shared/CommandRunner.js +18 -3
  23. package/dist/commands/shared/interrupt.d.ts +40 -0
  24. package/dist/commands/shared/interrupt.js +73 -0
  25. package/dist/commands/shared/optionTypes.d.ts +2 -0
  26. package/dist/commands/shared/startHelpers.d.ts +26 -3
  27. package/dist/commands/shared/startHelpers.js +145 -23
  28. package/dist/commands/types.d.ts +5 -0
  29. package/dist/connection/cdp.js +1 -16
  30. package/dist/connection/chromeIdentity.d.ts +24 -5
  31. package/dist/connection/chromeIdentity.js +53 -22
  32. package/dist/connection/launcher.d.ts +34 -1
  33. package/dist/connection/launcher.js +98 -10
  34. package/dist/connection/typed-cdp.d.ts +3 -2
  35. package/dist/constants.d.ts +1 -1
  36. package/dist/constants.js +1 -1
  37. package/dist/daemon/SessionController.d.ts +10 -5
  38. package/dist/daemon/SessionController.js +15 -8
  39. package/dist/daemon/ipcServer.js +1 -1
  40. package/dist/daemon/launcher.d.ts +5 -0
  41. package/dist/daemon/launcher.js +8 -1
  42. package/dist/daemon/session/Session.d.ts +5 -1
  43. package/dist/daemon/session/Session.js +9 -8
  44. package/dist/daemon/session/TelemetryStore.d.ts +5 -0
  45. package/dist/daemon/session/TelemetryStore.js +4 -0
  46. package/dist/daemon/session/captureGate.d.ts +59 -0
  47. package/dist/daemon/session/captureGate.js +96 -0
  48. package/dist/daemon/session/chromeConnection.d.ts +16 -1
  49. package/dist/daemon/session/chromeConnection.js +34 -4
  50. package/dist/daemon/session/collectors.d.ts +15 -0
  51. package/dist/daemon/session/collectors.js +39 -2
  52. package/dist/daemon/session/commandRegistry.d.ts +14 -1
  53. package/dist/daemon/session/commandRegistry.js +46 -11
  54. package/dist/daemon/session/downloads.d.ts +32 -0
  55. package/dist/daemon/session/downloads.js +96 -0
  56. package/dist/daemon/session/interactions.d.ts +3 -2
  57. package/dist/daemon/session/interactions.js +7 -2
  58. package/dist/daemon/session/plugins.js +6 -0
  59. package/dist/daemon.js +12843 -11482
  60. package/dist/errors/CommandError.d.ts +2 -0
  61. package/dist/errors/issues.d.ts +1 -1
  62. package/dist/errors/messages.d.ts +58 -0
  63. package/dist/errors/messages.js +112 -0
  64. package/dist/index.js +999 -1020
  65. package/dist/ipc/client.d.ts +14 -1
  66. package/dist/ipc/client.js +21 -4
  67. package/dist/ipc/protocol/commands.d.ts +32 -2
  68. package/dist/ipc/protocol/commands.js +1 -0
  69. package/dist/ipc/protocol/domTypes.d.ts +24 -1
  70. package/dist/ipc/session/queries.d.ts +3 -0
  71. package/dist/ipc/session/types.d.ts +5 -0
  72. package/dist/ipc/transport/IPCError.d.ts +9 -0
  73. package/dist/ipc/transport/IPCError.js +12 -0
  74. package/dist/ipc/transport/errors.d.ts +2 -1
  75. package/dist/ipc/transport/errors.js +4 -1
  76. package/dist/ipc/transport/index.d.ts +4 -2
  77. package/dist/ipc/transport/index.js +13 -3
  78. package/dist/runtime/dom/actionEffects.d.ts +48 -9
  79. package/dist/runtime/dom/actionEffects.js +269 -34
  80. package/dist/runtime/dom/actionEffectsScripts.d.ts +45 -0
  81. package/dist/runtime/dom/actionEffectsScripts.js +101 -2
  82. package/dist/runtime/dom/captureArea.d.ts +35 -0
  83. package/dist/runtime/dom/captureArea.js +203 -0
  84. package/dist/runtime/dom/elementInfo.d.ts +10 -8
  85. package/dist/runtime/dom/elementInfo.js +8 -6
  86. package/dist/runtime/dom/formDiscovery.d.ts +1 -1
  87. package/dist/runtime/page/bdgWorld.d.ts +9 -0
  88. package/dist/runtime/page/bdgWorld.js +11 -0
  89. package/dist/runtime/page/captureEmulation.d.ts +119 -0
  90. package/dist/runtime/page/captureEmulation.js +189 -0
  91. package/dist/runtime/page/captureScroll.d.ts +24 -0
  92. package/dist/runtime/page/captureScroll.js +124 -0
  93. package/dist/runtime/page/screenshot.d.ts +41 -0
  94. package/dist/runtime/page/screenshot.js +394 -0
  95. package/dist/session/paths.d.ts +14 -0
  96. package/dist/session/paths.js +25 -0
  97. package/dist/telemetry/downloads.d.ts +127 -0
  98. package/dist/telemetry/downloads.js +265 -0
  99. package/dist/telemetry/har/builder.js +22 -7
  100. package/dist/telemetry/har/sanitize.d.ts +7 -3
  101. package/dist/telemetry/har/sanitize.js +52 -6
  102. package/dist/telemetry/har/sanitizeBody.d.ts +47 -7
  103. package/dist/telemetry/har/sanitizeBody.js +429 -56
  104. package/dist/telemetry/har/types.d.ts +2 -0
  105. package/dist/telemetry/network.d.ts +4 -4
  106. package/dist/telemetry/network.js +38 -4
  107. package/dist/telemetry/networkRetention.d.ts +35 -14
  108. package/dist/telemetry/networkRetention.js +62 -26
  109. package/dist/types.d.ts +9 -14
  110. package/dist/ui/OutputBuilder.d.ts +3 -2
  111. package/dist/ui/OutputBuilder.js +4 -3
  112. package/dist/ui/formatters/cdp.d.ts +32 -9
  113. package/dist/ui/formatters/cdp.js +77 -6
  114. package/dist/ui/formatters/details.js +7 -15
  115. package/dist/ui/formatters/preview.d.ts +2 -0
  116. package/dist/ui/formatters/preview.js +7 -1
  117. package/dist/ui/formatters/status.js +6 -1
  118. package/dist/ui/formatting.d.ts +7 -0
  119. package/dist/ui/formatting.js +13 -0
  120. package/dist/ui/logging/logger.d.ts +1 -1
  121. package/dist/ui/messages/chrome.d.ts +13 -0
  122. package/dist/ui/messages/chrome.js +26 -0
  123. package/dist/ui/messages/commands.d.ts +71 -3
  124. package/dist/ui/messages/commands.js +98 -3
  125. package/dist/ui/messages/networkMessages.d.ts +24 -5
  126. package/dist/ui/messages/networkMessages.js +31 -8
  127. package/dist/utils/async.d.ts +3 -2
  128. package/dist/utils/async.js +16 -3
  129. package/dist/utils/http.d.ts +11 -4
  130. package/dist/utils/http.js +5 -3
  131. package/package.json +18 -4
  132. /package/dist/{commands/dom → runtime/page}/screenshotResize.d.ts +0 -0
  133. /package/dist/{commands/dom → runtime/page}/screenshotResize.js +0 -0
@@ -5,12 +5,17 @@
5
5
  * working on the result. Costs one page script sent before the action (not
6
6
  * waited for: CDP runs it before the action's own scripts) and one read
7
7
  * after it, plus a second look 300 ms later when nothing seemed to happen.
8
+ * While watching, a timer in bdg's world notes the stalls during which the
9
+ * page's tasks could not run ({@link STALL_WATCH_START_SCRIPT}); they do not
10
+ * count as quiet time when deciding whether the DOM kept changing.
8
11
  * Worst case, when the page does not answer (a navigation is pending, or a
9
12
  * long script runs), the snapshot is given up after {@link START_TIMEOUT_MS}
10
- * and each read after {@link READ_TIMEOUT_MS}.
13
+ * and each read after {@link READ_TIMEOUT_MS}, plus as long again, once per
14
+ * action, to ask whether the page ran a long task ({@link pageAnswer}).
11
15
  */
12
- import { EFFECTS_READ_SCRIPT, EFFECTS_START_SCRIPT, EFFECTS_STOP_SCRIPT, } from './actionEffectsScripts.js';
16
+ import { AWAIT_DUE_TIMERS_SCRIPT, EFFECTS_READ_SCRIPT, EFFECTS_START_SCRIPT, EFFECTS_STOP_SCRIPT, LONG_TASKS_READ_SCRIPT, START_DUE_TIMERS_SCRIPT, STALL_READ_SCRIPT, STALL_WATCH_START_SCRIPT, STALL_WATCH_STOP_SCRIPT, } from './actionEffectsScripts.js';
13
17
  import { listenForActivity, } from './pageActivity.js';
18
+ import { evaluateInBdgWorld, hasBdgWorld } from '../page/bdgWorld.js';
14
19
  import { createLogger } from '../../ui/logging/index.js';
15
20
  import { delay, raceTimeout } from '../../utils/async.js';
16
21
  import { getErrorMessage } from '../../utils/errors.js';
@@ -21,21 +26,53 @@ const MAX_NEW_MESSAGES = 3;
21
26
  const MAX_SHOWN_ELEMENTS = 3;
22
27
  /**
23
28
  * Bursts of DOM changes that make the DOM look busy: at least this many
24
- * within {@link BUSY_WINDOW_MS}, the last within {@link BUSY_RECENT_MS}
29
+ * within {@link BUSY_WINDOW_MS}, the last within {@link BUSY_RECENT_MS} of
30
+ * quiet time ({@link quietMs})
25
31
  */
26
32
  const BUSY_BURSTS = 2;
27
33
  const BUSY_WINDOW_MS = 500;
28
34
  const BUSY_RECENT_MS = 150;
29
- /** Second look at a DOM that looked busy; it is still changing with {@link BUSY_BURSTS} new bursts by then (ms) */
35
+ /**
36
+ * Quiet time since a single burst under which the DOM still looks busy (ms):
37
+ * the page stalled for most of the time since, so its next change could not
38
+ * come. Higher, a short task after a single render (garbage collection, a
39
+ * layout) would cost every such click a second look.
40
+ */
41
+ const LONE_BURST_QUIET_MS = 75;
42
+ /**
43
+ * Second look at a DOM that looked busy; it is still changing when it
44
+ * changed again and was never quiet longer than {@link BUSY_RECENT_MS}
45
+ * until then (ms)
46
+ */
30
47
  const STILL_CHANGING_RECHECK_MS = 250;
31
48
  /** Resource types of pending requests that mean more content is coming */
32
49
  const CONTENT_REQUEST_TYPES = new Set(['Document', 'XHR', 'Fetch', 'Script']);
33
50
  /** Longest message text reported */
34
51
  const MAX_MESSAGE_LENGTH = 120;
52
+ /**
53
+ * How long reading the stalls ({@link STALL_READ_SCRIPT}) may take after a
54
+ * read whose bursts make the stalls matter; without an answer, none are
55
+ * known
56
+ */
57
+ const STALLS_READ_TIMEOUT_MS = 100;
35
58
  /** How long collecting waits for the snapshot taken before the action */
36
59
  const START_TIMEOUT_MS = 200;
37
- /** How long a read after the action may take before its part is skipped */
60
+ /**
61
+ * How long a read after the action may take before its part is skipped. A
62
+ * read has up to three steps ({@link letDueTimersRun}): setting a timer (up
63
+ * to this), waiting for it (up to {@link DUE_TIMERS_TIMEOUT_MS}) and reading
64
+ * (up to this again), so about 600 ms at worst; a DOM that looks busy is
65
+ * read twice.
66
+ */
38
67
  const READ_TIMEOUT_MS = 250;
68
+ /**
69
+ * How long a read waits for the timer it set once the page has set it,
70
+ * before reading anyway: a 0 ms timer waits for the 0 ms tasks queued before
71
+ * it (measured: under 1 ms on an idle page, 5 ms behind a MessageChannel
72
+ * scheduler, 50 ms behind ten chains of 5 ms tasks), and throttled timers
73
+ * may not run for a second
74
+ */
75
+ const DUE_TIMERS_TIMEOUT_MS = 100;
39
76
  /** Second look before claiming "no effect" (late timers, animations) */
40
77
  const NO_EFFECT_RECHECK_MS = 300;
41
78
  /**
@@ -121,26 +158,67 @@ export function shownElements(shown, messages) {
121
158
  .slice(0, MAX_SHOWN_ELEMENTS)
122
159
  .map((element) => ({ ...element, text: cutText(element.text) }));
123
160
  }
161
+ /**
162
+ * Bursts of a read within {@link BUSY_WINDOW_MS} that may make the DOM look
163
+ * busy, else none: at least {@link BUSY_BURSTS} of them, or a single one
164
+ * older than {@link BUSY_RECENT_MS} (quiet since, unless the page stalled
165
+ * and its next change could not come).
166
+ *
167
+ * @param settle - Signals of the read
168
+ * @returns Ages of those bursts (ms), or an empty list
169
+ */
170
+ function recentBursts(settle) {
171
+ const recent = (settle?.burstAges ?? []).filter((age) => age <= BUSY_WINDOW_MS);
172
+ if (recent.length >= BUSY_BURSTS)
173
+ return recent;
174
+ return recent.length === 1 && (recent[0] ?? 0) > BUSY_RECENT_MS ? recent : [];
175
+ }
176
+ /**
177
+ * How long the page was quiet between two moments of a read: the time
178
+ * between them less the stalls in it, during which the page's tasks could
179
+ * not run, so it could not change the DOM either.
180
+ *
181
+ * @param fromAge - Earlier moment, as an age at the read (ms)
182
+ * @param toAge - Later moment, as an age at the read (ms)
183
+ * @param stalls - Stalls of the read (they do not overlap)
184
+ * @returns Quiet time (ms)
185
+ */
186
+ export function quietMs(fromAge, toAge, stalls = []) {
187
+ const stalled = stalls.reduce((sum, [began, ended]) => sum + Math.max(0, Math.min(fromAge, began) - Math.max(toAge, ended)), 0);
188
+ return fromAge - toAge - stalled;
189
+ }
124
190
  /**
125
191
  * Whether a read's DOM looks busy, worth a second look: at least
126
192
  * {@link BUSY_BURSTS} bursts of structural changes within
127
- * {@link BUSY_WINDOW_MS}, the last within {@link BUSY_RECENT_MS}. Text-only
128
- * changes (clocks) and style changes (animations) are not bursts.
193
+ * {@link BUSY_WINDOW_MS}, and quiet for at most {@link BUSY_RECENT_MS} since
194
+ * the last ({@link quietMs}: stalls do not count). A single burst older than
195
+ * that counts when the page stalled for most of the time since, quiet for
196
+ * at most {@link LONE_BURST_QUIET_MS}: on a renderer running the page's
197
+ * timers late, a page's second step may not have come by the first read.
198
+ * Text-only changes (clocks) and style changes (animations) are not bursts.
129
199
  *
130
200
  * @param settle - Signals of the read
131
201
  * @returns True when the DOM may still be changing
132
202
  */
133
203
  export function domLooksBusy(settle) {
134
- if (!settle)
204
+ const recent = recentBursts(settle);
205
+ if (recent.length === 0)
135
206
  return false;
136
- const recent = settle.burstAges.filter((age) => age <= BUSY_WINDOW_MS);
137
- return recent.length >= BUSY_BURSTS && Math.min(...recent) <= BUSY_RECENT_MS;
207
+ const limit = recent.length === 1 ? LONE_BURST_QUIET_MS : BUSY_RECENT_MS;
208
+ return quietMs(Math.min(...recent), 0, settle?.stalls) <= limit;
138
209
  }
139
210
  /**
140
- * Whether the DOM kept changing during the second look: at least
141
- * {@link BUSY_BURSTS} new bursts within the time since the first read (a
142
- * render that ends in two commits, or a poller updating once a second, does
143
- * not count).
211
+ * Whether the DOM kept changing during the second look: at least one new
212
+ * burst since the first read, and no quiet gap longer than
213
+ * {@link BUSY_RECENT_MS} from the last burst the first read saw, through the
214
+ * new ones, to the second read. Stalls in a gap, during which the page's
215
+ * tasks could not run, are not quiet ({@link quietMs}): a page whose steps
216
+ * come 250 ms apart around a 200 ms long task, or whose timers a starved
217
+ * renderer runs 150 ms late, keeps changing. A page changing every 140 ms
218
+ * keeps changing; changes more than 150 ms apart while the page could run,
219
+ * a render that ended over 150 ms before the second read and a poller
220
+ * updating every 300 ms do not. A short render whose last commit came within
221
+ * 150 ms of the second read counts as changing.
144
222
  *
145
223
  * @param settle - Signals of the second read
146
224
  * @param sinceMs - Time since the first read
@@ -149,7 +227,14 @@ export function domLooksBusy(settle) {
149
227
  export function domKeptChanging(settle, sinceMs) {
150
228
  if (!settle)
151
229
  return false;
152
- return settle.burstAges.filter((age) => age < sinceMs).length >= BUSY_BURSTS;
230
+ const fresh = settle.burstAges.filter((age) => age < sinceMs);
231
+ if (fresh.length === 0)
232
+ return false;
233
+ const seen = settle.burstAges.filter((age) => age >= sinceMs);
234
+ const times = [...seen.slice(-1), ...fresh, 0];
235
+ return times
236
+ .slice(1)
237
+ .every((age, i) => quietMs(times[i] ?? age, age, settle.stalls) <= BUSY_RECENT_MS);
153
238
  }
154
239
  /**
155
240
  * What the page was still working on when the action returned, or undefined
@@ -230,8 +315,11 @@ export function hadNoEffect(read, effects, activity) {
230
315
  }
231
316
  /**
232
317
  * Start watching an action's effects: listen for main-frame navigations,
233
- * document statuses, requests and new windows, and send the page snapshot
234
- * without waiting for it.
318
+ * document statuses, requests and new windows, send the page snapshot
319
+ * without waiting for it, and start the stall watch in bdg's world. That
320
+ * creates bdg's world for the reads now, while the page is idle (created at
321
+ * the first read, it would wait for a page busy after the action and could
322
+ * leave the read no time to answer).
235
323
  *
236
324
  * @param cdp - CDP connection
237
325
  * @returns Watch to collect from after the action
@@ -243,7 +331,9 @@ export function watchActionEffects(cdp) {
243
331
  start: evaluate(cdp, EFFECTS_START_SCRIPT),
244
332
  stopConfirmed: false,
245
333
  unresponsive: false,
334
+ askedLate: false,
246
335
  };
336
+ void evaluateInWorld(watch.cdp, STALL_WATCH_START_SCRIPT, false);
247
337
  return {
248
338
  collect: (options) => collectEffects(watch, options),
249
339
  dispose: () => disposeWatch(watch),
@@ -283,34 +373,99 @@ async function collectEffects(watch, options) {
283
373
  }
284
374
  /**
285
375
  * The snapshot taken before the action, waiting at most
286
- * {@link START_TIMEOUT_MS}; a snapshot still unanswered then (and no
287
- * navigation pending) marks the page unresponsive.
376
+ * {@link START_TIMEOUT_MS} ({@link pageAnswer}); a snapshot still
377
+ * unanswered then (and no navigation pending) marks the page unresponsive.
288
378
  *
289
379
  * @param watch - The action's watch
290
380
  * @returns The snapshot, or undefined
291
381
  */
292
382
  async function awaitStart(watch) {
293
- const started = await raceTimeout(watch.start.then((value) => ({ value })), START_TIMEOUT_MS);
383
+ const started = await pageAnswer(watch, watch.start.then((value) => ({ value })), START_TIMEOUT_MS);
294
384
  if (!started)
295
385
  watch.unresponsive = !watch.listener.navigationPending();
296
386
  return started?.value;
297
387
  }
388
+ /**
389
+ * The answer of a page script within a time, or within {@link READ_TIMEOUT_MS}
390
+ * more when the page only answered late ({@link answeredLate}): the same
391
+ * script is waited for, not sent again (a read that stopped the page's watch
392
+ * could not be repeated).
393
+ *
394
+ * @param watch - The action's watch
395
+ * @param answer - The script's answer
396
+ * @param ms - Time it gets first
397
+ * @returns The answer, or undefined when the page is busy
398
+ */
399
+ async function pageAnswer(watch, answer, ms) {
400
+ const first = await raceTimeout(answer, ms);
401
+ if (first !== undefined || !(await answeredLate(watch)))
402
+ return first;
403
+ return raceTimeout(answer, READ_TIMEOUT_MS);
404
+ }
405
+ /**
406
+ * Whether a page whose script got no answer in time only answered late: it
407
+ * answers now, within {@link READ_TIMEOUT_MS}, and ran no long task since
408
+ * the watch began ({@link LONG_TASKS_READ_SCRIPT}). The renderer ran the
409
+ * page's tasks late (a starved machine), and CDP runs a page's scripts in
410
+ * the order sent, so the script that got no answer has run by now; a page
411
+ * that ran a long task, or still does not answer, is busy. Asked at most
412
+ * once per action, so a busy or starved page costs at most
413
+ * {@link READ_TIMEOUT_MS} more; not asked while a main-frame load is
414
+ * pending.
415
+ *
416
+ * @param watch - The action's watch
417
+ * @returns True when the page is not busy, only slow
418
+ */
419
+ async function answeredLate(watch) {
420
+ if (watch.askedLate || watch.listener.navigationPending())
421
+ return false;
422
+ watch.askedLate = true;
423
+ const longTasks = await raceTimeout(evaluateInWorld(watch.cdp, LONG_TASKS_READ_SCRIPT, false), READ_TIMEOUT_MS);
424
+ const late = longTasks === 0;
425
+ log.debug(late
426
+ ? 'Page answered late without a long task (a slow renderer); waiting for its answer'
427
+ : `Page busy (long tasks: ${longTasks ?? 'no answer'})`);
428
+ return late;
429
+ }
298
430
  /**
299
431
  * Whether the DOM is still changing: when the last read looked busy
300
432
  * ({@link domLooksBusy}), a second read {@link STILL_CHANGING_RECHECK_MS}
301
- * later must see it keep changing ({@link domKeptChanging}).
433
+ * later must see it keep changing ({@link domKeptChanging}). The time
434
+ * between the reads is the page's own when both have it.
302
435
  *
303
436
  * @param watch - The action's watch
304
437
  * @param snapshot - Last read, if any
305
438
  * @returns True when the DOM kept changing
306
439
  */
307
440
  async function stillChanging(watch, snapshot) {
308
- if (!domLooksBusy(snapshot?.settle))
441
+ const first = snapshot?.settle;
442
+ if (!domLooksBusy(first))
309
443
  return false;
310
444
  const firstRead = Date.now();
311
445
  await delay(STILL_CHANGING_RECHECK_MS);
312
- const recheck = await readPage(watch, { stop: false, reportShown: false });
313
- return domKeptChanging(recheck?.settle, Date.now() - firstRead);
446
+ const recheck = (await readPage(watch, { stop: false, reportShown: false, stalls: true }))
447
+ ?.settle;
448
+ const sinceMs = first?.at !== undefined && recheck?.at !== undefined
449
+ ? Math.round(recheck.at - first.at)
450
+ : Date.now() - firstRead;
451
+ const changing = domKeptChanging(recheck, sinceMs);
452
+ log.debug(`DOM looked busy (burst ages ${first?.burstAges.join(',')} ms, ` +
453
+ `stalls ${describeStalls(first?.stalls)}); ` +
454
+ `${sinceMs} ms later ${recheck?.burstAges.join(',') ?? 'no answer'}, ` +
455
+ `stalls ${describeStalls(recheck?.stalls)}: ` +
456
+ (changing ? 'still changing' : 'settled'));
457
+ return changing;
458
+ }
459
+ /**
460
+ * Stalls for a debug line: each as `began-ended` ms ago.
461
+ *
462
+ * @param stalls - Stalls of a read
463
+ * @returns Description, `none` without any
464
+ */
465
+ function describeStalls(stalls) {
466
+ if (!stalls || stalls.length === 0)
467
+ return 'none';
468
+ return stalls.map(([began, ended]) => `${began}-${ended}`).join(',');
314
469
  }
315
470
  /**
316
471
  * The page's work as the last read and the CDP events saw it.
@@ -359,36 +514,116 @@ function effectsOf(start, snapshot, events) {
359
514
  }
360
515
  /**
361
516
  * Read the page after the action, unless a main-frame load is pending (the
362
- * read would wait for the new page). A stopping read that answered stops the
363
- * page's watch, so disposing need not. A read that got no answer in time
364
- * marks the page unresponsive.
517
+ * read would wait for the new page). The page first gets to run a timer that
518
+ * fell due meanwhile ({@link letDueTimersRun}); the read itself does not wait
519
+ * for timers. A read whose bursts may make the DOM look busy, and the second
520
+ * look at a DOM that did (`stalls`: its first look's bursts may have left
521
+ * the window by then), also gets the stalls up to it ({@link withStalls}).
522
+ * A stopping read that answered stops the page's watch, so disposing need
523
+ * not. A page that did not answer in time is marked unresponsive.
365
524
  *
366
525
  * @param watch - The action's watch
367
- * @param options - Also stop the page's watch; list shown elements
526
+ * @param options - Also stop the page's watch; list shown elements; always read stalls
368
527
  * @returns The read, or undefined
369
528
  */
370
529
  async function readPage(watch, options) {
371
530
  if (watch.listener.navigationPending())
372
531
  return undefined;
373
532
  const expression = `(${EFFECTS_READ_SCRIPT})(${options.stop}, ${options.reportShown})`;
374
- const answer = await raceTimeout(evaluate(watch.cdp, expression).then((value) => ({ value })), READ_TIMEOUT_MS);
533
+ const answered = await letDueTimersRun(watch);
534
+ const answer = answered
535
+ ? await pageAnswer(watch, evaluate(watch.cdp, expression).then((value) => ({ value })), READ_TIMEOUT_MS)
536
+ : undefined;
375
537
  if (!answer)
376
538
  watch.unresponsive = !watch.listener.navigationPending();
377
539
  const snapshot = answer?.value;
378
540
  if (options.stop && snapshot)
379
541
  watch.stopConfirmed = true;
380
- return snapshot;
542
+ return snapshot && withStalls(watch.cdp, snapshot, options.stalls === true);
543
+ }
544
+ /**
545
+ * A read with the stalls up to it, as ages at the read, when asked to or
546
+ * when it has enough recent bursts for them to matter
547
+ * ({@link recentBursts}); other reads (a static page) are returned as they
548
+ * are, without a further page script. Stalls are read right after the read,
549
+ * in bdg's world, within {@link STALLS_READ_TIMEOUT_MS}; page times after
550
+ * the read are cut off.
551
+ *
552
+ * @param cdp - CDP connection
553
+ * @param snapshot - The read
554
+ * @param always - Read stalls whatever the bursts (the second look at a busy DOM)
555
+ * @returns The read, with stalls when known
556
+ */
557
+ async function withStalls(cdp, snapshot, always) {
558
+ const settle = snapshot.settle;
559
+ if (settle?.at === undefined || (!always && recentBursts(settle).length === 0))
560
+ return snapshot;
561
+ const at = settle.at;
562
+ const stalls = await raceTimeout(evaluateInWorld(cdp, STALL_READ_SCRIPT, false), STALLS_READ_TIMEOUT_MS);
563
+ if (!Array.isArray(stalls))
564
+ return snapshot;
565
+ const ages = stalls
566
+ .filter(([due]) => due < at)
567
+ .map(([due, ran]) => [Math.round(at - due), Math.max(0, Math.round(at - ran))]);
568
+ return { ...snapshot, settle: { ...settle, stalls: ages } };
569
+ }
570
+ /**
571
+ * Let the page run a timer that fell due while it was busy, so a read sees
572
+ * its change: set a 0 ms timer ({@link START_DUE_TIMERS_SCRIPT}), then wait
573
+ * for it at most {@link DUE_TIMERS_TIMEOUT_MS}. Both run in bdg's world, whose
574
+ * `setTimeout` the page cannot replace or fake; its timers share the page's
575
+ * queue. Setting the timer has the read's {@link READ_TIMEOUT_MS}
576
+ * ({@link pageAnswer}); a page that does not answer by then is busy and not
577
+ * read. A page that answers just in time, then keeps its timer waiting the
578
+ * full limit and is slow to read can take about 600 ms (250 + 100 + 250)
579
+ * before the read is given up, more when it only answered late.
580
+ *
581
+ * @param watch - The action's watch
582
+ * @returns False when the page did not answer in time (busy)
583
+ */
584
+ async function letDueTimersRun(watch) {
585
+ const started = await pageAnswer(watch, evaluateInWorld(watch.cdp, START_DUE_TIMERS_SCRIPT, false).then(() => true), READ_TIMEOUT_MS);
586
+ if (!started)
587
+ return false;
588
+ await raceTimeout(evaluateInWorld(watch.cdp, AWAIT_DUE_TIMERS_SCRIPT, true), DUE_TIMERS_TIMEOUT_MS);
589
+ return true;
590
+ }
591
+ /**
592
+ * Evaluate one of bdg's page scripts in bdg's world for its value, failures
593
+ * logged.
594
+ *
595
+ * @param cdp - CDP connection
596
+ * @param expression - Script
597
+ * @param awaitPromise - Wait for the promise it returns
598
+ * @returns Its value, or undefined on an exception or a failed call
599
+ */
600
+ async function evaluateInWorld(cdp, expression, awaitPromise) {
601
+ try {
602
+ const reply = await evaluateInBdgWorld(cdp, { expression, awaitPromise, returnByValue: true });
603
+ if (!reply.exceptionDetails)
604
+ return reply.result.value;
605
+ log.debug(`bdg world script failed: ${reply.exceptionDetails.text}`);
606
+ }
607
+ catch (error) {
608
+ log.debug(`bdg world script not run: ${getErrorMessage(error)}`);
609
+ }
610
+ return undefined;
381
611
  }
382
612
  /**
383
- * Stop listening, and stop the page's watch unless a read did. The stop is
384
- * sent even when the snapshot never answered: CDP runs it after the
385
- * snapshot, wherever that ran (the page also stops watching on its own
386
- * after 30 s).
613
+ * Stop listening, stop the stall watch and stop the page's watch unless a
614
+ * read did. The page's stop is sent even when the snapshot never answered:
615
+ * CDP runs it after the snapshot, wherever that ran (the page also stops
616
+ * watching on its own after 30 s). The stall watch is not stopped when a new
617
+ * document committed and bdg's world is gone with the old one: its watch
618
+ * went with it, and stopping would only make a new world.
387
619
  *
388
620
  * @param watch - The action's watch
389
621
  */
390
622
  function disposeWatch(watch) {
623
+ const documentGone = watch.listener.events.document !== undefined && !hasBdgWorld(watch.cdp);
391
624
  watch.listener.dispose();
625
+ if (!documentGone)
626
+ void evaluateInWorld(watch.cdp, STALL_WATCH_STOP_SCRIPT, false);
392
627
  if (watch.stopConfirmed)
393
628
  return;
394
629
  void watch.cdp
@@ -74,6 +74,19 @@ export declare const UNCERTAIN_JS = "(state, active) => {\n if (state.copied) r
74
74
  * the messages.
75
75
  */
76
76
  export declare const EFFECTS_START_SCRIPT: string;
77
+ /**
78
+ * Page-side wait for one turn of the page's timers. A busy or descheduled
79
+ * renderer (a slow machine) runs a CDP read before a timer that fell due
80
+ * meanwhile, so a page changing every 100 ms would look like a single
81
+ * render; Chrome runs the earliest overdue timers before a 0 ms timer posted
82
+ * after them (a longer delay would wait for every due timer). That order is
83
+ * Chrome's scheduler behaviour, not a web standard, verified on Chrome 154.
84
+ */
85
+ export declare const DUE_TIMERS_JS = "() => new Promise((resolve) => setTimeout(resolve, 0))";
86
+ /** Sets the {@link DUE_TIMERS_JS} timer, kept for {@link AWAIT_DUE_TIMERS_SCRIPT} */
87
+ export declare const START_DUE_TIMERS_SCRIPT = "globalThis.__bdgDueTimers = (() => new Promise((resolve) => setTimeout(resolve, 0)))(), true";
88
+ /** Resolves once the timer {@link START_DUE_TIMERS_SCRIPT} set has run */
89
+ export declare const AWAIT_DUE_TIMERS_SCRIPT = "globalThis.__bdgDueTimers";
77
90
  /**
78
91
  * Read after an action, called with `(stop, shown)`: `stop` also stops
79
92
  * watching, `shown` lists the elements the action showed
@@ -85,6 +98,38 @@ export declare const EFFECTS_START_SCRIPT: string;
85
98
  * (everything shown is new).
86
99
  */
87
100
  export declare const EFFECTS_READ_SCRIPT: string;
101
+ /**
102
+ * Stall watch, run in bdg's world while an action's effects are watched,
103
+ * left in `globalThis.__bdgStalls`: a timer every {@link STALL_BEAT_MS}
104
+ * that notes when it ran over {@link STALL_MIN_MS} late, as a stall from
105
+ * when it was due to when it ran (the last {@link MAX_STALLS}). bdg's world
106
+ * has its own `setTimeout`, so a page that replaced it is still watched, and
107
+ * its timers share the page's queue: a stall is time the page's own timers
108
+ * could not run either (a long task, or a renderer that runs the page's
109
+ * tasks late). The page cannot see it, except in the main-world fallback
110
+ * for frame-scoped connections (no bdg world), where `__bdgStalls` is a
111
+ * global of the page. It also counts the page's long tasks (over 50 ms of
112
+ * script, style or layout), read by {@link LONG_TASKS_READ_SCRIPT}. It
113
+ * stops itself after {@link MAX_WATCH_MS}.
114
+ */
115
+ export declare const STALL_WATCH_START_SCRIPT = "(() => {\n if (globalThis.__bdgStalls) globalThis.__bdgStalls.stop();\n const native = String(setTimeout).includes('[native code]');\n const watch = { stalls: [], due: 0, ticked: native, stopped: false, timer: 0, longTasks: 0 };\n const observer = typeof PerformanceObserver === 'function' &&\n (PerformanceObserver.supportedEntryTypes || []).includes('longtask')\n ? new PerformanceObserver((list) => { watch.longTasks += list.getEntries().length; })\n : null;\n if (observer) observer.observe({ type: 'longtask' });\n watch.longTasksSeen = () => {\n if (!observer) return null;\n watch.longTasks += observer.takeRecords().length;\n return watch.longTasks;\n };\n const schedule = () => {\n watch.due = performance.now() + 20;\n watch.timer = setTimeout(beat, 20);\n };\n const beat = () => {\n const now = performance.now();\n watch.ticked = true;\n if (now - watch.due > 10) {\n watch.stalls.push([watch.due, now]);\n if (watch.stalls.length > 50) watch.stalls.shift();\n }\n schedule();\n };\n watch.stop = () => {\n watch.stopped = true;\n clearTimeout(watch.timer);\n clearTimeout(expiry);\n if (observer) observer.disconnect();\n };\n const expiry = setTimeout(watch.stop, 30000);\n schedule();\n globalThis.__bdgStalls = watch;\n return true;\n})()";
116
+ /**
117
+ * Reads the stalls {@link STALL_WATCH_START_SCRIPT} noted, as page times
118
+ * `[due, ran]`, plus the stall still going on (its timer over
119
+ * {@link STALL_MIN_MS} overdue now) as `[due, now]`; that one only with the
120
+ * browser's own `setTimeout` or once the timer has run at least once (in the
121
+ * main world, where bdg's scripts run without bdg's world, a page may have
122
+ * replaced `setTimeout` with one that never runs). Null without a watch.
123
+ */
124
+ export declare const STALL_READ_SCRIPT = "(() => {\n const watch = globalThis.__bdgStalls;\n if (!watch) return null;\n const now = performance.now();\n const ongoing = watch.ticked && !watch.stopped && now - watch.due > 10;\n return ongoing ? watch.stalls.concat([[watch.due, now]]) : watch.stalls;\n})()";
125
+ /**
126
+ * Reads how many long tasks the page ran since {@link STALL_WATCH_START_SCRIPT}
127
+ * started, also those not yet delivered to its observer. Null without a
128
+ * watch or without long-task support.
129
+ */
130
+ export declare const LONG_TASKS_READ_SCRIPT = "(() => {\n const watch = globalThis.__bdgStalls;\n return watch && watch.longTasksSeen ? watch.longTasksSeen() : null;\n})()";
131
+ /** Stops the watch {@link STALL_WATCH_START_SCRIPT} left */
132
+ export declare const STALL_WATCH_STOP_SCRIPT = "if (globalThis.__bdgStalls) { globalThis.__bdgStalls.stop(); delete globalThis.__bdgStalls; }";
88
133
  /** Stops the watch {@link EFFECTS_START_SCRIPT} left (when no read stopped it) */
89
134
  export declare const EFFECTS_STOP_SCRIPT = "if (window.__bdgEffects) { window.__bdgEffects.stop(); delete window.__bdgEffects; }";
90
135
  //# sourceMappingURL=actionEffectsScripts.d.ts.map
@@ -61,6 +61,12 @@ const MAX_MESSAGE_TEXT = 300;
61
61
  const MAX_WATCH_MS = 30000;
62
62
  /** Bursts of DOM changes a watch keeps (their times) */
63
63
  const MAX_BURSTS = 20;
64
+ /** Delay of the stall watch's timer ({@link STALL_WATCH_START_SCRIPT}, ms) */
65
+ const STALL_BEAT_MS = 20;
66
+ /** How late the stall watch's timer must run to count as a stall (ms) */
67
+ const STALL_MIN_MS = 10;
68
+ /** Stalls a stall watch keeps */
69
+ const MAX_STALLS = 50;
64
70
  /**
65
71
  * Page-side test whether an element is shown: rendered, not aria-hidden,
66
72
  * not `visibility: hidden` and not fully transparent.
@@ -299,14 +305,16 @@ export const UNCERTAIN_JS = `(state, active) => {
299
305
  }`;
300
306
  /**
301
307
  * Page-side signs, at a read, that the page is still working on the
302
- * action's result: how long ago each recent burst of DOM changes was (ms,
303
- * newest last), and a loading indicator shown since the start (described).
308
+ * action's result: the page time of the read, how long ago each recent
309
+ * burst of DOM changes was (ms, newest last), and a loading indicator shown
310
+ * since the start (described).
304
311
  */
305
312
  const SETTLE_JS = `(state) => {
306
313
  const now = performance.now();
307
314
  const describe = ${ELEMENT_DESCRIPTION_JS};
308
315
  const loader = (${LOADERS_JS})().find((el) => !state.loaders.has(el));
309
316
  return {
317
+ at: now,
310
318
  burstAges: state.bursts.map((time) => Math.round(now - time)),
311
319
  loading: loader ? describe(loader) : null
312
320
  };
@@ -392,6 +400,19 @@ export const EFFECTS_START_SCRIPT = `(() => {
392
400
  window.__bdgEffects = state;
393
401
  return { href: location.href, messages: (${MESSAGES_JS})(ids) };
394
402
  })()`;
403
+ /**
404
+ * Page-side wait for one turn of the page's timers. A busy or descheduled
405
+ * renderer (a slow machine) runs a CDP read before a timer that fell due
406
+ * meanwhile, so a page changing every 100 ms would look like a single
407
+ * render; Chrome runs the earliest overdue timers before a 0 ms timer posted
408
+ * after them (a longer delay would wait for every due timer). That order is
409
+ * Chrome's scheduler behaviour, not a web standard, verified on Chrome 154.
410
+ */
411
+ export const DUE_TIMERS_JS = `() => new Promise((resolve) => setTimeout(resolve, 0))`;
412
+ /** Sets the {@link DUE_TIMERS_JS} timer, kept for {@link AWAIT_DUE_TIMERS_SCRIPT} */
413
+ export const START_DUE_TIMERS_SCRIPT = `globalThis.__bdgDueTimers = (${DUE_TIMERS_JS})(), true`;
414
+ /** Resolves once the timer {@link START_DUE_TIMERS_SCRIPT} set has run */
415
+ export const AWAIT_DUE_TIMERS_SCRIPT = 'globalThis.__bdgDueTimers';
395
416
  /**
396
417
  * Read after an action, called with `(stop, shown)`: `stop` also stops
397
418
  * watching, `shown` lists the elements the action showed
@@ -421,6 +442,84 @@ export const EFFECTS_READ_SCRIPT = `((stop, shown) => {
421
442
  shown: shown ? (${SHOWN_ELEMENTS_JS})(state) : undefined
422
443
  };
423
444
  })`;
445
+ /**
446
+ * Stall watch, run in bdg's world while an action's effects are watched,
447
+ * left in `globalThis.__bdgStalls`: a timer every {@link STALL_BEAT_MS}
448
+ * that notes when it ran over {@link STALL_MIN_MS} late, as a stall from
449
+ * when it was due to when it ran (the last {@link MAX_STALLS}). bdg's world
450
+ * has its own `setTimeout`, so a page that replaced it is still watched, and
451
+ * its timers share the page's queue: a stall is time the page's own timers
452
+ * could not run either (a long task, or a renderer that runs the page's
453
+ * tasks late). The page cannot see it, except in the main-world fallback
454
+ * for frame-scoped connections (no bdg world), where `__bdgStalls` is a
455
+ * global of the page. It also counts the page's long tasks (over 50 ms of
456
+ * script, style or layout), read by {@link LONG_TASKS_READ_SCRIPT}. It
457
+ * stops itself after {@link MAX_WATCH_MS}.
458
+ */
459
+ export const STALL_WATCH_START_SCRIPT = `(() => {
460
+ if (globalThis.__bdgStalls) globalThis.__bdgStalls.stop();
461
+ const native = String(setTimeout).includes('[native code]');
462
+ const watch = { stalls: [], due: 0, ticked: native, stopped: false, timer: 0, longTasks: 0 };
463
+ const observer = typeof PerformanceObserver === 'function' &&
464
+ (PerformanceObserver.supportedEntryTypes || []).includes('longtask')
465
+ ? new PerformanceObserver((list) => { watch.longTasks += list.getEntries().length; })
466
+ : null;
467
+ if (observer) observer.observe({ type: 'longtask' });
468
+ watch.longTasksSeen = () => {
469
+ if (!observer) return null;
470
+ watch.longTasks += observer.takeRecords().length;
471
+ return watch.longTasks;
472
+ };
473
+ const schedule = () => {
474
+ watch.due = performance.now() + ${STALL_BEAT_MS};
475
+ watch.timer = setTimeout(beat, ${STALL_BEAT_MS});
476
+ };
477
+ const beat = () => {
478
+ const now = performance.now();
479
+ watch.ticked = true;
480
+ if (now - watch.due > ${STALL_MIN_MS}) {
481
+ watch.stalls.push([watch.due, now]);
482
+ if (watch.stalls.length > ${MAX_STALLS}) watch.stalls.shift();
483
+ }
484
+ schedule();
485
+ };
486
+ watch.stop = () => {
487
+ watch.stopped = true;
488
+ clearTimeout(watch.timer);
489
+ clearTimeout(expiry);
490
+ if (observer) observer.disconnect();
491
+ };
492
+ const expiry = setTimeout(watch.stop, ${MAX_WATCH_MS});
493
+ schedule();
494
+ globalThis.__bdgStalls = watch;
495
+ return true;
496
+ })()`;
497
+ /**
498
+ * Reads the stalls {@link STALL_WATCH_START_SCRIPT} noted, as page times
499
+ * `[due, ran]`, plus the stall still going on (its timer over
500
+ * {@link STALL_MIN_MS} overdue now) as `[due, now]`; that one only with the
501
+ * browser's own `setTimeout` or once the timer has run at least once (in the
502
+ * main world, where bdg's scripts run without bdg's world, a page may have
503
+ * replaced `setTimeout` with one that never runs). Null without a watch.
504
+ */
505
+ export const STALL_READ_SCRIPT = `(() => {
506
+ const watch = globalThis.__bdgStalls;
507
+ if (!watch) return null;
508
+ const now = performance.now();
509
+ const ongoing = watch.ticked && !watch.stopped && now - watch.due > ${STALL_MIN_MS};
510
+ return ongoing ? watch.stalls.concat([[watch.due, now]]) : watch.stalls;
511
+ })()`;
512
+ /**
513
+ * Reads how many long tasks the page ran since {@link STALL_WATCH_START_SCRIPT}
514
+ * started, also those not yet delivered to its observer. Null without a
515
+ * watch or without long-task support.
516
+ */
517
+ export const LONG_TASKS_READ_SCRIPT = `(() => {
518
+ const watch = globalThis.__bdgStalls;
519
+ return watch && watch.longTasksSeen ? watch.longTasksSeen() : null;
520
+ })()`;
521
+ /** Stops the watch {@link STALL_WATCH_START_SCRIPT} left */
522
+ export const STALL_WATCH_STOP_SCRIPT = 'if (globalThis.__bdgStalls) { globalThis.__bdgStalls.stop(); delete globalThis.__bdgStalls; }';
424
523
  /** Stops the watch {@link EFFECTS_START_SCRIPT} left (when no read stopped it) */
425
524
  export const EFFECTS_STOP_SCRIPT = 'if (window.__bdgEffects) { window.__bdgEffects.stop(); delete window.__bdgEffects; }';
426
525
  //# sourceMappingURL=actionEffectsScripts.js.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The area an element screenshot captures: its border box, grown to what
3
+ * the element paints beyond it (overflowing descendants, text, shadows,
4
+ * outline), plus any `--padding`.
5
+ */
6
+ import type { CDPConnection } from '../../connection/cdp.js';
7
+ import type { ElementBounds } from '../../types.js';
8
+ /** An element of the page */
9
+ export interface ElementRef {
10
+ backendNodeId: number;
11
+ }
12
+ /**
13
+ * The border box of an element (padding and border included), relative to
14
+ * the viewport.
15
+ *
16
+ * @param cdp - Session connection
17
+ * @param ref - The element
18
+ * @returns Bounds in CSS px
19
+ * @throws CommandError (81) when it is not rendered or has no area
20
+ */
21
+ export declare function getElementBounds(cdp: CDPConnection, ref: ElementRef): Promise<ElementBounds>;
22
+ /**
23
+ * Area an element screenshot captures: the border box, grown to the content
24
+ * that overflows it ({@link CONTENT_OVERFLOW_JS}), so floated children are
25
+ * not cropped away.
26
+ *
27
+ * @param cdp - Session connection
28
+ * @param ref - The element
29
+ * @param bounds - Border box (DOM.getBoxModel coordinates)
30
+ * @param padding - Extra space around it (CSS px)
31
+ * @returns The area, or the border box (with the padding) when nothing
32
+ * overflows (or the page cannot be asked)
33
+ */
34
+ export declare function captureArea(cdp: CDPConnection, ref: ElementRef, bounds: ElementBounds, padding: number): Promise<ElementBounds>;
35
+ //# sourceMappingURL=captureArea.d.ts.map