browser-commander 0.17.1 → 0.18.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 (70) hide show
  1. package/.jscpd-baseline.json +7 -18
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +84 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +4 -1
  6. package/scripts/version-and-commit.mjs +13 -2
  7. package/src/bindings.js +13 -1
  8. package/src/browser/connector.js +15 -3
  9. package/src/browser/launcher.js +14 -2
  10. package/src/browser/navigation.js +63 -0
  11. package/src/core/dialog-manager.js +48 -0
  12. package/src/core/navigation-manager.js +83 -87
  13. package/src/core/navigation-readiness.js +133 -0
  14. package/src/core/readiness.js +478 -0
  15. package/src/downloads/attach.js +58 -0
  16. package/src/downloads/destination.js +125 -0
  17. package/src/downloads/manager.js +453 -0
  18. package/src/downloads/naming.js +246 -0
  19. package/src/downloads/sources.js +188 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +72 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +323 -0
  29. package/src/traces/observers.js +246 -0
  30. package/src/traces/page-capture.js +365 -0
  31. package/src/traces/reader.js +226 -0
  32. package/src/traces/recorder.js +437 -0
  33. package/src/traces/redaction.js +231 -0
  34. package/src/traces/schema.js +165 -0
  35. package/src/traces/viewer.js +302 -0
  36. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  37. package/tests/e2e/downloads.e2e.test.js +188 -0
  38. package/tests/e2e/traces.e2e.test.js +457 -0
  39. package/tests/helpers/click-fixtures.js +62 -0
  40. package/tests/helpers/download-fixtures.js +125 -0
  41. package/tests/helpers/download-server.js +109 -0
  42. package/tests/helpers/e2e-browser.js +67 -0
  43. package/tests/helpers/file-modes.js +31 -0
  44. package/tests/helpers/fixture-server.js +56 -0
  45. package/tests/helpers/mocks.js +63 -66
  46. package/tests/helpers/readiness-server.js +96 -0
  47. package/tests/helpers/temp-directory.js +44 -0
  48. package/tests/helpers/trace-fixtures.js +198 -0
  49. package/tests/helpers/trace-server.js +117 -0
  50. package/tests/unit/core/dialog-manager.test.js +118 -10
  51. package/tests/unit/core/navigation-manager.test.js +162 -0
  52. package/tests/unit/core/readiness.test.js +517 -0
  53. package/tests/unit/downloads/attach.test.js +220 -0
  54. package/tests/unit/downloads/destination.test.js +114 -0
  55. package/tests/unit/downloads/manager.test.js +354 -0
  56. package/tests/unit/downloads/naming.test.js +174 -0
  57. package/tests/unit/downloads/store.test.js +215 -0
  58. package/tests/unit/interactions/click-activation.test.js +283 -0
  59. package/tests/unit/interactions/click-result.test.js +90 -0
  60. package/tests/unit/interactions/click.test.js +409 -200
  61. package/tests/unit/scripts/check-release-format.test.js +4 -1
  62. package/tests/unit/scripts/push-failure-classifier.test.js +153 -0
  63. package/tests/unit/scripts/push-with-rebase-retry.test.js +302 -0
  64. package/tests/unit/tests/tracing.test.js +299 -0
  65. package/tests/unit/traces/bundle.test.js +260 -0
  66. package/tests/unit/traces/reader.test.js +208 -0
  67. package/tests/unit/traces/recorder.test.js +562 -0
  68. package/tests/unit/traces/redaction.test.js +170 -0
  69. package/tests/unit/traces/schema.test.js +92 -0
  70. package/tests/unit/traces/viewer.test.js +289 -0
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Orthogonal click activation options.
3
+ *
4
+ * `noAutoScroll: true` used to be translated into Playwright's `{ force: true }`,
5
+ * which skips actionability checks but still scrolls the element into view. The
6
+ * option therefore promised something the engine never delivered. These three
7
+ * axes are independent and each one means exactly what it says:
8
+ *
9
+ * - `activation`: how the click is delivered ('pointer' | 'dom')
10
+ * - `scroll`: what may happen to the scroll position ('auto' | 'preserve' | 'none')
11
+ * - `actionability`: whether engine pre-checks are skipped ('normal' | 'force')
12
+ */
13
+
14
+ import { TIMING } from '../core/constants.js';
15
+ import { createDeadline } from '../core/readiness.js';
16
+
17
+ /** How the click is delivered to the element. */
18
+ export const CLICK_ACTIVATION = Object.freeze({
19
+ /** Real pointer input at the element's click point. */
20
+ POINTER: 'pointer',
21
+ /** `HTMLElement.click()` - an untrusted event that skips pointer handlers. */
22
+ DOM: 'dom',
23
+ });
24
+
25
+ /** What the click is allowed to do to the scroll position. */
26
+ export const CLICK_SCROLL = Object.freeze({
27
+ /** Let the engine scroll the element into view (default). */
28
+ AUTO: 'auto',
29
+ /** Allow scrolling, then restore the original scroll position. */
30
+ PRESERVE: 'preserve',
31
+ /** Never scroll; fail if the click cannot be delivered without scrolling. */
32
+ NONE: 'none',
33
+ });
34
+
35
+ /** Whether the engine's actionability pre-checks run. */
36
+ export const CLICK_ACTIONABILITY = Object.freeze({
37
+ NORMAL: 'normal',
38
+ /** Skip engine pre-checks. Does NOT disable scrolling. */
39
+ FORCE: 'force',
40
+ });
41
+
42
+ /**
43
+ * Thrown when `scroll: 'none'` cannot be honored.
44
+ *
45
+ * Failing loudly is the point: the previous behavior scrolled anyway and
46
+ * reported success, so callers who needed the viewport to stay put had no way
47
+ * to find out that it had moved.
48
+ */
49
+ export class ScrollConstraintError extends Error {
50
+ /**
51
+ * @param {string} message - What made the constraint impossible to honor
52
+ * @param {Object} [detail] - Structured detail about the element and viewport
53
+ */
54
+ constructor(message, detail = {}) {
55
+ super(message);
56
+ this.name = 'ScrollConstraintError';
57
+ this.detail = detail;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Resolve the activation options, applying the `noAutoScroll` compatibility
63
+ * mapping.
64
+ *
65
+ * @param {Object} options - Raw caller options
66
+ * @param {string} [options.activation] - Activation mode
67
+ * @param {string} [options.scroll] - Scroll policy
68
+ * @param {string} [options.actionability] - Actionability policy
69
+ * @param {boolean} [options.noAutoScroll] - Deprecated alias for `scroll: 'none'`
70
+ * @param {Function} [options.log] - Logger used for the deprecation notice
71
+ * @returns {{activation: string, scroll: string, actionability: string, deprecations: string[]}} Resolved options
72
+ */
73
+ export function resolveActivationOptions(options = {}) {
74
+ const { activation, scroll, actionability, noAutoScroll, log } = options;
75
+ const deprecations = [];
76
+
77
+ let resolvedScroll = scroll;
78
+
79
+ if (noAutoScroll !== undefined) {
80
+ deprecations.push(
81
+ 'noAutoScroll is deprecated; use scroll: "none" (no scrolling at all) ' +
82
+ 'or actionability: "force" (skip engine pre-checks, scrolling still allowed)'
83
+ );
84
+ if (resolvedScroll === undefined) {
85
+ resolvedScroll = noAutoScroll ? CLICK_SCROLL.NONE : CLICK_SCROLL.AUTO;
86
+ }
87
+ }
88
+
89
+ const resolved = {
90
+ activation: activation ?? CLICK_ACTIVATION.POINTER,
91
+ scroll: resolvedScroll ?? CLICK_SCROLL.AUTO,
92
+ actionability: actionability ?? CLICK_ACTIONABILITY.NORMAL,
93
+ deprecations,
94
+ };
95
+
96
+ assertOneOf('activation', resolved.activation, CLICK_ACTIVATION);
97
+ assertOneOf('scroll', resolved.scroll, CLICK_SCROLL);
98
+ assertOneOf('actionability', resolved.actionability, CLICK_ACTIONABILITY);
99
+
100
+ if (deprecations.length > 0 && log?.debug) {
101
+ deprecations.forEach((message) => log.debug(() => `⚠️ ${message}`));
102
+ }
103
+
104
+ return resolved;
105
+ }
106
+
107
+ /**
108
+ * Start a click action: open one deadline and resolve the activation options.
109
+ *
110
+ * Both `clickElement` and `clickButton` need a clock and the same option
111
+ * resolution, and both must do it *before* anything can throw so that
112
+ * failures still report how long they took. The clock is the deadline's, so
113
+ * every later step - dispatch, probe, verification - measures against one
114
+ * monotonic budget rather than each starting a wall-clock timer of its own.
115
+ *
116
+ * @param {Object} options - Raw caller options, as accepted by {@link resolveActivationOptions}
117
+ * @param {number} [options.timeout] - Total budget for the whole click
118
+ * @returns {{deadline: Object, elapsed: Function, activationOptions: Object}} Deadline, elapsed-ms reader and resolved options
119
+ */
120
+ export function beginClickAction(options = {}) {
121
+ const { timeout = TIMING.VERIFICATION_TIMEOUT } = options;
122
+ const deadline = createDeadline({ timeout });
123
+
124
+ return {
125
+ deadline,
126
+ elapsed: () => deadline.elapsedMs(),
127
+ activationOptions: resolveActivationOptions(options),
128
+ };
129
+ }
130
+
131
+ function assertOneOf(name, value, allowed) {
132
+ const values = Object.values(allowed);
133
+ if (!values.includes(value)) {
134
+ throw new Error(
135
+ `${name} must be one of ${values.map((v) => `'${v}'`).join(', ')}, got '${value}'`
136
+ );
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Measure an element's click point and whether it is reachable right now.
142
+ *
143
+ * @param {Object} options - Configuration options
144
+ * @param {Object} options.adapter - Engine adapter
145
+ * @param {Object} options.locatorOrElement - Element or locator
146
+ * @returns {Promise<Object>} Geometry and hit-test result
147
+ */
148
+ export function measureClickPoint(options = {}) {
149
+ const { adapter, locatorOrElement } = options;
150
+
151
+ return adapter.evaluateOnElement(locatorOrElement, (el) => {
152
+ const rect = el.getBoundingClientRect();
153
+ const x = rect.left + rect.width / 2;
154
+ const y = rect.top + rect.height / 2;
155
+ const inViewport =
156
+ rect.width > 0 &&
157
+ rect.height > 0 &&
158
+ x >= 0 &&
159
+ y >= 0 &&
160
+ x <= window.innerWidth &&
161
+ y <= window.innerHeight;
162
+ const hit = inViewport ? document.elementFromPoint(x, y) : null;
163
+
164
+ return {
165
+ x,
166
+ y,
167
+ width: rect.width,
168
+ height: rect.height,
169
+ top: rect.top,
170
+ left: rect.left,
171
+ viewport: { width: window.innerWidth, height: window.innerHeight },
172
+ scroll: { x: window.scrollX, y: window.scrollY },
173
+ inViewport,
174
+ hitsTarget: Boolean(hit && (hit === el || el.contains(hit))),
175
+ };
176
+ });
177
+ }
178
+
179
+ /**
180
+ * Read the current window scroll position.
181
+ *
182
+ * @param {Object} adapter - Engine adapter
183
+ * @returns {Promise<{x: number, y: number}>} Scroll offsets
184
+ */
185
+ export async function readScrollPosition(adapter) {
186
+ if (typeof adapter?.evaluateOnPage !== 'function') {
187
+ return null;
188
+ }
189
+ try {
190
+ return await adapter.evaluateOnPage(() => ({
191
+ x: window.scrollX,
192
+ y: window.scrollY,
193
+ }));
194
+ } catch {
195
+ // Scroll position is evidence, not a precondition - never fail a click
196
+ // because we could not read it.
197
+ return null;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Restore a previously captured scroll position.
203
+ *
204
+ * @param {Object} adapter - Engine adapter
205
+ * @param {{x: number, y: number}} position - Position to restore
206
+ * @returns {Promise<void>} Resolves once restored
207
+ */
208
+ export async function restoreScrollPosition(adapter, position) {
209
+ if (!position || typeof adapter?.evaluateOnPage !== 'function') {
210
+ return;
211
+ }
212
+ // `evaluateOnPage` takes an argument *list*, so the position has to be
213
+ // wrapped - passing the object directly made Playwright try to spread it.
214
+ await adapter.evaluateOnPage(
215
+ (pos) => window.scrollTo(pos.x, pos.y),
216
+ [position]
217
+ );
218
+ }
219
+
220
+ /**
221
+ * Deliver a click according to the resolved activation options.
222
+ *
223
+ * @param {Object} options - Configuration options
224
+ * @param {Object} options.page - Browser page object
225
+ * @param {Object} options.adapter - Engine adapter
226
+ * @param {Object} options.locatorOrElement - Element or locator to click
227
+ * @param {Object} options.activationOptions - Result of {@link resolveActivationOptions}
228
+ * @param {Function} [options.log] - Logger instance
229
+ * @returns {Promise<{mode: string, scrollBefore: Object|null, scrollAfter: Object|null, point: Object|null}>} Dispatch detail
230
+ * @throws {ScrollConstraintError} When `scroll: 'none'` cannot be honored
231
+ */
232
+ export async function dispatchClick(options = {}) {
233
+ const {
234
+ page,
235
+ adapter,
236
+ locatorOrElement,
237
+ activationOptions,
238
+ log = { debug: () => {} },
239
+ } = options;
240
+
241
+ const { activation, scroll, actionability } = activationOptions;
242
+
243
+ if (activation === CLICK_ACTIVATION.DOM) {
244
+ log.debug(
245
+ () =>
246
+ '🖱️ Dispatching DOM activation (HTMLElement.click(); untrusted event, no scrolling)'
247
+ );
248
+ const scrollBefore = await readScrollPosition(adapter);
249
+ await adapter.evaluateOnElement(locatorOrElement, (el) => el.click());
250
+ return {
251
+ mode: CLICK_ACTIVATION.DOM,
252
+ scrollBefore,
253
+ scrollAfter: await readScrollPosition(adapter),
254
+ point: null,
255
+ };
256
+ }
257
+
258
+ if (scroll === CLICK_SCROLL.NONE) {
259
+ return dispatchPointerWithoutScrolling({
260
+ page,
261
+ adapter,
262
+ locatorOrElement,
263
+ log,
264
+ });
265
+ }
266
+
267
+ const scrollBefore = await readScrollPosition(adapter);
268
+ const engineOptions =
269
+ actionability === CLICK_ACTIONABILITY.FORCE ? { force: true } : {};
270
+ await adapter.click(locatorOrElement, engineOptions);
271
+
272
+ if (scroll === CLICK_SCROLL.PRESERVE) {
273
+ await restoreScrollPosition(adapter, scrollBefore);
274
+ }
275
+
276
+ return {
277
+ mode: CLICK_ACTIVATION.POINTER,
278
+ scrollBefore,
279
+ scrollAfter: await readScrollPosition(adapter),
280
+ point: null,
281
+ };
282
+ }
283
+
284
+ async function dispatchPointerWithoutScrolling(options) {
285
+ const { page, adapter, locatorOrElement, log } = options;
286
+
287
+ const point = await measureClickPoint({ adapter, locatorOrElement });
288
+
289
+ if (!point.inViewport) {
290
+ throw new ScrollConstraintError(
291
+ 'scroll: "none" was requested but the element is outside the viewport, ' +
292
+ 'so a real pointer click cannot reach it without scrolling. Use ' +
293
+ 'scroll: "preserve" to scroll and restore, scroll: "auto" to allow ' +
294
+ 'scrolling, or activation: "dom" to dispatch an untrusted click.',
295
+ point
296
+ );
297
+ }
298
+
299
+ if (!point.hitsTarget) {
300
+ throw new ScrollConstraintError(
301
+ 'scroll: "none" was requested but another element covers the target at ' +
302
+ 'its click point, so a real pointer click would hit the wrong element.',
303
+ point
304
+ );
305
+ }
306
+
307
+ log.debug(
308
+ () =>
309
+ `🖱️ Pointer click at (${Math.round(point.x)}, ${Math.round(point.y)}) without scrolling`
310
+ );
311
+
312
+ const scrollBefore = { x: point.scroll.x, y: point.scroll.y };
313
+ await page.mouse.click(point.x, point.y);
314
+ const scrollAfter = await readScrollPosition(adapter);
315
+
316
+ return { mode: CLICK_ACTIVATION.POINTER, scrollBefore, scrollAfter, point };
317
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Truthful result model for click operations.
3
+ *
4
+ * The old model answered every click with two booleans, and both of them were
5
+ * optimistic: a button that did nothing still reported `verified: true`. This
6
+ * model separates three questions that used to be conflated - did we dispatch
7
+ * the click, did the page react, and how did the operation end - and records
8
+ * the evidence behind each answer.
9
+ */
10
+
11
+ import { randomUUID } from 'node:crypto';
12
+
13
+ /** How a click operation ended. */
14
+ export const CLICK_STATUS = Object.freeze({
15
+ /** Click dispatched and its effect was confirmed. */
16
+ SUCCEEDED: 'succeeded',
17
+ /** Click could not be dispatched, or dispatch provably failed. */
18
+ FAILED: 'failed',
19
+ /** The operation ran out of its budget. */
20
+ TIMED_OUT: 'timed_out',
21
+ /** Navigation or an explicit stop cut the operation short. */
22
+ INTERRUPTED: 'interrupted',
23
+ /** Click dispatched, but nothing confirmed or denied that it had an effect. */
24
+ UNVERIFIED: 'unverified',
25
+ });
26
+
27
+ /** What the page did in response to the click. */
28
+ export const CLICK_EFFECT = Object.freeze({
29
+ /** Observed evidence that the click did something. */
30
+ CONFIRMED: 'confirmed',
31
+ /** No evidence either way. */
32
+ NOT_OBSERVED: 'not-observed',
33
+ /** Observed evidence that the click did NOT do what was expected. */
34
+ CONTRADICTED: 'contradicted',
35
+ });
36
+
37
+ /**
38
+ * Build one piece of evidence for a click or readiness decision.
39
+ *
40
+ * @param {string} type - Evidence kind, e.g. 'element-state' or 'navigation'
41
+ * @param {Object} [detail] - Arbitrary structured detail
42
+ * @returns {{type: string, detail: Object}} Evidence entry
43
+ */
44
+ export function evidence(type, detail = {}) {
45
+ return { type, detail };
46
+ }
47
+
48
+ /**
49
+ * Build a click result, deriving the legacy booleans conservatively.
50
+ *
51
+ * `verified` is deliberately derived from `effect === 'confirmed'` rather than
52
+ * from "nothing went wrong", which is what made the old value meaningless.
53
+ *
54
+ * @param {Object} options - Configuration options
55
+ * @param {string} options.status - One of {@link CLICK_STATUS}
56
+ * @param {boolean} [options.dispatched=false] - Whether the click reached the element
57
+ * @param {string} [options.effect] - One of {@link CLICK_EFFECT}
58
+ * @param {Array} [options.evidence] - Evidence entries
59
+ * @param {number} [options.elapsedMs=0] - Duration of the operation
60
+ * @param {string} [options.reason=''] - Human-readable summary
61
+ * @param {boolean} [options.navigated=false] - Whether navigation was attributed to this click
62
+ * @param {string} [options.actionId] - Correlation ID for this click
63
+ * @returns {Object} Click result
64
+ */
65
+ export function makeClickResult(options = {}) {
66
+ const {
67
+ status,
68
+ dispatched = false,
69
+ effect = CLICK_EFFECT.NOT_OBSERVED,
70
+ evidence: evidenceList = [],
71
+ elapsedMs = 0,
72
+ reason = '',
73
+ navigated = false,
74
+ actionId,
75
+ ...rest
76
+ } = options;
77
+
78
+ return {
79
+ status,
80
+ dispatched,
81
+ effect,
82
+ evidence: evidenceList,
83
+ elapsedMs,
84
+ reason,
85
+ navigated,
86
+ ...(actionId === undefined ? {} : { actionId }),
87
+
88
+ // Legacy booleans, kept so existing callers keep working - but now derived
89
+ // from what was actually observed.
90
+ clicked: dispatched,
91
+ verified: effect === CLICK_EFFECT.CONFIRMED,
92
+ ...rest,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Mint a correlation ID so navigation evidence can be tied to one click.
98
+ *
99
+ * The random half comes from the system CSPRNG rather than `Math.random()`.
100
+ * The ID is only a correlation key, but it travels into logs and trace
101
+ * bundles, where a reader cannot tell a correlation key from a token; taking
102
+ * the characters from `randomUUID` costs nothing and settles the question.
103
+ *
104
+ * @returns {string} Opaque action ID
105
+ */
106
+ export function nextActionId() {
107
+ return `click-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
108
+ }