browser-debugger-cli 0.9.0 → 0.11.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 (139) hide show
  1. package/.claude/skills/bdg/SKILL.md +268 -0
  2. package/README.md +15 -1
  3. package/dist/commands/dom/a11y.js +2 -1
  4. package/dist/commands/dom/formInteraction.js +56 -25
  5. package/dist/commands/dom/helpers/keyAttributes.d.ts +20 -0
  6. package/dist/commands/dom/helpers/keyAttributes.js +54 -0
  7. package/dist/commands/dom/helpers/query.d.ts +1 -1
  8. package/dist/commands/dom/helpers/query.js +66 -19
  9. package/dist/commands/dom/helpers/runElementCommand.js +4 -3
  10. package/dist/commands/dom/helpers/screenshot.js +85 -12
  11. package/dist/commands/dom/index.d.ts +1 -0
  12. package/dist/commands/dom/index.js +8 -3
  13. package/dist/commands/dom/inspect.d.ts +15 -0
  14. package/dist/commands/dom/inspect.js +82 -0
  15. package/dist/commands/dom/layout.js +2 -2
  16. package/dist/commands/dom/listeners.js +2 -2
  17. package/dist/commands/dom/semanticUtils.d.ts +14 -1
  18. package/dist/commands/dom/semanticUtils.js +44 -3
  19. package/dist/commands/installSkill.d.ts +20 -0
  20. package/dist/commands/installSkill.js +87 -0
  21. package/dist/commands/network/list.js +13 -2
  22. package/dist/commands/optionBehaviors.js +48 -6
  23. package/dist/commands/page.d.ts +1 -1
  24. package/dist/commands/page.js +62 -3
  25. package/dist/commands/shared/commonOptions.d.ts +4 -0
  26. package/dist/commands/shared/commonOptions.js +9 -0
  27. package/dist/commands/shared/optionTypes.d.ts +21 -0
  28. package/dist/commands/shared/startHelpers.d.ts +66 -0
  29. package/dist/commands/shared/startHelpers.js +91 -10
  30. package/dist/commands/shared/validation.d.ts +11 -0
  31. package/dist/commands/shared/validation.js +16 -0
  32. package/dist/commands.js +3 -0
  33. package/dist/daemon/launcher.d.ts +8 -1
  34. package/dist/daemon/launcher.js +3 -1
  35. package/dist/daemon/session/Session.d.ts +7 -0
  36. package/dist/daemon/session/Session.js +23 -1
  37. package/dist/daemon/session/commandRegistry.d.ts +14 -1
  38. package/dist/daemon/session/commandRegistry.js +65 -9
  39. package/dist/daemon/session/interactions.d.ts +18 -5
  40. package/dist/daemon/session/interactions.js +22 -12
  41. package/dist/daemon.js +3565 -329
  42. package/dist/errors/messages.d.ts +85 -0
  43. package/dist/errors/messages.js +128 -1
  44. package/dist/index.js +2151 -960
  45. package/dist/ipc/client.d.ts +9 -0
  46. package/dist/ipc/client.js +13 -0
  47. package/dist/ipc/protocol/commands.d.ts +56 -1
  48. package/dist/ipc/protocol/commands.js +2 -0
  49. package/dist/ipc/protocol/domTypes.d.ts +35 -2
  50. package/dist/ipc/protocol/inspectTypes.d.ts +388 -0
  51. package/dist/ipc/protocol/inspectTypes.js +10 -0
  52. package/dist/runtime/dom/actionEffects.d.ts +94 -15
  53. package/dist/runtime/dom/actionEffects.js +173 -27
  54. package/dist/runtime/dom/actionEffectsScripts.d.ts +52 -14
  55. package/dist/runtime/dom/actionEffectsScripts.js +224 -32
  56. package/dist/runtime/dom/elementInfo.d.ts +26 -0
  57. package/dist/runtime/dom/elementInfo.js +65 -0
  58. package/dist/runtime/dom/eventListeners.js +14 -4
  59. package/dist/runtime/dom/formFillHelpers/fill.d.ts +3 -4
  60. package/dist/runtime/dom/formFillHelpers/fill.js +77 -28
  61. package/dist/runtime/dom/frameSelection.d.ts +11 -0
  62. package/dist/runtime/dom/frameSelection.js +20 -1
  63. package/dist/runtime/dom/frames.d.ts +38 -5
  64. package/dist/runtime/dom/frames.js +136 -21
  65. package/dist/runtime/dom/inspect.d.ts +28 -0
  66. package/dist/runtime/dom/inspect.js +557 -0
  67. package/dist/runtime/dom/inspectAllStyles.d.ts +62 -0
  68. package/dist/runtime/dom/inspectAllStyles.js +385 -0
  69. package/dist/runtime/dom/inspectCascade.d.ts +94 -0
  70. package/dist/runtime/dom/inspectCascade.js +371 -0
  71. package/dist/runtime/dom/inspectCascadeModel.d.ts +39 -0
  72. package/dist/runtime/dom/inspectCascadeModel.js +232 -0
  73. package/dist/runtime/dom/inspectHints.d.ts +62 -0
  74. package/dist/runtime/dom/inspectHints.js +305 -0
  75. package/dist/runtime/dom/inspectLayoutModel.d.ts +87 -0
  76. package/dist/runtime/dom/inspectLayoutModel.js +346 -0
  77. package/dist/runtime/dom/inspectModel.d.ts +74 -0
  78. package/dist/runtime/dom/inspectModel.js +184 -0
  79. package/dist/runtime/dom/inspectPaintModel.d.ts +157 -0
  80. package/dist/runtime/dom/inspectPaintModel.js +461 -0
  81. package/dist/runtime/dom/inspectRules.d.ts +37 -0
  82. package/dist/runtime/dom/inspectRules.js +101 -0
  83. package/dist/runtime/dom/inspectScripts.d.ts +132 -0
  84. package/dist/runtime/dom/inspectScripts.js +263 -0
  85. package/dist/runtime/dom/inspectTree.d.ts +40 -0
  86. package/dist/runtime/dom/inspectTree.js +134 -0
  87. package/dist/runtime/dom/inspectVariables.d.ts +33 -0
  88. package/dist/runtime/dom/inspectVariables.js +94 -0
  89. package/dist/runtime/dom/inspectWhyModel.d.ts +20 -0
  90. package/dist/runtime/dom/inspectWhyModel.js +134 -0
  91. package/dist/runtime/dom/layout.d.ts +5 -1
  92. package/dist/runtime/dom/layout.js +10 -3
  93. package/dist/runtime/dom/listenerPageScripts.d.ts +11 -5
  94. package/dist/runtime/dom/listenerPageScripts.js +95 -9
  95. package/dist/runtime/dom/listenerSummary.d.ts +4 -0
  96. package/dist/runtime/dom/listenerSummary.js +26 -9
  97. package/dist/runtime/dom/reactEventHelpers.d.ts +5 -0
  98. package/dist/runtime/dom/reactEventHelpers.js +12 -4
  99. package/dist/runtime/page/emulation.d.ts +20 -0
  100. package/dist/runtime/page/emulation.js +37 -0
  101. package/dist/telemetry/a11y.d.ts +10 -0
  102. package/dist/telemetry/a11y.js +78 -1
  103. package/dist/telemetry/console.d.ts +1 -0
  104. package/dist/telemetry/console.js +100 -5
  105. package/dist/telemetry/network.js +3 -1
  106. package/dist/types.d.ts +40 -0
  107. package/dist/ui/formatters/details.d.ts +8 -0
  108. package/dist/ui/formatters/details.js +59 -3
  109. package/dist/ui/formatters/dom.d.ts +2 -1
  110. package/dist/ui/formatters/dom.js +25 -9
  111. package/dist/ui/formatters/inspect.d.ts +39 -0
  112. package/dist/ui/formatters/inspect.js +596 -0
  113. package/dist/ui/formatters/installSkill.d.ts +11 -0
  114. package/dist/ui/formatters/installSkill.js +31 -0
  115. package/dist/ui/formatters/keyAttributes.d.ts +19 -0
  116. package/dist/ui/formatters/keyAttributes.js +84 -0
  117. package/dist/ui/formatters/layout.js +2 -2
  118. package/dist/ui/formatters/networkHeaders.d.ts +13 -0
  119. package/dist/ui/formatters/networkHeaders.js +23 -3
  120. package/dist/ui/formatters/networkList.d.ts +29 -1
  121. package/dist/ui/formatters/networkList.js +86 -20
  122. package/dist/ui/formatters/status.js +1 -1
  123. package/dist/ui/formatting.d.ts +9 -0
  124. package/dist/ui/formatting.js +6 -3
  125. package/dist/ui/messages/commands.d.ts +123 -7
  126. package/dist/ui/messages/commands.js +181 -10
  127. package/dist/ui/messages/networkMessages.d.ts +14 -0
  128. package/dist/ui/messages/networkMessages.js +18 -0
  129. package/dist/ui/messages/session.d.ts +14 -0
  130. package/dist/ui/messages/session.js +20 -0
  131. package/dist/utils/async.d.ts +9 -0
  132. package/dist/utils/async.js +17 -0
  133. package/dist/utils/color.d.ts +84 -0
  134. package/dist/utils/color.js +376 -0
  135. package/dist/utils/cssValues.d.ts +109 -0
  136. package/dist/utils/cssValues.js +236 -0
  137. package/dist/utils/selectorFilters.d.ts +12 -0
  138. package/dist/utils/selectorFilters.js +29 -0
  139. package/package.json +2 -1
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * What a DOM action changed on the page: whether it navigated (to a new
3
- * document or within the same one), which messages appeared, and whether it
4
- * had no visible effect at all. Costs one page script sent before the action
5
- * (not waited for: CDP runs it before the action's own scripts) and one read
3
+ * document or within the same one), which messages and elements appeared,
4
+ * whether it had no visible effect at all, and whether the page was still
5
+ * working on the result. Costs one page script sent before the action (not
6
+ * waited for: CDP runs it before the action's own scripts) and one read
6
7
  * after it, plus a second look 300 ms later when nothing seemed to happen.
7
- * Worst case, when the page does not answer (a navigation is pending), the
8
- * snapshot is given up after {@link START_TIMEOUT_MS} and each read after
9
- * {@link READ_TIMEOUT_MS}.
8
+ * Worst case, when the page does not answer (a navigation is pending, or a
9
+ * long script runs), the snapshot is given up after {@link START_TIMEOUT_MS}
10
+ * and each read after {@link READ_TIMEOUT_MS}.
10
11
  */
11
12
  import type { CDPConnection } from '../../connection/cdp.js';
12
- import type { ActionEffects, NewMessage, PageNavigation } from '../../ipc/protocol/domTypes.js';
13
+ import type { ActionEffects, NewMessage, PageNavigation, PendingChanges, ShownElement, TriggeredRequest } from '../../ipc/protocol/domTypes.js';
13
14
  import { type NavigationEvents } from './pageActivity.js';
14
15
  /** A message element as a page snapshot lists it */
15
16
  export interface SeenMessage {
@@ -19,6 +20,13 @@ export interface SeenMessage {
19
20
  element: string;
20
21
  }
21
22
  export type { NavigationEvents };
23
+ /** Signs that the page was still working at a read (same document only) */
24
+ export interface SettleSignals {
25
+ /** How long ago each recent burst of structural DOM changes was (ms, newest last) */
26
+ burstAges: number[];
27
+ /** A loading indicator shown since the action began, described */
28
+ loading: string | null;
29
+ }
22
30
  /** The page after the action */
23
31
  export interface ReadSnapshot {
24
32
  href: string;
@@ -29,6 +37,25 @@ export interface ReadSnapshot {
29
37
  /** Why "no effect" can't be claimed even without changes */
30
38
  uncertain?: string;
31
39
  messages: SeenMessage[];
40
+ /** Whether the page was still working (same document only) */
41
+ settle?: SettleSignals;
42
+ /** Elements the action showed (when asked for) */
43
+ shown?: ShownElement[];
44
+ }
45
+ /** What collecting saw of the page's work, for {@link pendingChanges} */
46
+ export interface PageWork {
47
+ /** Signals of the last read, when there was one in the same document */
48
+ settle?: SettleSignals;
49
+ /** The DOM kept changing in bursts over a second look ({@link domLooksBusy}) */
50
+ domChanging: boolean;
51
+ /** A read got no answer within its time (a long script) */
52
+ unresponsive: boolean;
53
+ /** A main-frame load was still pending */
54
+ navigating: boolean;
55
+ }
56
+ /** Effects, plus the page's work for deciding whether it had settled */
57
+ export interface CollectedEffects extends ActionEffects {
58
+ work?: PageWork;
32
59
  }
33
60
  /** What the action did besides changing the page, for the "no effect" decision */
34
61
  export interface OtherActivity {
@@ -55,6 +82,50 @@ export interface OtherActivity {
55
82
  * @returns New messages
56
83
  */
57
84
  export declare function newMessages(before: SeenMessage[], after: SeenMessage[], newDocument: boolean): NewMessage[];
85
+ /**
86
+ * Elements to report as shown: those whose text is not already reported as
87
+ * a new message, at most {@link MAX_SHOWN_ELEMENTS}, texts cut to
88
+ * {@link MAX_MESSAGE_LENGTH} characters.
89
+ *
90
+ * @param shown - Elements the page found shown by the action
91
+ * @param messages - New messages being reported
92
+ * @returns Elements to report
93
+ */
94
+ export declare function shownElements(shown: ShownElement[], messages: NewMessage[]): ShownElement[];
95
+ /**
96
+ * Whether a read's DOM looks busy, worth a second look: at least
97
+ * {@link BUSY_BURSTS} bursts of structural changes within
98
+ * {@link BUSY_WINDOW_MS}, the last within {@link BUSY_RECENT_MS}. Text-only
99
+ * changes (clocks) and style changes (animations) are not bursts.
100
+ *
101
+ * @param settle - Signals of the read
102
+ * @returns True when the DOM may still be changing
103
+ */
104
+ export declare function domLooksBusy(settle: SettleSignals | undefined): boolean;
105
+ /**
106
+ * Whether the DOM kept changing during the second look: at least
107
+ * {@link BUSY_BURSTS} new bursts within the time since the first read (a
108
+ * render that ends in two commits, or a poller updating once a second, does
109
+ * not count).
110
+ *
111
+ * @param settle - Signals of the second read
112
+ * @param sinceMs - Time since the first read
113
+ * @returns True when the DOM is still changing
114
+ */
115
+ export declare function domKeptChanging(settle: SettleSignals | undefined, sinceMs: number): boolean;
116
+ /**
117
+ * What the page was still working on when the action returned, or undefined
118
+ * when it looked settled: content requests (documents, fetch/XHR, scripts)
119
+ * still pending, a new document still loading, a loading indicator that
120
+ * appeared, a DOM still changing ({@link domKeptChanging}), or a page that
121
+ * did not answer (a long script). A result a timer renders later, with no
122
+ * DOM change before it, is not seen.
123
+ *
124
+ * @param work - What collecting saw
125
+ * @param requests - Requests the action triggered (with pending ones)
126
+ * @returns Pending work, or undefined
127
+ */
128
+ export declare function pendingChanges(work: PageWork, requests?: TriggeredRequest[]): PendingChanges | undefined;
58
129
  /**
59
130
  * How the page's location changed: a new document committed in the main
60
131
  * frame (also when it has the URL it had, as after a form POST that
@@ -72,8 +143,8 @@ export declare function pageNavigation(startHref: string | undefined, read: Read
72
143
  /**
73
144
  * Whether an action had no visible effect: the page was read before and
74
145
  * after in the same document, it counted no DOM change, nothing made the
75
- * check uncertain, and no navigation, message, request, dialog or new window
76
- * happened.
146
+ * check uncertain, and no navigation, message, shown element, request,
147
+ * dialog or new window happened.
77
148
  *
78
149
  * @param read - Page read after the action
79
150
  * @param effects - Navigation and messages found
@@ -81,16 +152,24 @@ export declare function pageNavigation(startHref: string | undefined, read: Read
81
152
  * @returns True to report `effect: "none"`
82
153
  */
83
154
  export declare function hadNoEffect(read: ReadSnapshot | undefined, effects: ActionEffects, activity: OtherActivity): boolean;
155
+ /** What collecting looks at besides navigation and messages */
156
+ export interface CollectOptions {
157
+ /** Dialogs the action opened */
158
+ dialogs: number;
159
+ /** Decide "no effect" (with a second look when nothing seemed to happen) */
160
+ detectNoEffect: boolean;
161
+ /** List the elements the action showed */
162
+ reportShown?: boolean;
163
+ /** Take a second look when the DOM looks busy, to tell whether it is still changing */
164
+ detectUnsettled?: boolean;
165
+ }
84
166
  /** Collects what changed, once the action and its wait are done */
85
167
  export interface ActionEffectsWatch {
86
168
  /**
87
- * @param options - Dialogs the action opened, and whether to decide "no effect"
88
- * @returns What changed
169
+ * @param options - Dialogs, and what to decide and list
170
+ * @returns What changed, and the page's work at the end
89
171
  */
90
- collect(options: {
91
- dialogs: number;
92
- detectNoEffect: boolean;
93
- }): Promise<ActionEffects>;
172
+ collect(options: CollectOptions): Promise<CollectedEffects>;
94
173
  /** Stop listening and stop the page's watch (always call) */
95
174
  dispose(): void;
96
175
  }
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * What a DOM action changed on the page: whether it navigated (to a new
3
- * document or within the same one), which messages appeared, and whether it
4
- * had no visible effect at all. Costs one page script sent before the action
5
- * (not waited for: CDP runs it before the action's own scripts) and one read
3
+ * document or within the same one), which messages and elements appeared,
4
+ * whether it had no visible effect at all, and whether the page was still
5
+ * working on the result. Costs one page script sent before the action (not
6
+ * waited for: CDP runs it before the action's own scripts) and one read
6
7
  * after it, plus a second look 300 ms later when nothing seemed to happen.
7
- * Worst case, when the page does not answer (a navigation is pending), the
8
- * snapshot is given up after {@link START_TIMEOUT_MS} and each read after
9
- * {@link READ_TIMEOUT_MS}.
8
+ * Worst case, when the page does not answer (a navigation is pending, or a
9
+ * long script runs), the snapshot is given up after {@link START_TIMEOUT_MS}
10
+ * and each read after {@link READ_TIMEOUT_MS}.
10
11
  */
11
12
  import { EFFECTS_READ_SCRIPT, EFFECTS_START_SCRIPT, EFFECTS_STOP_SCRIPT, } from './actionEffectsScripts.js';
12
13
  import { listenForActivity, } from './pageActivity.js';
@@ -16,6 +17,19 @@ import { getErrorMessage } from '../../utils/errors.js';
16
17
  const log = createLogger('dom');
17
18
  /** Messages reported per action */
18
19
  const MAX_NEW_MESSAGES = 3;
20
+ /** Shown elements reported per action */
21
+ const MAX_SHOWN_ELEMENTS = 3;
22
+ /**
23
+ * 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}
25
+ */
26
+ const BUSY_BURSTS = 2;
27
+ const BUSY_WINDOW_MS = 500;
28
+ 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) */
30
+ const STILL_CHANGING_RECHECK_MS = 250;
31
+ /** Resource types of pending requests that mean more content is coming */
32
+ const CONTENT_REQUEST_TYPES = new Set(['Document', 'XHR', 'Fetch', 'Script']);
19
33
  /** Longest message text reported */
20
34
  const MAX_MESSAGE_LENGTH = 120;
21
35
  /** How long collecting waits for the snapshot taken before the action */
@@ -90,6 +104,76 @@ function cutText(text) {
90
104
  return text;
91
105
  return `${characters.slice(0, MAX_MESSAGE_LENGTH - 1).join('')}…`;
92
106
  }
107
+ /**
108
+ * Elements to report as shown: those whose text is not already reported as
109
+ * a new message, at most {@link MAX_SHOWN_ELEMENTS}, texts cut to
110
+ * {@link MAX_MESSAGE_LENGTH} characters.
111
+ *
112
+ * @param shown - Elements the page found shown by the action
113
+ * @param messages - New messages being reported
114
+ * @returns Elements to report
115
+ */
116
+ export function shownElements(shown, messages) {
117
+ const reported = new Set(messages.map((message) => message.text));
118
+ return shown
119
+ .filter((element) => !reported.has(cutText(element.text)))
120
+ .slice(0, MAX_SHOWN_ELEMENTS)
121
+ .map((element) => ({ ...element, text: cutText(element.text) }));
122
+ }
123
+ /**
124
+ * Whether a read's DOM looks busy, worth a second look: at least
125
+ * {@link BUSY_BURSTS} bursts of structural changes within
126
+ * {@link BUSY_WINDOW_MS}, the last within {@link BUSY_RECENT_MS}. Text-only
127
+ * changes (clocks) and style changes (animations) are not bursts.
128
+ *
129
+ * @param settle - Signals of the read
130
+ * @returns True when the DOM may still be changing
131
+ */
132
+ export function domLooksBusy(settle) {
133
+ if (!settle)
134
+ return false;
135
+ const recent = settle.burstAges.filter((age) => age <= BUSY_WINDOW_MS);
136
+ return recent.length >= BUSY_BURSTS && Math.min(...recent) <= BUSY_RECENT_MS;
137
+ }
138
+ /**
139
+ * Whether the DOM kept changing during the second look: at least
140
+ * {@link BUSY_BURSTS} new bursts within the time since the first read (a
141
+ * render that ends in two commits, or a poller updating once a second, does
142
+ * not count).
143
+ *
144
+ * @param settle - Signals of the second read
145
+ * @param sinceMs - Time since the first read
146
+ * @returns True when the DOM is still changing
147
+ */
148
+ export function domKeptChanging(settle, sinceMs) {
149
+ if (!settle)
150
+ return false;
151
+ return settle.burstAges.filter((age) => age < sinceMs).length >= BUSY_BURSTS;
152
+ }
153
+ /**
154
+ * What the page was still working on when the action returned, or undefined
155
+ * when it looked settled: content requests (documents, fetch/XHR, scripts)
156
+ * still pending, a new document still loading, a loading indicator that
157
+ * appeared, a DOM still changing ({@link domKeptChanging}), or a page that
158
+ * did not answer (a long script). A result a timer renders later, with no
159
+ * DOM change before it, is not seen.
160
+ *
161
+ * @param work - What collecting saw
162
+ * @param requests - Requests the action triggered (with pending ones)
163
+ * @returns Pending work, or undefined
164
+ */
165
+ export function pendingChanges(work, requests = []) {
166
+ const settle = work.settle;
167
+ const pendingRequests = requests.filter((request) => request.pending && CONTENT_REQUEST_TYPES.has(request.resourceType ?? '')).length;
168
+ const pending = {
169
+ ...(pendingRequests > 0 && { requests: pendingRequests }),
170
+ ...(work.navigating && { navigation: true }),
171
+ ...(settle?.loading && { loading: settle.loading }),
172
+ ...(work.domChanging && { domChanging: true }),
173
+ ...(work.unresponsive && { busy: true }),
174
+ };
175
+ return Object.keys(pending).length > 0 ? pending : undefined;
176
+ }
93
177
  /**
94
178
  * How the page's location changed: a new document committed in the main
95
179
  * frame (also when it has the URL it had, as after a form POST that
@@ -122,8 +206,8 @@ export function pageNavigation(startHref, read, events) {
122
206
  /**
123
207
  * Whether an action had no visible effect: the page was read before and
124
208
  * after in the same document, it counted no DOM change, nothing made the
125
- * check uncertain, and no navigation, message, request, dialog or new window
126
- * happened.
209
+ * check uncertain, and no navigation, message, shown element, request,
210
+ * dialog or new window happened.
127
211
  *
128
212
  * @param read - Page read after the action
129
213
  * @param effects - Navigation and messages found
@@ -137,6 +221,7 @@ export function hadNoEffect(read, effects, activity) {
137
221
  read.uncertain === undefined &&
138
222
  effects.navigation === undefined &&
139
223
  (effects.messages ?? []).length === 0 &&
224
+ (effects.shown ?? []).length === 0 &&
140
225
  activity.requests === 0 &&
141
226
  activity.dialogs === 0 &&
142
227
  !activity.opened);
@@ -155,6 +240,7 @@ export function watchActionEffects(cdp) {
155
240
  listener: listenForActivity(cdp),
156
241
  start: evaluate(cdp, EFFECTS_START_SCRIPT),
157
242
  stopConfirmed: false,
243
+ unresponsive: false,
158
244
  };
159
245
  return {
160
246
  collect: (options) => collectEffects(watch, options),
@@ -163,28 +249,82 @@ export function watchActionEffects(cdp) {
163
249
  }
164
250
  /**
165
251
  * What changed: the navigation (from CDP events even without a snapshot),
166
- * new messages and, when asked, "no effect" after a second look.
252
+ * new messages, shown elements when asked, "no effect" after a second look
253
+ * when asked, and the page's work at the end.
167
254
  *
168
255
  * @param watch - The action's watch
169
- * @param options - Dialogs the action opened, and whether to decide "no effect"
256
+ * @param options - Dialogs, and what to decide and list
170
257
  * @returns What changed
171
258
  */
172
259
  async function collectEffects(watch, options) {
173
- const start = await raceTimeout(watch.start, START_TIMEOUT_MS);
174
- if (!start)
175
- return effectsOf(undefined, undefined, watch.listener.events);
176
- let snapshot = await readPage(watch, false);
260
+ const start = await awaitStart(watch);
261
+ if (!start) {
262
+ return { ...effectsOf(undefined, undefined, watch.listener.events), work: pageWork(watch) };
263
+ }
264
+ const reportShown = options.reportShown === true;
265
+ let snapshot = await readPage(watch, { stop: false, reportShown });
177
266
  let effects = effectsOf(start, snapshot, watch.listener.events);
178
267
  const quiet = () => hadNoEffect(snapshot, effects, { ...watch.listener.activity(), dialogs: options.dialogs });
179
- if (!options.detectNoEffect || !quiet())
180
- return effects;
181
- await delay(NO_EFFECT_RECHECK_MS);
182
- snapshot = await readPage(watch, true);
183
- effects = effectsOf(start, snapshot, watch.listener.events);
184
- return quiet() ? { ...effects, effect: 'none' } : effects;
268
+ if (options.detectNoEffect && quiet()) {
269
+ await delay(NO_EFFECT_RECHECK_MS);
270
+ snapshot = await readPage(watch, { stop: true, reportShown });
271
+ effects = effectsOf(start, snapshot, watch.listener.events);
272
+ if (quiet())
273
+ effects = { ...effects, effect: 'none' };
274
+ }
275
+ const domChanging = options.detectUnsettled === true && (await stillChanging(watch, snapshot));
276
+ return { ...effects, work: pageWork(watch, snapshot, domChanging) };
277
+ }
278
+ /**
279
+ * The snapshot taken before the action, waiting at most
280
+ * {@link START_TIMEOUT_MS}; a snapshot still unanswered then (and no
281
+ * navigation pending) marks the page unresponsive.
282
+ *
283
+ * @param watch - The action's watch
284
+ * @returns The snapshot, or undefined
285
+ */
286
+ async function awaitStart(watch) {
287
+ const started = await raceTimeout(watch.start.then((value) => ({ value })), START_TIMEOUT_MS);
288
+ if (!started)
289
+ watch.unresponsive = !watch.listener.navigationPending();
290
+ return started?.value;
291
+ }
292
+ /**
293
+ * Whether the DOM is still changing: when the last read looked busy
294
+ * ({@link domLooksBusy}), a second read {@link STILL_CHANGING_RECHECK_MS}
295
+ * later must see it keep changing ({@link domKeptChanging}).
296
+ *
297
+ * @param watch - The action's watch
298
+ * @param snapshot - Last read, if any
299
+ * @returns True when the DOM kept changing
300
+ */
301
+ async function stillChanging(watch, snapshot) {
302
+ if (!domLooksBusy(snapshot?.settle))
303
+ return false;
304
+ const firstRead = Date.now();
305
+ await delay(STILL_CHANGING_RECHECK_MS);
306
+ const recheck = await readPage(watch, { stop: false, reportShown: false });
307
+ return domKeptChanging(recheck?.settle, Date.now() - firstRead);
308
+ }
309
+ /**
310
+ * The page's work as the last read and the CDP events saw it.
311
+ *
312
+ * @param watch - The action's watch
313
+ * @param snapshot - Last read, if any
314
+ * @param domChanging - Whether the DOM kept changing over a second look
315
+ * @returns Page work
316
+ */
317
+ function pageWork(watch, snapshot, domChanging = false) {
318
+ return {
319
+ ...(snapshot?.settle && { settle: snapshot.settle }),
320
+ domChanging,
321
+ unresponsive: watch.unresponsive,
322
+ navigating: watch.listener.navigationPending(),
323
+ };
185
324
  }
186
325
  /**
187
- * Navigation and new messages from the snapshots and CDP events.
326
+ * Navigation, new messages and shown elements from the snapshots and CDP
327
+ * events.
188
328
  *
189
329
  * @param start - Snapshot before the action, if taken
190
330
  * @param snapshot - Read after the action, if taken
@@ -194,26 +334,32 @@ async function collectEffects(watch, options) {
194
334
  function effectsOf(start, snapshot, events) {
195
335
  const navigation = pageNavigation(start?.href, snapshot, events);
196
336
  const messages = start && snapshot ? newMessages(start.messages, snapshot.messages, snapshot.fresh) : [];
337
+ const shown = shownElements(snapshot?.shown ?? [], messages);
197
338
  return {
198
339
  ...(navigation && { navigation }),
199
340
  ...(messages.length > 0 && { messages }),
341
+ ...(shown.length > 0 && { shown }),
200
342
  };
201
343
  }
202
344
  /**
203
345
  * Read the page after the action, unless a main-frame load is pending (the
204
346
  * read would wait for the new page). A stopping read that answered stops the
205
- * page's watch, so disposing need not.
347
+ * page's watch, so disposing need not. A read that got no answer in time
348
+ * marks the page unresponsive.
206
349
  *
207
350
  * @param watch - The action's watch
208
- * @param stop - Also stop the page's watch
351
+ * @param options - Also stop the page's watch; list shown elements
209
352
  * @returns The read, or undefined
210
353
  */
211
- async function readPage(watch, stop) {
354
+ async function readPage(watch, options) {
212
355
  if (watch.listener.navigationPending())
213
356
  return undefined;
214
- const expression = `(${EFFECTS_READ_SCRIPT})(${stop})`;
215
- const snapshot = await raceTimeout(evaluate(watch.cdp, expression), READ_TIMEOUT_MS);
216
- if (stop && snapshot)
357
+ const expression = `(${EFFECTS_READ_SCRIPT})(${options.stop}, ${options.reportShown})`;
358
+ const answer = await raceTimeout(evaluate(watch.cdp, expression).then((value) => ({ value })), READ_TIMEOUT_MS);
359
+ if (!answer)
360
+ watch.unresponsive = !watch.listener.navigationPending();
361
+ const snapshot = answer?.value;
362
+ if (options.stop && snapshot)
217
363
  watch.stopConfirmed = true;
218
364
  return snapshot;
219
365
  }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Page scripts behind the "what changed" part of DOM action results: one
3
3
  * snapshot before the action ({@link EFFECTS_START_SCRIPT}) and one read after
4
- * it ({@link EFFECTS_READ_SCRIPT}).
4
+ * it ({@link EFFECTS_READ_SCRIPT}), plus the snapshot a hover takes of what
5
+ * is hidden around its target ({@link REVEAL_SNAPSHOT_JS}).
5
6
  */
6
7
  /**
7
8
  * Page-side test whether an element is part of a message's chrome rather
@@ -10,12 +11,43 @@
10
11
  * not `closeable`, `enclosed` or `disclosure`).
11
12
  */
12
13
  export declare const MESSAGE_CHROME_JS = "(node) => {\n const closer = /(^|[-_\\s])(close|dismiss)($|[-_\\s])/i;\n return node.getAttribute('aria-hidden') === 'true' ||\n /^(button|script|style|template)$/.test(node.localName) ||\n node.getAttribute('role') === 'button' ||\n closer.test(node.getAttribute('class') || '') ||\n closer.test(node.getAttribute('aria-label') || '');\n}";
14
+ /**
15
+ * Page-side snapshot a hover takes right before the mouse moves (called by
16
+ * the click script with the hovered element): the elements around it (its
17
+ * parent and everything in it) and the tooltips, menus, listboxes, dialogs
18
+ * and popovers anywhere on the page that are hidden, at most
19
+ * {@link MAX_REVEAL_CANDIDATES} looked at within {@link REVEAL_BUDGET_MS}.
20
+ * They are kept by identity in the action's watch, so its read can tell
21
+ * which of them the hover revealed, also through CSS `:hover` rules that
22
+ * change no DOM, and elements moving in the page can't pass for revealed
23
+ * ones. Does nothing without a running watch (another frame).
24
+ */
25
+ export declare const REVEAL_SNAPSHOT_JS: string;
26
+ /**
27
+ * Page-side list of the elements an action showed: elements added during
28
+ * the watch inside the target's container ({@link NEAR_SCOPE_JS}) or, anywhere,
29
+ * popups and messages (tooltip, menu, listbox, dialog, alert and status
30
+ * roles, `aria-live`, message-like classes), plus, after a hover, the
31
+ * elements hidden before it that are shown now ({@link REVEAL_SNAPSHOT_JS}).
32
+ * Background widgets elsewhere on the page do not count. Only shown ones
33
+ * with visible text count, the outermost of nested ones, and not those
34
+ * whose text a removed element had (a re-render). At most
35
+ * {@link MAX_SHOWN}, within {@link SHOWN_BUDGET_MS}.
36
+ */
37
+ export declare const SHOWN_ELEMENTS_JS: string;
13
38
  /**
14
39
  * Page-side test whether a mutation is only focus/hover churn: a class
15
40
  * change on an element the action's events hit (`targets`) that only adds
16
41
  * or removes classes containing "focus" or "hover".
17
42
  */
18
43
  export declare const CHURN_ONLY_JS = "(record, targets) => {\n if (record.type !== 'attributes' || record.attributeName !== 'class' || !targets.has(record.target)) return false;\n const tokens = (text) => new Set((text || '').split(/\\s+/).filter(Boolean));\n const before = tokens(record.oldValue);\n const after = tokens(record.target.getAttribute('class'));\n const changed = [...before].filter((c) => !after.has(c)).concat([...after].filter((c) => !before.has(c)));\n return changed.every((c) => /focus|hover/i.test(c));\n}";
44
+ /**
45
+ * Page-side test whether a mutation changes the page's structure or state
46
+ * rather than only animating it: elements added or removed, or an attribute
47
+ * other than `style` changed. Text-only changes (clocks, counters) and style
48
+ * changes (script-driven animations) do not count.
49
+ */
50
+ export declare const STRUCTURAL_CHANGE_JS = "(record) => {\n if (record.type === 'attributes') return record.attributeName !== 'style';\n if (record.type !== 'childList') return false;\n const element = (node) => node.nodeType === 1;\n return Array.from(record.addedNodes).some(element) || Array.from(record.removedNodes).some(element);\n}";
19
51
  /**
20
52
  * Page-side reason why "no effect" can't be claimed even without DOM
21
53
  * changes, or undefined: `clipboard` (a copy or cut happened), `no-event`
@@ -29,22 +61,28 @@ export declare const CHURN_ONLY_JS = "(record, targets) => {\n if (record.type
29
61
  export declare const UNCERTAIN_JS = "(state, active) => {\n if (state.copied) return 'clipboard';\n if (state.targets.size === 0) return 'no-event';\n const controls = /^(input|select|textarea|option|label|canvas|video|audio|iframe|embed|object)$/;\n for (const node of state.path) {\n if (controls.test(node.localName) || node.isContentEditable) return 'control';\n if (node.hasAttribute('popovertarget') || node.hasAttribute('commandfor')) return 'control';\n if (node.localName === 'a' && node.hasAttribute('href')) {\n if (node.hasAttribute('download') || (node.target && node.target !== '_self')) return 'new-window';\n if (!/^https?:$/.test(node.protocol)) return 'external-link';\n }\n }\n for (const node of state.targets) if (node.localName.includes('-') && !node.shadowRoot) return 'closed-shadow';\n const plain = (node) => /^(body|button|summary)$/.test(node.localName) ||\n (node.localName === 'a' && node.hasAttribute('href')) ||\n (node.localName === 'input' && /^(button|submit|reset)$/i.test(node.type || ''));\n if (active && active !== state.focus && !plain(active)) return 'focus';\n return undefined;\n}";
30
62
  /**
31
63
  * Snapshot before an action, left in `window.__bdgEffects`: the messages
32
- * shown, and a MutationObserver (on the document, its open shadow roots and
33
- * any shadow root attached while watching, which also counts as a change)
34
- * counting changes other than {@link CHURN_ONLY_JS}. Capture listeners
35
- * record which elements the action's events reached, copy/cut events, and
36
- * the scroll position at the first press (a click scrolls its target into
37
- * view first). The watch stops itself after {@link MAX_WATCH_MS}, so a
38
- * snapshot that ran late (after a navigation, with nobody reading it)
39
- * leaves nothing behind. Evaluates to the URL and the messages.
64
+ * and loading indicators shown, and a MutationObserver (on the document,
65
+ * its open shadow roots and any shadow root attached while watching, which
66
+ * also counts as a change) counting changes other than
67
+ * {@link CHURN_ONLY_JS}, keeping the elements added and removed and the
68
+ * times of {@link STRUCTURAL_CHANGE_JS} bursts. Capture listeners record
69
+ * which elements the action's events reached, the first key press's target,
70
+ * copy/cut events and the scroll position at the first press (a click
71
+ * scrolls its target into view first). The watch stops itself after
72
+ * {@link MAX_WATCH_MS}, so a snapshot that ran late (after a navigation,
73
+ * with nobody reading it) leaves nothing behind. Evaluates to the URL and
74
+ * the messages.
40
75
  */
41
76
  export declare const EFFECTS_START_SCRIPT: string;
42
77
  /**
43
- * Read after an action (call with `true` to also stop watching): the URL,
44
- * the messages shown and, when the snapshot is still there (same document),
45
- * the number of changes counted (plus one when the page scrolled after the
46
- * press) and why "no effect" could not be claimed ({@link UNCERTAIN_JS}).
47
- * `fresh` means a new document (everything shown is new).
78
+ * Read after an action, called with `(stop, shown)`: `stop` also stops
79
+ * watching, `shown` lists the elements the action showed
80
+ * ({@link SHOWN_ELEMENTS_JS}). Returns the URL, the messages shown and,
81
+ * when the snapshot is still there (same document), the number of changes
82
+ * counted (plus one when the page scrolled after the press), why "no
83
+ * effect" could not be claimed ({@link UNCERTAIN_JS}) and whether the page
84
+ * is still working ({@link SETTLE_JS}). `fresh` means a new document
85
+ * (everything shown is new).
48
86
  */
49
87
  export declare const EFFECTS_READ_SCRIPT: string;
50
88
  /** Stops the watch {@link EFFECTS_START_SCRIPT} left (when no read stopped it) */