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
@@ -0,0 +1,517 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import {
4
+ READINESS_STATUS,
5
+ LONG_LIVED_REQUEST_PATTERNS,
6
+ isLongLivedRequest,
7
+ createDeadline,
8
+ sleepWithinDeadline,
9
+ stableCheck,
10
+ urlStableFor,
11
+ networkIdleFor,
12
+ domStableFor,
13
+ visibleImages,
14
+ predicate,
15
+ runReadinessChecks,
16
+ runWithinDeadline,
17
+ } from '../../../src/core/readiness.js';
18
+
19
+ /**
20
+ * Deadline driven by a clock the test advances by hand, so budget assertions
21
+ * are exact instead of timing-dependent.
22
+ */
23
+ function createFakeClock(start = 0) {
24
+ let value = start;
25
+ return {
26
+ now: () => value,
27
+ advance: (ms) => {
28
+ value += ms;
29
+ },
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Run one readiness check to completion under a fresh deadline.
35
+ *
36
+ * @param {Object} check - Readiness check to run
37
+ * @param {Object} [context] - Extra context merged into the run arguments
38
+ * @param {number} [timeout] - Deadline budget in milliseconds
39
+ * @returns {Promise<Object>} Check outcome
40
+ */
41
+ function runCheck(check, context = {}, timeout = 2000) {
42
+ return check.run({ deadline: createDeadline({ timeout }), ...context });
43
+ }
44
+
45
+ describe('readiness', () => {
46
+ describe('createDeadline', () => {
47
+ it('should require a non-negative finite timeout', () => {
48
+ assert.throws(() => createDeadline({}), TypeError);
49
+ assert.throws(() => createDeadline({ timeout: -1 }), TypeError);
50
+ assert.throws(() => createDeadline({ timeout: Infinity }), TypeError);
51
+ });
52
+
53
+ it('should report remaining budget that only ever shrinks', () => {
54
+ const clock = createFakeClock();
55
+ const deadline = createDeadline({ timeout: 1000, now: clock.now });
56
+
57
+ assert.strictEqual(deadline.remainingMs(), 1000);
58
+ assert.strictEqual(deadline.expired(), false);
59
+
60
+ clock.advance(400);
61
+ assert.strictEqual(deadline.elapsedMs(), 400);
62
+ assert.strictEqual(deadline.remainingMs(), 600);
63
+
64
+ clock.advance(5000);
65
+ assert.strictEqual(deadline.remainingMs(), 0);
66
+ assert.strictEqual(deadline.expired(), true);
67
+ });
68
+
69
+ it('should never hand out a bigger budget than the caller asked for', () => {
70
+ // Regression test for issue #89: the old code used
71
+ // `Math.max(60000, timeout - elapsed)`, which grew the budget instead of
72
+ // shrinking it.
73
+ const clock = createFakeClock();
74
+ const deadline = createDeadline({ timeout: 500, now: clock.now });
75
+
76
+ for (let step = 0; step < 10; step += 1) {
77
+ assert.ok(deadline.remainingMs() <= 500);
78
+ clock.advance(100);
79
+ }
80
+ assert.strictEqual(deadline.remainingMs(), 0);
81
+ });
82
+
83
+ it('should call itself expired as soon as nothing is left', () => {
84
+ // A check stops polling when `remainingMs()` reaches zero, so a deadline
85
+ // that still called itself live at that moment made the result blame the
86
+ // check ('failed') for what was really the budget running out. The two
87
+ // answers have to agree at every sub-millisecond point around the edge.
88
+ for (const spent of [19.5, 19.6, 19.99, 20, 20.4]) {
89
+ const clock = createFakeClock();
90
+ const deadline = createDeadline({ timeout: 20, now: clock.now });
91
+ clock.advance(spent);
92
+
93
+ assert.strictEqual(
94
+ deadline.expired(),
95
+ deadline.remainingMs() === 0,
96
+ `expired() disagreed with remainingMs() after ${spent}ms`
97
+ );
98
+ }
99
+ });
100
+
101
+ it('should ignore a clock that jumps backwards', () => {
102
+ let value = 1000;
103
+ const deadline = createDeadline({ timeout: 1000, now: () => value });
104
+ value = 0;
105
+ assert.strictEqual(deadline.elapsedMs(), 0);
106
+ assert.strictEqual(deadline.remainingMs(), 1000);
107
+ });
108
+ });
109
+
110
+ describe('sleepWithinDeadline', () => {
111
+ it('should not sleep past the deadline', async () => {
112
+ const deadline = createDeadline({ timeout: 40 });
113
+ const startedAt = Date.now();
114
+ await sleepWithinDeadline(5000, deadline);
115
+ assert.ok(Date.now() - startedAt < 1000);
116
+ });
117
+ });
118
+
119
+ describe('isLongLivedRequest', () => {
120
+ it('should ignore websockets, streams, analytics and polling', () => {
121
+ const ignored = [
122
+ 'wss://example.com/live',
123
+ 'ws://example.com/socket',
124
+ 'https://example.com/socket.io/?EIO=4',
125
+ 'https://example.com/sse',
126
+ 'https://www.google-analytics.com/collect',
127
+ 'https://example.com/api/telemetry',
128
+ 'https://example.com/v1/longpoll',
129
+ ];
130
+ for (const url of ignored) {
131
+ assert.ok(isLongLivedRequest(url), `${url} should be ignored`);
132
+ }
133
+ });
134
+
135
+ it('should not ignore ordinary page resources', () => {
136
+ const kept = [
137
+ 'https://example.com/index.html',
138
+ 'https://example.com/app.js',
139
+ 'https://example.com/api/users',
140
+ 'https://example.com/streamers/list',
141
+ ];
142
+ for (const url of kept) {
143
+ assert.strictEqual(isLongLivedRequest(url), false, url);
144
+ }
145
+ });
146
+
147
+ it('should tolerate missing and non-string urls', () => {
148
+ assert.strictEqual(isLongLivedRequest(undefined), false);
149
+ assert.strictEqual(isLongLivedRequest(''), false);
150
+ assert.strictEqual(isLongLivedRequest(42), false);
151
+ });
152
+
153
+ it('should accept caller-supplied patterns', () => {
154
+ assert.strictEqual(
155
+ isLongLivedRequest('https://example.com/keepalive', [/keepalive/u]),
156
+ true
157
+ );
158
+ assert.ok(LONG_LIVED_REQUEST_PATTERNS.length > 0);
159
+ });
160
+ });
161
+
162
+ describe('stableCheck', () => {
163
+ it('should require a name and a sampler', () => {
164
+ assert.throws(() => stableCheck({}), TypeError);
165
+ assert.throws(() => stableCheck({ name: 'x' }), TypeError);
166
+ });
167
+
168
+ it('should require the sampler to hold stable for the full window', async () => {
169
+ const samples = [true, false, true, true, true, true];
170
+ let index = 0;
171
+ const check = stableCheck({
172
+ name: 'flaky',
173
+ stableForMs: 20,
174
+ intervalMs: 5,
175
+ sample: () => samples[Math.min(index++, samples.length - 1)],
176
+ });
177
+
178
+ const outcome = await runCheck(check);
179
+
180
+ assert.strictEqual(outcome.satisfied, true);
181
+ assert.ok(index > 3, 'the unstable sample must reset the window');
182
+ });
183
+
184
+ it('should give up when the deadline is reached', async () => {
185
+ const check = stableCheck({
186
+ name: 'never',
187
+ stableForMs: 50,
188
+ intervalMs: 5,
189
+ sample: () => false,
190
+ });
191
+
192
+ const deadline = createDeadline({ timeout: 60 });
193
+ const outcome = await check.run({ deadline });
194
+
195
+ assert.strictEqual(outcome.satisfied, false);
196
+ assert.strictEqual(outcome.detail.reason, 'deadline reached');
197
+ });
198
+ });
199
+
200
+ describe('urlStableFor', () => {
201
+ it('should report the url it settled on and notify the caller', async () => {
202
+ const seen = [];
203
+ const urls = ['https://a.test/', 'https://b.test/', 'https://b.test/'];
204
+ let index = 0;
205
+ const page = { url: () => urls[Math.min(index++, urls.length - 1)] };
206
+
207
+ const check = urlStableFor({ stableForMs: 0, intervalMs: 5 });
208
+ const outcome = await runCheck(check, {
209
+ page,
210
+ onUrlSample: (url) => seen.push(url),
211
+ });
212
+
213
+ assert.strictEqual(outcome.satisfied, true);
214
+ assert.strictEqual(outcome.detail.url, 'https://b.test/');
215
+ assert.strictEqual(seen[0], 'https://a.test/');
216
+ });
217
+ });
218
+
219
+ describe('networkIdleFor', () => {
220
+ it('should skip when there is no network tracker', async () => {
221
+ const deadline = createDeadline({ timeout: 1000 });
222
+ const outcome = await networkIdleFor().run({ deadline });
223
+ assert.strictEqual(outcome.skipped, true);
224
+ });
225
+
226
+ it('should hand the tracker only the remaining budget', async () => {
227
+ const clock = createFakeClock();
228
+ const deadline = createDeadline({ timeout: 1000, now: clock.now });
229
+ clock.advance(700);
230
+
231
+ let received = null;
232
+ const networkTracker = {
233
+ waitForNetworkIdle: async (opts) => {
234
+ received = opts;
235
+ return true;
236
+ },
237
+ getPendingCount: () => 0,
238
+ };
239
+
240
+ const outcome = await networkIdleFor().run({ deadline, networkTracker });
241
+
242
+ assert.strictEqual(received.timeout, 300);
243
+ assert.strictEqual(outcome.satisfied, true);
244
+ });
245
+
246
+ it('should fail rather than wait when the budget is gone', async () => {
247
+ const clock = createFakeClock();
248
+ const deadline = createDeadline({ timeout: 100, now: clock.now });
249
+ clock.advance(500);
250
+
251
+ let called = false;
252
+ const networkTracker = {
253
+ waitForNetworkIdle: async () => {
254
+ called = true;
255
+ return true;
256
+ },
257
+ };
258
+
259
+ const outcome = await networkIdleFor().run({ deadline, networkTracker });
260
+
261
+ assert.strictEqual(called, false);
262
+ assert.strictEqual(outcome.satisfied, false);
263
+ assert.strictEqual(outcome.detail.reason, 'no remaining budget');
264
+ });
265
+
266
+ it('should report the pending urls behind a failure', async () => {
267
+ const deadline = createDeadline({ timeout: 1000 });
268
+ const networkTracker = {
269
+ waitForNetworkIdle: async () => false,
270
+ getPendingCount: () => 2,
271
+ getPendingUrls: () => ['https://example.com/slow'],
272
+ };
273
+
274
+ const outcome = await networkIdleFor().run({ deadline, networkTracker });
275
+
276
+ assert.strictEqual(outcome.satisfied, false);
277
+ assert.deepStrictEqual(outcome.detail.pendingUrls, [
278
+ 'https://example.com/slow',
279
+ ]);
280
+ });
281
+ });
282
+
283
+ describe('adapter-backed checks', () => {
284
+ it('should resolve the adapter lazily through getAdapter', async () => {
285
+ let resolved = 0;
286
+ const adapter = {
287
+ evaluateOnPage: async () => ({
288
+ nodes: 10,
289
+ length: 100,
290
+ readyState: 'complete',
291
+ }),
292
+ };
293
+
294
+ const check = domStableFor({ stableForMs: 0, intervalMs: 5 });
295
+ const outcome = await runCheck(check, {
296
+ getAdapter: async () => {
297
+ resolved += 1;
298
+ return adapter;
299
+ },
300
+ });
301
+
302
+ assert.strictEqual(outcome.satisfied, true);
303
+ assert.ok(resolved >= 1);
304
+ });
305
+
306
+ it('should surface a missing adapter as failure evidence', async () => {
307
+ const check = visibleImages({ intervalMs: 5 });
308
+ const deadline = createDeadline({ timeout: 30 });
309
+
310
+ const result = await runReadinessChecks({ checks: [check], deadline });
311
+
312
+ assert.strictEqual(result.ready, false);
313
+ assert.ok(
314
+ result.evidence[0].detail.error.includes('engine adapter'),
315
+ result.evidence[0].detail.error
316
+ );
317
+ });
318
+
319
+ it('should wait for in-viewport images to finish decoding', async () => {
320
+ const states = [
321
+ { total: 3, pending: 2 },
322
+ { total: 3, pending: 0 },
323
+ ];
324
+ let index = 0;
325
+ const adapter = {
326
+ evaluateOnPage: async () => states[Math.min(index++, 1)],
327
+ };
328
+
329
+ const check = visibleImages({ intervalMs: 5 });
330
+ const outcome = await runCheck(check, { adapter });
331
+
332
+ assert.strictEqual(outcome.satisfied, true);
333
+ assert.strictEqual(outcome.detail.pending, 0);
334
+ });
335
+ });
336
+
337
+ describe('predicate', () => {
338
+ it('should require a function', () => {
339
+ assert.throws(() => predicate({}), TypeError);
340
+ });
341
+
342
+ it('should poll the caller predicate until it holds', async () => {
343
+ let calls = 0;
344
+ const check = predicate({
345
+ name: 'spa-ready',
346
+ intervalMs: 5,
347
+ fn: () => ++calls >= 3,
348
+ });
349
+
350
+ const outcome = await runCheck(check);
351
+
352
+ assert.strictEqual(outcome.satisfied, true);
353
+ assert.strictEqual(calls, 3);
354
+ });
355
+ });
356
+
357
+ describe('runReadinessChecks', () => {
358
+ const ok = (name) => ({ name, run: async () => ({ satisfied: true }) });
359
+ const bad = (name) => ({ name, run: async () => ({ satisfied: false }) });
360
+ const skip = (name) => ({ name, run: async () => ({ skipped: true }) });
361
+
362
+ it('should be ready only when nothing failed and nothing is pending', async () => {
363
+ const deadline = createDeadline({ timeout: 1000 });
364
+ const result = await runReadinessChecks({
365
+ checks: [ok('a'), skip('b'), ok('c')],
366
+ deadline,
367
+ });
368
+
369
+ assert.strictEqual(result.ready, true);
370
+ assert.strictEqual(result.status, READINESS_STATUS.READY);
371
+ assert.deepStrictEqual(result.checks.satisfied, ['a', 'c']);
372
+ assert.deepStrictEqual(result.checks.skipped, ['b']);
373
+ assert.deepStrictEqual(result.checks.failed, []);
374
+ });
375
+
376
+ it('should report failed checks without stopping the whole report', async () => {
377
+ const deadline = createDeadline({ timeout: 1000 });
378
+ const result = await runReadinessChecks({
379
+ checks: [bad('a'), ok('b')],
380
+ deadline,
381
+ });
382
+
383
+ assert.strictEqual(result.ready, false);
384
+ assert.strictEqual(result.status, READINESS_STATUS.FAILED);
385
+ assert.deepStrictEqual(result.checks.failed, ['a']);
386
+ assert.deepStrictEqual(result.checks.satisfied, ['b']);
387
+ assert.strictEqual(result.evidence.length, 2);
388
+ });
389
+
390
+ it('should turn a thrown check into evidence rather than a crash', async () => {
391
+ const deadline = createDeadline({ timeout: 1000 });
392
+ const result = await runReadinessChecks({
393
+ checks: [
394
+ {
395
+ name: 'boom',
396
+ run: async () => {
397
+ throw new Error('adapter gone');
398
+ },
399
+ },
400
+ ],
401
+ deadline,
402
+ });
403
+
404
+ assert.strictEqual(result.ready, false);
405
+ assert.strictEqual(result.evidence[0].detail.error, 'adapter gone');
406
+ });
407
+
408
+ it('should mark unreached checks as pending after the deadline', async () => {
409
+ const clock = createFakeClock();
410
+ const deadline = createDeadline({ timeout: 100, now: clock.now });
411
+ const result = await runReadinessChecks({
412
+ checks: [
413
+ {
414
+ name: 'slow',
415
+ run: async () => {
416
+ clock.advance(500);
417
+ return { satisfied: false };
418
+ },
419
+ },
420
+ ok('never-run'),
421
+ ],
422
+ deadline,
423
+ });
424
+
425
+ assert.strictEqual(result.status, READINESS_STATUS.TIMED_OUT);
426
+ assert.deepStrictEqual(result.checks.pending, ['never-run']);
427
+ assert.strictEqual(result.ready, false);
428
+ });
429
+
430
+ it('should report elapsed and total budget on the result', async () => {
431
+ const clock = createFakeClock();
432
+ const deadline = createDeadline({ timeout: 800, now: clock.now });
433
+ const result = await runReadinessChecks({
434
+ checks: [ok('a')],
435
+ deadline,
436
+ });
437
+
438
+ assert.strictEqual(result.timeoutMs, 800);
439
+ assert.strictEqual(typeof result.elapsedMs, 'number');
440
+ });
441
+ });
442
+
443
+ describe('runWithinDeadline', () => {
444
+ it('should return the value of an operation that finished in time', async () => {
445
+ // A missing deadline means no bound at all, and both cases must report
446
+ // the operation's own answer rather than an expiry.
447
+ for (const deadline of [createDeadline({ timeout: 1000 }), null]) {
448
+ const outcome = await runWithinDeadline(deadline, async () => 'done');
449
+
450
+ assert.deepStrictEqual(outcome, { timedOut: false, value: 'done' });
451
+ }
452
+ });
453
+
454
+ it('should report expiry instead of waiting out a longer operation', async () => {
455
+ // Regression test for issue #89: an engine probe carrying its own
456
+ // 30-second default must not outlive the budget the caller asked for.
457
+ const started = Date.now();
458
+ const outcome = await runWithinDeadline(
459
+ createDeadline({ timeout: 30 }),
460
+ () => new Promise((resolve) => setTimeout(() => resolve('late'), 5000))
461
+ );
462
+
463
+ assert.strictEqual(outcome.timedOut, true);
464
+ assert.strictEqual(outcome.value, undefined);
465
+ assert.ok(
466
+ Date.now() - started < 2000,
467
+ 'the deadline, not the operation, must decide when to give up'
468
+ );
469
+ });
470
+
471
+ it('should not start an operation whose budget is already spent', async () => {
472
+ let started = false;
473
+ const clock = createFakeClock();
474
+ const deadline = createDeadline({ timeout: 100, now: clock.now });
475
+ clock.advance(150);
476
+
477
+ const outcome = await runWithinDeadline(deadline, async () => {
478
+ started = true;
479
+ });
480
+
481
+ assert.strictEqual(outcome.timedOut, true);
482
+ assert.strictEqual(started, false);
483
+ });
484
+
485
+ it('should propagate a failure the operation reports in time', async () => {
486
+ await assert.rejects(
487
+ runWithinDeadline(createDeadline({ timeout: 1000 }), async () => {
488
+ throw new Error('probe failed');
489
+ }),
490
+ /probe failed/
491
+ );
492
+ });
493
+
494
+ it('should absorb a rejection that arrives after expiry', async () => {
495
+ // An abandoned probe still settles; its rejection must not escape as an
496
+ // unhandled one into a process that already moved on.
497
+ const unhandled = [];
498
+ const onUnhandled = (error) => unhandled.push(error);
499
+ process.on('unhandledRejection', onUnhandled);
500
+
501
+ try {
502
+ const outcome = await runWithinDeadline(
503
+ createDeadline({ timeout: 20 }),
504
+ () =>
505
+ new Promise((_resolve, reject) => {
506
+ setTimeout(() => reject(new Error('too late')), 60);
507
+ })
508
+ );
509
+ assert.strictEqual(outcome.timedOut, true);
510
+ await new Promise((resolve) => setTimeout(resolve, 150));
511
+ assert.deepStrictEqual(unhandled, []);
512
+ } finally {
513
+ process.off('unhandledRejection', onUnhandled);
514
+ }
515
+ });
516
+ });
517
+ });