browser-commander 0.18.0 → 0.20.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 (35) hide show
  1. package/.jscpd-baseline.json +8 -0
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +50 -2
  4. package/package.json +8 -3
  5. package/scripts/run-tests.mjs +4 -2
  6. package/src/downloads/manager.js +40 -8
  7. package/src/downloads/sources.js +127 -6
  8. package/src/downloads/staging.js +158 -0
  9. package/src/exports.js +14 -0
  10. package/src/traces/bundle.js +26 -3
  11. package/src/traces/deadline.js +33 -0
  12. package/src/traces/identity.js +127 -0
  13. package/src/traces/init-script.js +95 -0
  14. package/src/traces/links.js +623 -0
  15. package/src/traces/mutation-stream.js +232 -0
  16. package/src/traces/observers.js +29 -3
  17. package/src/traces/page-capture.js +251 -15
  18. package/src/traces/recorder.js +124 -94
  19. package/src/traces/schema.js +65 -1
  20. package/src/traces/viewer.js +157 -11
  21. package/tests/e2e/downloads.e2e.test.js +126 -2
  22. package/tests/e2e/traces.e2e.test.js +296 -11
  23. package/tests/helpers/download-server.js +26 -6
  24. package/tests/helpers/trace-fixtures.js +97 -3
  25. package/tests/helpers/trace-server.js +57 -1
  26. package/tests/unit/downloads/manager.test.js +213 -9
  27. package/tests/unit/downloads/staging.test.js +139 -0
  28. package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
  29. package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
  30. package/tests/unit/scripts/pre-commit-config.test.js +2 -1
  31. package/tests/unit/traces/bundle.test.js +24 -3
  32. package/tests/unit/traces/links.test.js +564 -0
  33. package/tests/unit/traces/reader.test.js +10 -4
  34. package/tests/unit/traces/recorder.test.js +172 -1
  35. package/tests/unit/traces/viewer.test.js +9 -0
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { EventEmitter } from 'node:events';
11
+ import { afterEach } from 'node:test';
11
12
  import { useTempDirectory } from './temp-directory.js';
12
13
 
13
14
  /**
@@ -19,6 +20,23 @@ export function useTempTraceDirectory() {
19
20
  return useTempDirectory('bc-traces-');
20
21
  }
21
22
 
23
+ /**
24
+ * Close every bundle a test opened, including deliberately truncated ones.
25
+ *
26
+ * @returns {(bundle: Object) => Object} Tracker for an opened bundle
27
+ */
28
+ export function useTraceBundleCleanup() {
29
+ const bundles = new Set();
30
+ afterEach(async () => {
31
+ await Promise.all([...bundles].map((bundle) => bundle.abort()));
32
+ bundles.clear();
33
+ });
34
+ return (bundle) => {
35
+ bundles.add(bundle);
36
+ return bundle;
37
+ };
38
+ }
39
+
22
40
  /**
23
41
  * A snapshot shaped like the one the in-page capture returns.
24
42
  *
@@ -50,7 +68,10 @@ export function makeSnapshot(overrides = {}) {
50
68
  /**
51
69
  * Create a fake page the recorder can drive.
52
70
  *
53
- * @param {Object} [options] - `{snapshot, mutations, screenshot}`
71
+ * @param {Object} [options] - `{snapshot, mutations, screenshot, frames}`
72
+ * @param {Object[]} [options.frames] - Extra frames, each `{mutations}`; the
73
+ * page itself is always the main frame, and a page given no frames has no
74
+ * `frames()` at all, the way a page object from neither engine would
54
75
  * @returns {Object} The fake page
55
76
  */
56
77
  export function createFakePage(options = {}) {
@@ -62,10 +83,11 @@ export function createFakePage(options = {}) {
62
83
  screenshot: options.screenshot ?? Buffer.from('fake-png'),
63
84
  screenshots: 0,
64
85
  evaluated: [],
86
+ initScripts: [],
65
87
  failures: new Map(),
66
88
  };
67
89
 
68
- return {
90
+ const page = {
69
91
  state,
70
92
  /**
71
93
  * Make one in-page call fail, the way a closed page does.
@@ -112,7 +134,17 @@ export function createFakePage(options = {}) {
112
134
  const batches = state.mutations.splice(0, state.mutations.length);
113
135
  const dropped = state.droppedMutations;
114
136
  state.droppedMutations = 0;
115
- return { batches, dropped, installed: true };
137
+ return {
138
+ batches: batches.map((batch) => ({
139
+ frameId: 'main',
140
+ mainFrame: true,
141
+ ...batch,
142
+ })),
143
+ dropped,
144
+ installed: true,
145
+ frameId: 'main',
146
+ mainFrame: true,
147
+ };
116
148
  }
117
149
  return true;
118
150
  },
@@ -120,11 +152,73 @@ export function createFakePage(options = {}) {
120
152
  state.screenshots += 1;
121
153
  return state.screenshot;
122
154
  },
155
+ /**
156
+ * Register a script for every future document, the Playwright spelling.
157
+ *
158
+ * @param {Function} fn - The function to run
159
+ * @param {*} argument - Its argument
160
+ * @returns {Promise<void>} Resolves once registered
161
+ */
162
+ addInitScript: async (fn, argument) => {
163
+ state.initScripts.push({
164
+ name: typeof fn === 'function' ? fn.name : String(fn),
165
+ argument,
166
+ });
167
+ },
123
168
  on: (event, listener) => emitter.on(event, listener),
124
169
  off: (event, listener) => emitter.off(event, listener),
125
170
  emit: (event, payload) => emitter.emit(event, payload),
126
171
  listenerCount: (event) => emitter.listenerCount(event),
127
172
  };
173
+
174
+ if (options.frames) {
175
+ const children = options.frames.map((frame, index) =>
176
+ createFakeFrame(state, frame, `child-${index + 1}`)
177
+ );
178
+ page.frames = () => [
179
+ createFakeFrame(state, null, 'main', page),
180
+ ...children,
181
+ ];
182
+ }
183
+
184
+ return page;
185
+ }
186
+
187
+ /**
188
+ * One frame of a fake page.
189
+ *
190
+ * The main frame answers out of the page's own state, so a page with frames
191
+ * behaves exactly like one without for everything except the extra documents.
192
+ *
193
+ * @param {Object} state - The page's state
194
+ * @param {Object|null} frame - `{mutations, dropped}` for a child frame
195
+ * @param {string} frameId - What this frame calls itself
196
+ * @param {Object} [page] - The page, when this is the main frame
197
+ * @returns {Object} A frame with `evaluate`
198
+ */
199
+ function createFakeFrame(state, frame, frameId, page) {
200
+ if (page) {
201
+ return { evaluate: page.evaluate };
202
+ }
203
+ const queued = frame?.mutations ? [...frame.mutations] : [];
204
+ return {
205
+ evaluate: async (fn, argument) => {
206
+ const name = typeof fn === 'function' ? fn.name : String(fn);
207
+ state.evaluated.push({ name, argument, frameId });
208
+ if (name === 'drainMutationsInPage') {
209
+ return {
210
+ batches: queued
211
+ .splice(0, queued.length)
212
+ .map((batch) => ({ frameId, mainFrame: false, ...batch })),
213
+ dropped: frame?.dropped ?? 0,
214
+ installed: true,
215
+ frameId,
216
+ mainFrame: false,
217
+ };
218
+ }
219
+ return true;
220
+ },
221
+ };
128
222
  }
129
223
 
130
224
  /**
@@ -25,12 +25,19 @@ export const TRACE_PAGE = `<!doctype html>
25
25
  <select id="plan"><option value="free">Free</option><option value="pro">Pro</option></select>
26
26
  <label><input id="terms" type="checkbox"> I agree</label>
27
27
  </form>
28
- <ul id="items"></ul>
28
+ <ul id="items"><li id="item-a">a</li><li id="item-b">b</li></ul>
29
+ <div id="subtree"><p id="old-child">old</p></div>
29
30
  <p id="status" data-state="idle">idle</p>
31
+ <textarea id="notes"></textarea>
32
+ <div id="tall" style="height:3000px"></div>
30
33
  <iframe id="inner" name="inner" src="/frame" style="width:80px;height:40px"></iframe>
31
34
  <a id="next" href="/next">Go to the next page</a>
32
35
  <a id="download" href="/file/report.txt" download>Download the report</a>
33
36
  <button id="add" type="button">Add an item</button>
37
+ <button id="insert-first" type="button">Insert before the first item</button>
38
+ <button id="move-last" type="button">Move the last item to the front</button>
39
+ <button id="remove-one" type="button">Remove an item</button>
40
+ <button id="replace-subtree" type="button">Replace a subtree</button>
34
41
  <button id="touch" type="button">Change the status</button>
35
42
  <button id="log" type="button">Log something</button>
36
43
  <button id="boom" type="button">Throw</button>
@@ -44,6 +51,30 @@ export const TRACE_PAGE = `<!doctype html>
44
51
  item.textContent = 'item ' + added;
45
52
  document.getElementById('items').appendChild(item);
46
53
  });
54
+ // Issue #93's third regression case: a replay that only appends gets
55
+ // every one of these wrong.
56
+ const items = () => document.getElementById('items');
57
+ document.getElementById('insert-first').addEventListener('click', () => {
58
+ const item = document.createElement('li');
59
+ item.id = 'inserted';
60
+ item.textContent = 'inserted first';
61
+ items().insertBefore(item, items().firstChild);
62
+ });
63
+ document.getElementById('move-last').addEventListener('click', () => {
64
+ items().insertBefore(items().lastElementChild, items().firstChild);
65
+ });
66
+ document.getElementById('remove-one').addEventListener('click', () => {
67
+ const gone = document.getElementById('item-a');
68
+ if (gone) gone.remove();
69
+ });
70
+ document.getElementById('replace-subtree').addEventListener('click', () => {
71
+ const subtree = document.getElementById('subtree');
72
+ subtree.replaceChildren();
73
+ const fresh = document.createElement('p');
74
+ fresh.id = 'new-child';
75
+ fresh.textContent = 'new';
76
+ subtree.appendChild(fresh);
77
+ });
47
78
  document.getElementById('touch').addEventListener('click', () => {
48
79
  const status = document.getElementById('status');
49
80
  status.setAttribute('data-state', 'touched');
@@ -68,6 +99,27 @@ export const TRACE_PAGE = `<!doctype html>
68
99
  </script>
69
100
  </body></html>`;
70
101
 
102
+ /**
103
+ * A page that changes its own DOM while it is still loading.
104
+ *
105
+ * Issue #93's first regression case: the interval between a navigation and the
106
+ * next checkpoint used to be unrecorded, because the in-page observer died
107
+ * with the previous document. Everything this page builds happens before any
108
+ * automation could install an observer after the fact.
109
+ */
110
+ export const INIT_PAGE = `<!doctype html>
111
+ <html><head><title>Initializing page</title></head>
112
+ <body style="margin:0">
113
+ <div id="root"></div>
114
+ <script>
115
+ const made = document.createElement('p');
116
+ made.id = 'made-while-loading';
117
+ made.textContent = 'built during initialization';
118
+ document.getElementById('root').appendChild(made);
119
+ document.getElementById('root').setAttribute('data-initialized', 'yes');
120
+ </script>
121
+ </body></html>`;
122
+
71
123
  /** The page a full navigation lands on. */
72
124
  export const NEXT_PAGE = `<!doctype html>
73
125
  <html><head><title>Next page</title></head>
@@ -96,6 +148,10 @@ export async function startTraceServer() {
96
148
  sendHtml(res, TRACE_PAGE);
97
149
  return;
98
150
  }
151
+ if (path === '/init') {
152
+ sendHtml(res, INIT_PAGE);
153
+ return;
154
+ }
99
155
  if (path === '/next') {
100
156
  sendHtml(res, NEXT_PAGE);
101
157
  return;
@@ -271,22 +271,95 @@ describe('download manager (issue #88)', () => {
271
271
  },
272
272
  });
273
273
 
274
+ /**
275
+ * Describe the CDP command that sends downloads to this test's staging path.
276
+ *
277
+ * @param {string} [browserContextId] - Context to configure
278
+ * @returns {Object} Expected CDP command
279
+ */
280
+ const downloadBehaviorCommand = (browserContextId) => ({
281
+ method: 'Browser.setDownloadBehavior',
282
+ params: {
283
+ behavior: 'allowAndName',
284
+ downloadPath: path.join(root, STAGING_DIRECTORY),
285
+ eventsEnabled: true,
286
+ ...(browserContextId ? { browserContextId } : {}),
287
+ },
288
+ });
289
+
274
290
  it('should ask Chromium to report downloads it did not automate', async () => {
275
291
  const session = createFakeCdpSession();
276
292
  manager = await cdpManagerOver(session);
277
293
 
278
- assert.deepStrictEqual(session.sent, [
279
- {
280
- method: 'Browser.setDownloadBehavior',
281
- params: {
282
- behavior: 'allowAndName',
283
- downloadPath: path.join(root, STAGING_DIRECTORY),
284
- eventsEnabled: true,
285
- },
286
- },
294
+ assert.deepStrictEqual(session.sent, [downloadBehaviorCommand()]);
295
+ });
296
+
297
+ it('should scope download behavior to the page browser context', async () => {
298
+ // Issue #97: browser.newPage() and browser.newContext() create a
299
+ // non-default Chromium context. Browser download events are global, but
300
+ // the path configured without this id belongs to the default context.
301
+ const browserSession = createFakeCdpSession();
302
+ browserSession.send = async (method, params) => {
303
+ browserSession.sent.push({ method, params });
304
+ if (method === 'Target.getBrowserContexts') {
305
+ return { browserContextIds: ['context-from-target'] };
306
+ }
307
+ return undefined;
308
+ };
309
+ const pageSession = createFakeCdpSession();
310
+ pageSession.send = async (method, params) => {
311
+ pageSession.sent.push({ method, params });
312
+ return {
313
+ targetInfo: { browserContextId: 'context-from-target' },
314
+ };
315
+ };
316
+ let pageSessionDetached = false;
317
+ pageSession.detach = async () => {
318
+ pageSessionDetached = true;
319
+ };
320
+ const browser = {
321
+ newBrowserCDPSession: async () => browserSession,
322
+ };
323
+ const browserContext = {
324
+ browser: () => browser,
325
+ newCDPSession: async () => pageSession,
326
+ };
327
+ const page = { context: () => browserContext };
328
+
329
+ manager = await createDownloadManager({
330
+ engine: 'playwright',
331
+ browser: browserContext,
332
+ page,
333
+ directory: root,
334
+ });
335
+
336
+ assert.deepStrictEqual(pageSession.sent, [
337
+ { method: 'Target.getTargetInfo', params: undefined },
338
+ ]);
339
+ assert.strictEqual(pageSessionDetached, true);
340
+ assert.deepStrictEqual(browserSession.sent, [
341
+ { method: 'Target.getBrowserContexts', params: undefined },
342
+ downloadBehaviorCommand('context-from-target'),
287
343
  ]);
288
344
  });
289
345
 
346
+ it('should keep a persistent context on default download behavior', async () => {
347
+ const session = createFakeCdpSession();
348
+ const persistentContext = {
349
+ browser: () => null,
350
+ newCDPSession: async () => session,
351
+ };
352
+
353
+ manager = await createDownloadManager({
354
+ engine: 'playwright',
355
+ browser: persistentContext,
356
+ page: { context: () => persistentContext },
357
+ directory: root,
358
+ });
359
+
360
+ assert.deepStrictEqual(session.sent, [downloadBehaviorCommand()]);
361
+ });
362
+
290
363
  it('should place a manually started download like an automated one', async () => {
291
364
  const session = createFakeCdpSession();
292
365
  manager = await cdpManagerOver(session);
@@ -338,6 +411,137 @@ describe('download manager (issue #88)', () => {
338
411
  assert.match(cancelled[0].failure, /canceled/);
339
412
  });
340
413
 
414
+ it('should wait for staged bytes that land after the completion event', async () => {
415
+ // Issue #92: Chromium writes `<guid>.crdownload` and renames it, so the
416
+ // completion event can arrive before `<guid>` is openable. Publishing on
417
+ // the event alone turned a download that succeeded into
418
+ // `ENOENT ... .browser-commander-staging/<guid>`.
419
+ const session = createFakeCdpSession();
420
+ manager = await cdpManagerOver(session);
421
+ const completed = collect(DOWNLOAD_EVENT.COMPLETED);
422
+ const failed = collect(DOWNLOAD_EVENT.FAILED);
423
+
424
+ const guid = 'guid-0010';
425
+ const stagedPath = path.join(root, STAGING_DIRECTORY, guid);
426
+ session.emit('Browser.downloadWillBegin', {
427
+ guid,
428
+ url: 'https://example.com/late.pdf',
429
+ suggestedFilename: 'late.pdf',
430
+ });
431
+ // The event first, the bytes afterwards - the ordering from the report.
432
+ session.emit('Browser.downloadProgress', { guid, state: 'completed' });
433
+ await yieldToEngine();
434
+ assert.deepStrictEqual(completed, [], 'published before the bytes exist');
435
+ await fs.writeFile(stagedPath, 'late body');
436
+
437
+ await manager.idle();
438
+
439
+ assert.deepStrictEqual(failed, []);
440
+ assert.strictEqual(completed.length, 1);
441
+ assert.strictEqual(
442
+ await fs.readFile(completed[0].path, 'utf8'),
443
+ 'late body'
444
+ );
445
+ assert.deepStrictEqual(
446
+ await fs.readdir(path.join(root, STAGING_DIRECTORY)),
447
+ []
448
+ );
449
+ });
450
+
451
+ it('should not claim bytes while the partial file is still being written', async () => {
452
+ const session = createFakeCdpSession();
453
+ manager = await cdpManagerOver(session);
454
+ const completed = collect(DOWNLOAD_EVENT.COMPLETED);
455
+
456
+ const guid = 'guid-0011';
457
+ const stagedPath = path.join(root, STAGING_DIRECTORY, guid);
458
+ session.emit('Browser.downloadWillBegin', {
459
+ guid,
460
+ url: 'https://example.com/partial.pdf',
461
+ suggestedFilename: 'partial.pdf',
462
+ });
463
+ // A half-written file under the final name, with Chromium's own
464
+ // in-progress marker still beside it.
465
+ await fs.writeFile(stagedPath, 'half');
466
+ await fs.writeFile(`${stagedPath}.crdownload`, 'rest');
467
+ session.emit('Browser.downloadProgress', { guid, state: 'completed' });
468
+ await yieldToEngine();
469
+ await yieldToEngine();
470
+ assert.deepStrictEqual(completed, [], 'claimed a half-written file');
471
+
472
+ await fs.rm(`${stagedPath}.crdownload`);
473
+ await fs.writeFile(stagedPath, 'half and rest');
474
+ await manager.idle();
475
+
476
+ assert.strictEqual(completed.length, 1);
477
+ assert.strictEqual(
478
+ await fs.readFile(completed[0].path, 'utf8'),
479
+ 'half and rest'
480
+ );
481
+ });
482
+
483
+ it('should report a completion whose bytes never arrive as a failure', async () => {
484
+ const session = createFakeCdpSession();
485
+ manager = await createDownloadManager({
486
+ engine: 'puppeteer',
487
+ directory: root,
488
+ browser: {
489
+ target: () => ({ createCDPSession: async () => session }),
490
+ },
491
+ stagingTimeout: 60,
492
+ stagingPollInterval: 5,
493
+ });
494
+ const failed = collect(DOWNLOAD_EVENT.FAILED);
495
+ const completed = collect(DOWNLOAD_EVENT.COMPLETED);
496
+
497
+ session.emit('Browser.downloadWillBegin', {
498
+ guid: 'guid-0012',
499
+ url: 'https://example.com/ghost.pdf',
500
+ suggestedFilename: 'ghost.pdf',
501
+ });
502
+ session.emit('Browser.downloadProgress', {
503
+ guid: 'guid-0012',
504
+ state: 'completed',
505
+ });
506
+ await manager.idle();
507
+
508
+ assert.deepStrictEqual(completed, []);
509
+ assert.strictEqual(failed.length, 1);
510
+ assert.match(failed[0].failure, /was not readable within 60ms/);
511
+ assert.match(failed[0].failure, /guid-0012/);
512
+ });
513
+
514
+ it('should keep a capture waiting until the staged bytes are readable', async () => {
515
+ const session = createFakeCdpSession();
516
+ manager = await cdpManagerOver(session);
517
+
518
+ const guid = 'guid-0013';
519
+ const stagedPath = path.join(root, STAGING_DIRECTORY, guid);
520
+ const artifact = await manager.capture({
521
+ timeout: 5000,
522
+ action: async () => {
523
+ session.emit('Browser.downloadWillBegin', {
524
+ guid,
525
+ url: 'https://example.com/captured.pdf',
526
+ suggestedFilename: 'captured.pdf',
527
+ });
528
+ session.emit('Browser.downloadProgress', {
529
+ guid,
530
+ state: 'completed',
531
+ });
532
+ setTimeout(() => {
533
+ fs.writeFile(stagedPath, 'captured body').catch(() => {});
534
+ }, 40);
535
+ },
536
+ });
537
+
538
+ assert.strictEqual(artifact.state, DOWNLOAD_EVENT.COMPLETED);
539
+ assert.strictEqual(
540
+ await fs.readFile(artifact.path, 'utf8'),
541
+ 'captured body'
542
+ );
543
+ });
544
+
341
545
  it('should ignore progress for a download it never saw start', async () => {
342
546
  const session = createFakeCdpSession();
343
547
  manager = await cdpManagerOver(session);
@@ -0,0 +1,139 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ import {
7
+ DEFAULT_STAGING_POLL_INTERVAL,
8
+ DEFAULT_STAGING_STABILITY,
9
+ DEFAULT_STAGING_TIMEOUT,
10
+ describeStagingTimeout,
11
+ STAGING_IN_PROGRESS_SUFFIXES,
12
+ waitForStagedFile,
13
+ } from '../../../src/downloads/staging.js';
14
+ import { useTempDownloadDirectory } from '../../helpers/download-fixtures.js';
15
+
16
+ describe('waiting for staged bytes (issue #92)', () => {
17
+ const directory = useTempDownloadDirectory();
18
+
19
+ it('should report a file that is already complete', async () => {
20
+ const staged = path.join(directory.path, 'guid-ready');
21
+ await fs.writeFile(staged, 'complete body');
22
+
23
+ const settled = await waitForStagedFile({ path: staged, timeout: 1000 });
24
+
25
+ assert.strictEqual(settled.ready, true);
26
+ assert.strictEqual(settled.bytes, 'complete body'.length);
27
+ assert.strictEqual(settled.reason, null);
28
+ });
29
+
30
+ it('should wait for a file that appears after the completion event', async () => {
31
+ // This is issue #92 exactly: the browser says "completed" and the rename
32
+ // Chromium does behind it has not become visible yet.
33
+ const staged = path.join(directory.path, 'guid-late');
34
+ const appear = setTimeout(() => {
35
+ fs.writeFile(staged, 'late body').catch(() => {});
36
+ }, 60);
37
+
38
+ const settled = await waitForStagedFile({
39
+ path: staged,
40
+ timeout: 5000,
41
+ interval: 5,
42
+ });
43
+ clearTimeout(appear);
44
+
45
+ assert.strictEqual(settled.ready, true, settled.reason ?? '');
46
+ assert.strictEqual(settled.bytes, 'late body'.length);
47
+ });
48
+
49
+ it('should keep waiting while the file is still growing', async () => {
50
+ const staged = path.join(directory.path, 'guid-growing');
51
+ await fs.writeFile(staged, 'a');
52
+ const grow = setInterval(() => {
53
+ fs.appendFile(staged, 'a').catch(() => {});
54
+ }, 5);
55
+ setTimeout(() => clearInterval(grow), 80);
56
+
57
+ const settled = await waitForStagedFile({
58
+ path: staged,
59
+ timeout: 5000,
60
+ interval: 5,
61
+ });
62
+ clearInterval(grow);
63
+
64
+ assert.strictEqual(settled.ready, true, settled.reason ?? '');
65
+ // The quiet period ends the wait, so the file had stopped growing before
66
+ // it was declared ready.
67
+ assert.strictEqual(settled.bytes, (await fs.stat(staged)).size);
68
+ });
69
+
70
+ it('should survive a pause between writes that is longer than one poll', async () => {
71
+ const staged = path.join(directory.path, 'guid-paused');
72
+ await fs.writeFile(staged, 'first');
73
+ const resume = setTimeout(() => {
74
+ fs.appendFile(staged, '-second').catch(() => {});
75
+ }, 25);
76
+
77
+ const settled = await waitForStagedFile({
78
+ path: staged,
79
+ timeout: 1000,
80
+ interval: 5,
81
+ stability: 60,
82
+ });
83
+ clearTimeout(resume);
84
+
85
+ assert.strictEqual(settled.ready, true, settled.reason ?? '');
86
+ assert.strictEqual(settled.bytes, 'first-second'.length);
87
+ assert.ok(settled.waitedMs >= 60);
88
+ });
89
+
90
+ it('should not claim a file whose partial sibling is still being written', async () => {
91
+ const staged = path.join(directory.path, 'guid-partial');
92
+ await fs.writeFile(staged, 'looks done');
93
+ await fs.writeFile(`${staged}.crdownload`, 'still writing');
94
+
95
+ const settled = await waitForStagedFile({
96
+ path: staged,
97
+ timeout: 60,
98
+ interval: 5,
99
+ });
100
+
101
+ assert.strictEqual(settled.ready, false);
102
+ assert.match(settled.reason, /\.crdownload file is still being written/);
103
+ });
104
+
105
+ it('should fail explicitly when the bytes never arrive', async () => {
106
+ const staged = path.join(directory.path, 'guid-missing');
107
+
108
+ const settled = await waitForStagedFile({
109
+ path: staged,
110
+ timeout: 60,
111
+ interval: 5,
112
+ });
113
+
114
+ assert.strictEqual(settled.ready, false);
115
+ assert.strictEqual(settled.bytes, null);
116
+ assert.match(settled.reason, /has not appeared/);
117
+ });
118
+
119
+ it('should name the file, the budget and what it last saw', () => {
120
+ const message = describeStagingTimeout({
121
+ path: '/tmp/root/.browser-commander-staging/guid',
122
+ timeout: 250,
123
+ reason: 'the file has not appeared yet',
124
+ });
125
+
126
+ assert.match(message, /reported the download as completed/);
127
+ assert.match(message, /guid/);
128
+ assert.match(message, /250ms/);
129
+ assert.match(message, /has not appeared yet/);
130
+ });
131
+
132
+ it('should ship defaults a download can actually finish within', () => {
133
+ assert.ok(DEFAULT_STAGING_TIMEOUT >= 1000);
134
+ assert.ok(DEFAULT_STAGING_POLL_INTERVAL > 0);
135
+ assert.ok(DEFAULT_STAGING_STABILITY > DEFAULT_STAGING_POLL_INTERVAL);
136
+ assert.ok(DEFAULT_STAGING_POLL_INTERVAL < DEFAULT_STAGING_TIMEOUT);
137
+ assert.ok(STAGING_IN_PROGRESS_SUFFIXES.includes('.crdownload'));
138
+ });
139
+ });
@@ -155,6 +155,14 @@ jobs:
155
155
  assert.equal(countFailures(content), 2);
156
156
  });
157
157
 
158
+ it('rejects download-artifact v7 after its runtime emitted DEP0005', () => {
159
+ const content = workflow({
160
+ steps: ' - uses: actions/download-artifact@v7',
161
+ });
162
+
163
+ assert.equal(countFailures(content), 1);
164
+ });
165
+
158
166
  it('accepts a base_ref bound to any environment variable name', () => {
159
167
  const content = workflow({
160
168
  on: 'pull_request',
@@ -131,6 +131,7 @@ describe('test runner helper', () => {
131
131
  buildNodeTestArgs(options, ['tests/unit/a.test.js'], '24.18.0'),
132
132
  [
133
133
  '--test',
134
+ '--throw-deprecation',
134
135
  '--test-isolation=none',
135
136
  '--experimental-test-coverage',
136
137
  '--test-reporter',
@@ -144,8 +145,8 @@ describe('test runner helper', () => {
144
145
  const options = parseTestRunnerArgs(['tests/unit']);
145
146
 
146
147
  assert.deepEqual(
147
- buildNodeTestArgs(options, ['tests/unit/a.test.js'], '20.20.2'),
148
- ['--test', 'tests/unit/a.test.js']
148
+ buildNodeTestArgs(options, ['tests/unit/a.test.js'], '22.22.3'),
149
+ ['--test', '--throw-deprecation', 'tests/unit/a.test.js']
149
150
  );
150
151
  });
151
152
  });
@@ -48,7 +48,8 @@ const MIRRORED_COMMANDS = [
48
48
  {
49
49
  id: 'python-mypy-scripts',
50
50
  workflow: 'python.yml',
51
- command: 'mypy --python-version 3.13 scripts tests/unit/scripts',
51
+ command:
52
+ 'mypy --no-warn-unused-configs --python-version 3.13 scripts tests/unit/scripts',
52
53
  },
53
54
  {
54
55
  id: 'rust-fmt',