browser-commander 0.17.2 → 0.19.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 (74) hide show
  1. package/.jscpd-baseline.json +12 -17
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +132 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +5 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +476 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +238 -0
  19. package/src/downloads/staging.js +146 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +86 -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 +338 -0
  29. package/src/traces/deadline.js +33 -0
  30. package/src/traces/identity.js +127 -0
  31. package/src/traces/init-script.js +95 -0
  32. package/src/traces/links.js +623 -0
  33. package/src/traces/mutation-stream.js +232 -0
  34. package/src/traces/observers.js +272 -0
  35. package/src/traces/page-capture.js +601 -0
  36. package/src/traces/reader.js +226 -0
  37. package/src/traces/recorder.js +454 -0
  38. package/src/traces/redaction.js +231 -0
  39. package/src/traces/schema.js +229 -0
  40. package/src/traces/viewer.js +448 -0
  41. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  42. package/tests/e2e/downloads.e2e.test.js +235 -0
  43. package/tests/e2e/traces.e2e.test.js +742 -0
  44. package/tests/helpers/click-fixtures.js +62 -0
  45. package/tests/helpers/download-fixtures.js +125 -0
  46. package/tests/helpers/download-server.js +129 -0
  47. package/tests/helpers/e2e-browser.js +67 -0
  48. package/tests/helpers/file-modes.js +31 -0
  49. package/tests/helpers/fixture-server.js +56 -0
  50. package/tests/helpers/mocks.js +63 -66
  51. package/tests/helpers/readiness-server.js +96 -0
  52. package/tests/helpers/temp-directory.js +44 -0
  53. package/tests/helpers/trace-fixtures.js +274 -0
  54. package/tests/helpers/trace-server.js +173 -0
  55. package/tests/unit/core/dialog-manager.test.js +118 -10
  56. package/tests/unit/core/navigation-manager.test.js +162 -0
  57. package/tests/unit/core/readiness.test.js +517 -0
  58. package/tests/unit/downloads/attach.test.js +220 -0
  59. package/tests/unit/downloads/destination.test.js +114 -0
  60. package/tests/unit/downloads/manager.test.js +485 -0
  61. package/tests/unit/downloads/naming.test.js +174 -0
  62. package/tests/unit/downloads/staging.test.js +117 -0
  63. package/tests/unit/downloads/store.test.js +215 -0
  64. package/tests/unit/interactions/click-activation.test.js +283 -0
  65. package/tests/unit/interactions/click-result.test.js +90 -0
  66. package/tests/unit/interactions/click.test.js +409 -200
  67. package/tests/unit/tests/tracing.test.js +299 -0
  68. package/tests/unit/traces/bundle.test.js +260 -0
  69. package/tests/unit/traces/links.test.js +557 -0
  70. package/tests/unit/traces/reader.test.js +208 -0
  71. package/tests/unit/traces/recorder.test.js +730 -0
  72. package/tests/unit/traces/redaction.test.js +170 -0
  73. package/tests/unit/traces/schema.test.js +92 -0
  74. package/tests/unit/traces/viewer.test.js +294 -0
@@ -6,168 +6,201 @@ import {
6
6
  verifyClick,
7
7
  clickElement,
8
8
  clickButton,
9
+ CLICK_STATUS,
10
+ CLICK_EFFECT,
9
11
  } from '../../../src/interactions/click.js';
10
12
  import {
11
13
  createMockPlaywrightPage,
12
14
  createMockLogger,
13
15
  } from '../../helpers/mocks.js';
16
+ import {
17
+ UNCHANGED_STATE,
18
+ IN_VIEWPORT_POINT,
19
+ createScrollModel,
20
+ } from '../../helpers/click-fixtures.js';
21
+ import { createDeadline } from '../../../src/core/readiness.js';
22
+
23
+ /**
24
+ * Run the default verifier against a fixed post-click element state.
25
+ *
26
+ * @param {Object} options - Configuration options
27
+ * @param {Object|Error} options.post - Post-click state, or an error to throw
28
+ * @param {Object} [options.pre] - Pre-click state
29
+ * @returns {Promise<Object>} Verification result
30
+ */
31
+ function verifyAgainst(options) {
32
+ const { post, pre = {} } = options;
33
+
34
+ return defaultClickVerification({
35
+ page: createMockPlaywrightPage(),
36
+ engine: 'playwright',
37
+ locatorOrElement: {},
38
+ preClickState: pre,
39
+ adapter: {
40
+ evaluateOnElement: async () => {
41
+ if (post instanceof Error) {
42
+ throw post;
43
+ }
44
+ return post;
45
+ },
46
+ },
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Verify a click that changed nothing at all about the target element.
52
+ *
53
+ * @returns {Promise<Object>} Verification result
54
+ */
55
+ function verifyUnchanged() {
56
+ return verifyAgainst({
57
+ post: { ...UNCHANGED_STATE },
58
+ pre: { ...UNCHANGED_STATE },
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Capture pre-click state through an element probe stub.
64
+ *
65
+ * @param {Function} evaluateOnElement - Adapter probe implementation
66
+ * @returns {Promise<Object>} Captured state
67
+ */
68
+ function capturePreWith(evaluateOnElement) {
69
+ return capturePreClickState({
70
+ page: createMockPlaywrightPage(),
71
+ engine: 'playwright',
72
+ locatorOrElement: {},
73
+ adapter: { evaluateOnElement },
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Run `verifyClick` with a custom verifier.
79
+ *
80
+ * @param {Function} verifyFn - Custom verification function
81
+ * @returns {Promise<Object>} Normalized verification outcome
82
+ */
83
+ function verifyClickWith(verifyFn) {
84
+ return verifyClick({
85
+ page: createMockPlaywrightPage(),
86
+ engine: 'playwright',
87
+ locatorOrElement: {},
88
+ log: createMockLogger(),
89
+ verifyFn,
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Assert that a verdict claims nothing it did not observe.
95
+ *
96
+ * @param {Object} result - Verification result
97
+ * @param {string} reasonFragment - Substring the reason must contain
98
+ */
99
+ function assertNotObserved(result, reasonFragment) {
100
+ assert.strictEqual(result.verified, false);
101
+ assert.strictEqual(result.effect, CLICK_EFFECT.NOT_OBSERVED);
102
+ assert.ok(
103
+ result.reason.includes(reasonFragment),
104
+ `reason ${JSON.stringify(result.reason)} should mention ${reasonFragment}`
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Invoke `clickElement` against the mock page with a caller-supplied adapter.
110
+ *
111
+ * @param {Object} adapter - Engine adapter stub
112
+ * @param {Object} [options] - Extra clickElement options
113
+ * @returns {Promise<{result: Object, page: Object}>} Result and the mock page
114
+ */
115
+ async function clickWith(adapter, options = {}) {
116
+ const page = createMockPlaywrightPage();
117
+ const result = await clickElement({
118
+ page,
119
+ engine: 'playwright',
120
+ log: createMockLogger(),
121
+ locatorOrElement: {},
122
+ adapter,
123
+ verify: false,
124
+ ...options,
125
+ });
126
+ return { result, page };
127
+ }
14
128
 
15
129
  describe('click', () => {
16
130
  describe('defaultClickVerification', () => {
17
131
  it('should verify click by checking element state', async () => {
18
- const page = createMockPlaywrightPage();
19
- const adapter = {
20
- evaluateOnElement: async () => ({
21
- disabled: false,
22
- ariaPressed: 'true',
23
- ariaExpanded: null,
24
- ariaSelected: null,
25
- checked: false,
26
- className: 'btn',
27
- isConnected: true,
28
- }),
29
- };
30
-
31
- const result = await defaultClickVerification({
32
- page,
33
- engine: 'playwright',
34
- locatorOrElement: {},
35
- preClickState: {
36
- ariaPressed: 'false',
37
- },
38
- adapter,
132
+ const result = await verifyAgainst({
133
+ post: { ...UNCHANGED_STATE, ariaPressed: 'true' },
134
+ pre: { ...UNCHANGED_STATE },
39
135
  });
40
136
 
41
137
  assert.strictEqual(result.verified, true);
138
+ assert.strictEqual(result.effect, CLICK_EFFECT.CONFIRMED);
42
139
  assert.ok(result.reason.includes('aria-pressed'));
43
140
  });
44
141
 
45
142
  it('should verify when className changed', async () => {
46
- const page = createMockPlaywrightPage();
47
- const adapter = {
48
- evaluateOnElement: async () => ({
49
- disabled: false,
50
- className: 'btn active',
51
- isConnected: true,
52
- }),
53
- };
54
-
55
- const result = await defaultClickVerification({
56
- page,
57
- engine: 'playwright',
58
- locatorOrElement: {},
59
- preClickState: {
60
- className: 'btn',
61
- },
62
- adapter,
143
+ const result = await verifyAgainst({
144
+ post: { ...UNCHANGED_STATE, className: 'btn active' },
145
+ pre: { ...UNCHANGED_STATE },
63
146
  });
64
147
 
65
148
  assert.strictEqual(result.verified, true);
66
149
  assert.ok(result.reason.includes('className'));
67
150
  });
68
151
 
69
- it('should verify when element is still connected', async () => {
70
- const page = createMockPlaywrightPage();
71
- const adapter = {
72
- evaluateOnElement: async () => ({
73
- disabled: false,
74
- isConnected: true,
75
- }),
76
- };
152
+ it('should NOT claim success when nothing about the element changed', async () => {
153
+ // Regression test for issue #89: a button whose handler does nothing left
154
+ // the element connected and unchanged, and that was reported as success.
155
+ const result = await verifyUnchanged();
77
156
 
78
- const result = await defaultClickVerification({
79
- page,
80
- engine: 'playwright',
81
- locatorOrElement: {},
82
- preClickState: {},
83
- adapter,
84
- });
157
+ assertNotObserved(result, 'no observable change');
158
+ });
85
159
 
86
- assert.strictEqual(result.verified, true);
87
- assert.ok(result.reason.includes('connected'));
160
+ it('should report not-observed when no pre-click state was captured', async () => {
161
+ const result = await verifyAgainst({ post: { ...UNCHANGED_STATE } });
162
+
163
+ assertNotObserved(result, 'no pre-click state');
88
164
  });
89
165
 
90
166
  it('should verify when element removed from DOM', async () => {
91
- const page = createMockPlaywrightPage();
92
- const adapter = {
93
- evaluateOnElement: async () => ({
94
- isConnected: false,
95
- }),
96
- };
97
-
98
- const result = await defaultClickVerification({
99
- page,
100
- engine: 'playwright',
101
- locatorOrElement: {},
102
- preClickState: {},
103
- adapter,
104
- });
167
+ const result = await verifyAgainst({ post: { isConnected: false } });
105
168
 
106
169
  assert.strictEqual(result.verified, true);
170
+ assert.strictEqual(result.effect, CLICK_EFFECT.CONFIRMED);
107
171
  assert.ok(result.reason.includes('removed'));
108
172
  });
109
173
 
110
- it('should handle navigation errors', async () => {
111
- const page = createMockPlaywrightPage();
112
- const adapter = {
113
- evaluateOnElement: async () => {
114
- throw new Error('Execution context was destroyed');
115
- },
116
- };
117
-
118
- const result = await defaultClickVerification({
119
- page,
120
- engine: 'playwright',
121
- locatorOrElement: {},
122
- preClickState: {},
123
- adapter,
174
+ it('should not treat a destroyed execution context as proof of effect', async () => {
175
+ // Regression test for issue #89: the click may or may not have caused the
176
+ // navigation that destroyed the context. Attribution is the caller's job.
177
+ const result = await verifyAgainst({
178
+ post: new Error('Execution context was destroyed'),
124
179
  });
125
180
 
126
- assert.strictEqual(result.verified, true);
181
+ assertNotObserved(result, 'verification unavailable');
127
182
  assert.strictEqual(result.navigationError, true);
128
183
  });
184
+
185
+ it('should attach evidence to every verdict', async () => {
186
+ const result = await verifyUnchanged();
187
+
188
+ assert.ok(Array.isArray(result.evidence));
189
+ assert.strictEqual(result.evidence[0].type, 'element-state');
190
+ });
129
191
  });
130
192
 
131
193
  describe('capturePreClickState', () => {
132
194
  it('should capture element state', async () => {
133
- const page = createMockPlaywrightPage();
134
- const adapter = {
135
- evaluateOnElement: async () => ({
136
- disabled: false,
137
- ariaPressed: 'false',
138
- ariaExpanded: null,
139
- ariaSelected: null,
140
- checked: false,
141
- className: 'btn',
142
- isConnected: true,
143
- }),
144
- };
145
-
146
- const state = await capturePreClickState({
147
- page,
148
- engine: 'playwright',
149
- locatorOrElement: {},
150
- adapter,
151
- });
195
+ const state = await capturePreWith(async () => ({ ...UNCHANGED_STATE }));
152
196
 
153
- assert.ok(state);
154
197
  assert.strictEqual(state.disabled, false);
155
198
  assert.strictEqual(state.className, 'btn');
156
199
  });
157
200
 
158
201
  it('should return empty object on navigation error', async () => {
159
- const page = createMockPlaywrightPage();
160
- const adapter = {
161
- evaluateOnElement: async () => {
162
- throw new Error('Execution context was destroyed');
163
- },
164
- };
165
-
166
- const state = await capturePreClickState({
167
- page,
168
- engine: 'playwright',
169
- locatorOrElement: {},
170
- adapter,
202
+ const state = await capturePreWith(async () => {
203
+ throw new Error('Execution context was destroyed');
171
204
  });
172
205
 
173
206
  assert.deepStrictEqual(state, {});
@@ -176,20 +209,11 @@ describe('click', () => {
176
209
 
177
210
  describe('verifyClick', () => {
178
211
  it('should use custom verify function', async () => {
179
- const page = createMockPlaywrightPage();
180
- const log = createMockLogger();
181
212
  let customCalled = false;
182
- const customVerifyFn = async () => {
213
+
214
+ const result = await verifyClickWith(async () => {
183
215
  customCalled = true;
184
216
  return { verified: true, reason: 'custom verification' };
185
- };
186
-
187
- const result = await verifyClick({
188
- page,
189
- engine: 'playwright',
190
- locatorOrElement: {},
191
- verifyFn: customVerifyFn,
192
- log,
193
217
  });
194
218
 
195
219
  assert.strictEqual(customCalled, true);
@@ -197,113 +221,160 @@ describe('click', () => {
197
221
  assert.strictEqual(result.reason, 'custom verification');
198
222
  });
199
223
 
200
- it('should log verification result', async () => {
201
- const page = createMockPlaywrightPage();
202
- const log = createMockLogger({ collectLogs: true });
203
-
204
- await verifyClick({
205
- page,
206
- engine: 'playwright',
207
- locatorOrElement: {},
208
- verifyFn: async () => ({ verified: true, reason: 'test' }),
209
- log,
210
- });
211
-
212
- // Should have logged
224
+ it('should derive an effect for custom verifiers that do not set one', async () => {
225
+ const confirmed = await verifyClickWith(async () => ({
226
+ verified: true,
227
+ reason: 'ok',
228
+ }));
229
+ assert.strictEqual(confirmed.effect, CLICK_EFFECT.CONFIRMED);
230
+
231
+ const denied = await verifyClickWith(async () => ({
232
+ verified: false,
233
+ reason: 'nope',
234
+ }));
235
+ assert.strictEqual(denied.effect, CLICK_EFFECT.NOT_OBSERVED);
213
236
  });
214
237
  });
215
238
 
216
239
  describe('clickElement', () => {
217
240
  it('should throw when locatorOrElement is not provided', async () => {
218
- const page = createMockPlaywrightPage();
219
- const log = createMockLogger();
220
-
221
241
  await assert.rejects(
222
- () => clickElement({ page, engine: 'playwright', log }),
242
+ () =>
243
+ clickElement({
244
+ page: createMockPlaywrightPage(),
245
+ engine: 'playwright',
246
+ log: createMockLogger(),
247
+ }),
223
248
  /locatorOrElement is required/
224
249
  );
225
250
  });
226
251
 
227
- it('should click element', async () => {
228
- const page = createMockPlaywrightPage();
229
- const log = createMockLogger();
252
+ it('should report unverified when verification was not requested', async () => {
230
253
  let clicked = false;
231
- const adapter = {
254
+ const { result } = await clickWith({
232
255
  click: async () => {
233
256
  clicked = true;
234
257
  },
235
258
  evaluateOnElement: async () => ({ isConnected: true }),
236
- };
237
- const mockLocator = {};
238
-
239
- const result = await clickElement({
240
- page,
241
- engine: 'playwright',
242
- log,
243
- locatorOrElement: mockLocator,
244
- adapter,
245
- verify: false,
246
259
  });
247
260
 
248
- assert.strictEqual(result.clicked, true);
249
261
  assert.strictEqual(clicked, true);
262
+ assert.strictEqual(result.clicked, true);
263
+ assert.strictEqual(result.verified, false);
264
+ assert.strictEqual(result.status, CLICK_STATUS.UNVERIFIED);
250
265
  });
251
266
 
252
- it('should click with force option when noAutoScroll is true', async () => {
253
- const page = createMockPlaywrightPage();
254
- const log = createMockLogger();
255
- let clickOptions = null;
256
- const adapter = {
257
- click: async (el, opts) => {
258
- clickOptions = opts;
267
+ it('should not route noAutoScroll through Playwright force, which still scrolls', async () => {
268
+ // Regression test for issue #89: `{ force: true }` skips actionability
269
+ // checks but does NOT disable scroll-into-view, so the old mapping
270
+ // silently scrolled the page while reporting that it had not.
271
+ let adapterClickCalled = false;
272
+ const { result, page } = await clickWith(
273
+ {
274
+ click: async () => {
275
+ adapterClickCalled = true;
276
+ },
277
+ evaluateOnElement: async () => ({ ...IN_VIEWPORT_POINT }),
278
+ evaluateOnPage: async () => ({ x: 0, y: 0 }),
259
279
  },
260
- evaluateOnElement: async () => ({ isConnected: true }),
261
- };
280
+ { noAutoScroll: true }
281
+ );
262
282
 
263
- await clickElement({
264
- page,
265
- engine: 'playwright',
266
- log,
267
- locatorOrElement: {},
268
- adapter,
269
- noAutoScroll: true,
270
- verify: false,
271
- });
283
+ assert.strictEqual(adapterClickCalled, false);
284
+ assert.strictEqual(page.mouse.clicks.length, 1);
285
+ assert.strictEqual(page.mouse.clicks[0].x, IN_VIEWPORT_POINT.x);
286
+ assert.strictEqual(page.mouse.clicks[0].y, IN_VIEWPORT_POINT.y);
287
+ assert.strictEqual(result.dispatched, true);
288
+ });
289
+
290
+ it('should pass force to the engine only for actionability: force', async () => {
291
+ let clickOptions = null;
292
+ await clickWith(
293
+ {
294
+ click: async (el, opts) => {
295
+ clickOptions = opts;
296
+ },
297
+ evaluateOnElement: async () => ({ isConnected: true }),
298
+ evaluateOnPage: async () => ({ x: 0, y: 0 }),
299
+ },
300
+ { actionability: 'force' }
301
+ );
272
302
 
273
303
  assert.deepStrictEqual(clickOptions, { force: true });
274
304
  });
275
305
 
276
- it('should handle navigation errors', async () => {
277
- const page = createMockPlaywrightPage();
278
- const log = createMockLogger();
279
- const adapter = {
306
+ it('should fail clearly when scroll: none cannot be honored', async () => {
307
+ const { result, page } = await clickWith(
308
+ {
309
+ click: async () => {
310
+ throw new Error('adapter.click must not be reached');
311
+ },
312
+ evaluateOnElement: async () => ({
313
+ ...IN_VIEWPORT_POINT,
314
+ y: 4000,
315
+ top: 3980,
316
+ inViewport: false,
317
+ hitsTarget: false,
318
+ }),
319
+ evaluateOnPage: async () => ({ x: 0, y: 0 }),
320
+ },
321
+ { scroll: 'none' }
322
+ );
323
+
324
+ assert.strictEqual(result.status, CLICK_STATUS.FAILED);
325
+ assert.strictEqual(result.dispatched, false);
326
+ assert.strictEqual(page.mouse.clicks.length, 0);
327
+ assert.ok(result.reason.includes('outside the viewport'));
328
+ });
329
+
330
+ it('should restore the scroll position for scroll: preserve', async () => {
331
+ const scroll = createScrollModel();
332
+ await clickWith(
333
+ {
334
+ click: async () => scroll.set(3911),
335
+ evaluateOnElement: async () => ({ isConnected: true }),
336
+ evaluateOnPage: scroll.evaluateOnPage,
337
+ },
338
+ { scroll: 'preserve' }
339
+ );
340
+
341
+ assert.strictEqual(scroll.current(), 0);
342
+ });
343
+
344
+ it('should report interrupted, not verified, when navigation cuts in', async () => {
345
+ const { result } = await clickWith({
280
346
  click: async () => {
281
347
  throw new Error('Execution context was destroyed');
282
348
  },
283
- };
284
-
285
- const result = await clickElement({
286
- page,
287
- engine: 'playwright',
288
- log,
289
- locatorOrElement: {},
290
- adapter,
291
- verify: false,
292
349
  });
293
350
 
294
351
  assert.strictEqual(result.clicked, false);
295
- assert.strictEqual(result.verified, true);
352
+ assert.strictEqual(result.verified, false);
353
+ assert.strictEqual(result.status, CLICK_STATUS.INTERRUPTED);
354
+ assert.strictEqual(result.effect, CLICK_EFFECT.NOT_OBSERVED);
355
+ });
356
+
357
+ it('should carry an action id for navigation correlation', async () => {
358
+ const { result } = await clickWith({
359
+ click: async () => {},
360
+ evaluateOnElement: async () => ({ isConnected: true }),
361
+ });
362
+
363
+ assert.strictEqual(typeof result.actionId, 'string');
364
+ assert.ok(result.elapsedMs >= 0);
296
365
  });
297
366
  });
298
367
 
299
368
  describe('clickButton', () => {
300
369
  it('should throw when selector is not provided', async () => {
301
- const page = createMockPlaywrightPage();
302
- const log = createMockLogger();
303
- const wait = async () => {};
304
-
305
370
  await assert.rejects(
306
- () => clickButton({ page, engine: 'playwright', log, wait }),
371
+ () =>
372
+ clickButton({
373
+ page: createMockPlaywrightPage(),
374
+ engine: 'playwright',
375
+ log: createMockLogger(),
376
+ wait: async () => {},
377
+ }),
307
378
  /selector is required/
308
379
  );
309
380
  });
@@ -312,8 +383,6 @@ describe('click', () => {
312
383
  const page = createMockPlaywrightPage({
313
384
  elements: { button: { visible: true, count: 1 } },
314
385
  });
315
- const log = createMockLogger();
316
- const wait = async ({ ms }) => ({ completed: true, aborted: false });
317
386
 
318
387
  // This is a complex test that requires full mock setup
319
388
  // For unit tests, we'll verify the interface
@@ -321,8 +390,8 @@ describe('click', () => {
321
390
  const result = await clickButton({
322
391
  page,
323
392
  engine: 'playwright',
324
- log,
325
- wait,
393
+ log: createMockLogger(),
394
+ wait: async () => ({ completed: true, aborted: false }),
326
395
  selector: 'button',
327
396
  scrollIntoView: false,
328
397
  waitAfterClick: 0,
@@ -331,10 +400,150 @@ describe('click', () => {
331
400
  });
332
401
  assert.ok(typeof result.clicked === 'boolean');
333
402
  assert.ok(typeof result.navigated === 'boolean');
403
+ assert.ok(Object.values(CLICK_STATUS).includes(result.status));
334
404
  } catch (e) {
335
405
  // May fail due to mock limitations, but we verify the interface works
336
406
  assert.ok(e.message);
337
407
  }
338
408
  });
339
409
  });
410
+
411
+ describe('click deadlines', () => {
412
+ /** An element probe that never answers, like a locator on a lost document. */
413
+ const neverAnswers = () => new Promise(() => {});
414
+
415
+ /**
416
+ * A probe that answers once and then hangs.
417
+ *
418
+ * This is what a real engine looks like once the document the element
419
+ * lived in is gone: the first read is served, and any later one waits for
420
+ * the engine's own far longer timeout.
421
+ *
422
+ * @returns {Function} Element probe for a stub adapter
423
+ */
424
+ const answersOnceThenHangs = () => {
425
+ let probes = 0;
426
+ return async (...args) =>
427
+ (probes += 1) === 1 ? { ...UNCHANGED_STATE } : neverAnswers(...args);
428
+ };
429
+
430
+ /**
431
+ * Click a stub adapter with a budget far below any engine's own timeout.
432
+ *
433
+ * @param {Object} adapter - Stub engine adapter
434
+ * @returns {Promise<Object>} Click result
435
+ */
436
+ const clickOnABudget = async (adapter) => {
437
+ const { result } = await clickWith(adapter, {
438
+ verify: true,
439
+ timeout: 60,
440
+ });
441
+ return result;
442
+ };
443
+
444
+ it('should stop probing when the click budget is spent', async () => {
445
+ // Regression test for issue #89: Playwright's locator probe carries a
446
+ // 30-second default, so an unbounded verification outlived every budget
447
+ // the caller asked for.
448
+ const started = Date.now();
449
+ const result = await defaultClickVerification({
450
+ page: createMockPlaywrightPage(),
451
+ engine: 'playwright',
452
+ locatorOrElement: {},
453
+ preClickState: { ...UNCHANGED_STATE },
454
+ adapter: { evaluateOnElement: neverAnswers },
455
+ deadline: createDeadline({ timeout: 40 }),
456
+ });
457
+
458
+ assert.strictEqual(result.timedOut, true);
459
+ assert.strictEqual(result.effect, CLICK_EFFECT.NOT_OBSERVED);
460
+ assert.strictEqual(result.evidence[0].type, 'verification-timeout');
461
+ assert.ok(Date.now() - started < 2000);
462
+ });
463
+
464
+ it('should report timed_out rather than unverified for an unreadable effect', async () => {
465
+ const result = await clickOnABudget({
466
+ click: async () => {},
467
+ evaluateOnElement: answersOnceThenHangs(),
468
+ });
469
+
470
+ assert.strictEqual(result.status, CLICK_STATUS.TIMED_OUT);
471
+ assert.strictEqual(result.dispatched, true);
472
+ assert.strictEqual(result.verified, false);
473
+ assert.ok(
474
+ result.evidence.some((item) => item.type === 'verification-timeout')
475
+ );
476
+ });
477
+
478
+ it('should stop waiting on a dispatch that outlives the budget', async () => {
479
+ // Dispatch is an engine round-trip too, so an unbounded one spends the
480
+ // budget the caller reserved for the whole click before verification
481
+ // ever gets a turn.
482
+ const started = Date.now();
483
+ const result = await clickOnABudget({
484
+ click: neverAnswers,
485
+ evaluateOnElement: async () => ({ ...UNCHANGED_STATE }),
486
+ });
487
+
488
+ assert.ok(Date.now() - started < 2000);
489
+ assert.strictEqual(result.status, CLICK_STATUS.TIMED_OUT);
490
+ assert.strictEqual(result.dispatched, false);
491
+ assert.strictEqual(result.evidence[0].type, 'dispatch-timeout');
492
+ });
493
+
494
+ it('should measure elapsed time monotonically, not by the wall clock', async () => {
495
+ const { result } = await clickWith({
496
+ click: async () => {},
497
+ evaluateOnElement: async () => ({ ...UNCHANGED_STATE }),
498
+ });
499
+
500
+ assert.ok(Number.isFinite(result.elapsedMs));
501
+ assert.ok(result.elapsedMs >= 0);
502
+ });
503
+
504
+ it('should give up on a target the page navigated away from', async () => {
505
+ // A navigation replaces the document, and the engine answers the probe on
506
+ // the new one instead of failing - so the URL is what ends the wait.
507
+ const page = createMockPlaywrightPage();
508
+ let url = 'https://example.com/start';
509
+ page.url = () => url;
510
+ // The pre-click probe answers; only the post-click one is left hanging,
511
+ // which is what a probe against a replaced document looks like.
512
+ const timer = setTimeout(() => {
513
+ url = 'https://example.com/arrived';
514
+ }, 60);
515
+
516
+ const started = Date.now();
517
+ const result = await clickElement({
518
+ page,
519
+ engine: 'playwright',
520
+ log: createMockLogger(),
521
+ locatorOrElement: {},
522
+ verify: true,
523
+ timeout: 5000,
524
+ adapter: {
525
+ click: async () => {},
526
+ evaluateOnElement: answersOnceThenHangs(),
527
+ },
528
+ });
529
+ clearTimeout(timer);
530
+
531
+ assert.ok(
532
+ Date.now() - started < 2000,
533
+ 'the navigation, not the budget, should end the wait'
534
+ );
535
+
536
+ assert.strictEqual(result.status, CLICK_STATUS.UNVERIFIED);
537
+ assert.strictEqual(result.effect, CLICK_EFFECT.NOT_OBSERVED);
538
+ assert.strictEqual(result.dispatched, true);
539
+ const navigation = result.evidence.find(
540
+ (item) => item.type === 'navigation'
541
+ );
542
+ assert.ok(navigation, 'the navigation should be recorded as evidence');
543
+ // The navigation is correlated with the click, but it is not proof that
544
+ // the click caused it.
545
+ assert.strictEqual(navigation.detail.provesClickEffect, false);
546
+ assert.strictEqual(navigation.detail.to, 'https://example.com/arrived');
547
+ });
548
+ });
340
549
  });