browser-commander 0.19.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Persist managed downloads from Playwright pages in non-default Chromium browser
8
+ contexts. Browser Commander now resolves the page target's `browserContextId`
9
+ and applies its staging directory to that context while retaining browser-wide
10
+ download lifecycle events.
11
+
12
+ Wait for a real quiet period before accepting staged download bytes, close unfinished trace bundles explicitly in tests, and move the supported Node.js floor to 22 with the maintained `better-sqlite3` fallback.
13
+
3
14
  ## 0.19.0
4
15
 
5
16
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "homepage": "https://github.com/link-foundation/browser-commander#readme",
54
54
  "engines": {
55
- "node": ">=20.0.0"
55
+ "node": ">=22.0.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "playwright": ">=1.40.0",
@@ -67,7 +67,7 @@
67
67
  }
68
68
  },
69
69
  "dependencies": {
70
- "better-sqlite3": "^12.11.1",
70
+ "better-sqlite3": "^13.0.3",
71
71
  "links-notation": "^0.20.0",
72
72
  "log-lazy": "^1.0.4",
73
73
  "test-anywhere": "^0.9.1"
@@ -83,5 +83,9 @@
83
83
  "playwright": "^1.62.1",
84
84
  "prettier": "^3.9.6",
85
85
  "puppeteer": "^25.10.0"
86
+ },
87
+ "allowScripts": {
88
+ "better-sqlite3@13.0.3": true,
89
+ "puppeteer": false
86
90
  }
87
91
  }
@@ -147,13 +147,15 @@ export function buildNodeTestArgs(
147
147
  testFiles,
148
148
  nodeVersion = process.versions.node
149
149
  ) {
150
- const args = ['--test'];
150
+ // A resource leak that is a warning today becomes a runtime failure in a
151
+ // later Node release. Make deprecations fail at the point that creates them.
152
+ const args = ['--test', '--throw-deprecation'];
151
153
 
152
154
  // Node's default process isolation serializes each test file's results over
153
155
  // a child-process channel. Node 24 on macOS can intermittently corrupt that
154
156
  // stream (ERR_TEST_FAILURE: "Unable to deserialize cloned data"). Running
155
157
  // supported runtimes in-process removes that transport while keeping the
156
- // Node 20-compatible fallback for consumers on the minimum engine version.
158
+ // Node 22-compatible fallback for consumers on the minimum engine version.
157
159
  if (supportsTestIsolation(nodeVersion)) {
158
160
  args.push('--test-isolation=none');
159
161
  }
@@ -22,6 +22,7 @@ import {
22
22
  import {
23
23
  attachCdpSource,
24
24
  attachPlaywrightSource,
25
+ browserContextIdForPage,
25
26
  classifyFailure,
26
27
  DOWNLOAD_FAILURE,
27
28
  openBrowserCdpSession,
@@ -308,17 +309,25 @@ export async function createDownloadManager(options = {}) {
308
309
  try {
309
310
  cdpSession = await openBrowserCdpSession({ engine, browser, page });
310
311
  if (cdpSession) {
312
+ const browserContextId = await browserContextIdForPage({
313
+ engine,
314
+ browser,
315
+ page,
316
+ browserSession: cdpSession,
317
+ });
311
318
  attached.push(
312
319
  await attachCdpSource({
313
320
  session: cdpSession,
314
321
  root,
315
322
  sink,
323
+ browserContextId,
316
324
  stagingTimeout,
317
325
  stagingPollInterval,
318
326
  })
319
327
  );
320
328
  }
321
329
  } catch (error) {
330
+ await cdpSession?.detach?.().catch?.(() => {});
322
331
  cdpSession = undefined;
323
332
  log?.warn?.(`manual downloads are not observable: ${error.message}`);
324
333
  }
@@ -92,6 +92,7 @@ export function attachPlaywrightSource({ context, sink }) {
92
92
  * @param {Object} options.session - CDP session with Browser domain access
93
93
  * @param {string} options.root - Managed download directory
94
94
  * @param {Object} options.sink - `{started, finished, failed, track}` callbacks
95
+ * @param {string} [options.browserContextId] - Chromium context to configure
95
96
  * @param {number} [options.stagingTimeout] - Budget for the bytes to land on disk
96
97
  * @param {number} [options.stagingPollInterval] - Milliseconds between readings
97
98
  * @returns {Promise<Object>} Handle with `detach()` and `stagingDirectory`
@@ -100,6 +101,7 @@ export async function attachCdpSource({
100
101
  session,
101
102
  root,
102
103
  sink,
104
+ browserContextId,
103
105
  stagingTimeout,
104
106
  stagingPollInterval,
105
107
  }) {
@@ -116,6 +118,7 @@ export async function attachCdpSource({
116
118
  behavior: 'allowAndName',
117
119
  downloadPath: stagingDirectory,
118
120
  eventsEnabled: true,
121
+ ...(browserContextId ? { browserContextId } : {}),
119
122
  });
120
123
 
121
124
  const byGuid = new Map();
@@ -200,6 +203,74 @@ export async function attachCdpSource({
200
203
  };
201
204
  }
202
205
 
206
+ /**
207
+ * Read the Chromium browser-context id that owns a page.
208
+ *
209
+ * `Browser.setDownloadBehavior` is sent over a browser-wide session so its
210
+ * events cover the whole browser, but omitting `browserContextId` configures
211
+ * only Chromium's default context. Playwright's `browser.newPage()` convenience
212
+ * API creates a non-default context, whose id is exposed by its target.
213
+ *
214
+ * @param {Object} options - Browser handles
215
+ * @param {string} options.engine - Engine name
216
+ * @param {Object} [options.browser] - Browser or persistent context
217
+ * @param {Object} [options.page] - Page whose context downloads belong to
218
+ * @param {Object} [options.browserSession] - Browser-wide CDP session
219
+ * @returns {Promise<string|undefined>} Chromium context id, when non-default
220
+ */
221
+ export async function browserContextIdForPage({
222
+ engine,
223
+ browser,
224
+ page,
225
+ browserSession,
226
+ }) {
227
+ if (engine !== 'playwright' || !page) {
228
+ return undefined;
229
+ }
230
+
231
+ const context = page.context?.() ?? browser;
232
+ // Playwright exposes a persistent context as the browser's default context:
233
+ // unlike browser.newContext(), its `browser()` is null. Keeping the id
234
+ // absent preserves default-context behavior, including downloads from tabs
235
+ // opened outside the automation.
236
+ if (typeof context?.browser === 'function' && !context.browser()) {
237
+ return undefined;
238
+ }
239
+ const pageSession = await context?.newCDPSession?.(page);
240
+ if (!pageSession) {
241
+ return undefined;
242
+ }
243
+
244
+ let browserContextId;
245
+ try {
246
+ const { targetInfo } = await pageSession.send('Target.getTargetInfo');
247
+ browserContextId = targetInfo?.browserContextId || undefined;
248
+ } finally {
249
+ // This session exists only for target metadata. A failure to detach it
250
+ // must not discard the id that lets the long-lived source work correctly.
251
+ await pageSession.detach?.().catch?.(() => {});
252
+ }
253
+ if (!browserContextId || !browserSession) {
254
+ return browserContextId;
255
+ }
256
+
257
+ try {
258
+ // TargetInfo also reports an opaque id for a persistent/default context in
259
+ // current Chromium. Only ids returned here are true non-default contexts
260
+ // accepted by Browser.setDownloadBehavior's browserContextId parameter.
261
+ const { browserContextIds = [] } = await browserSession.send(
262
+ 'Target.getBrowserContexts'
263
+ );
264
+ return browserContextIds.includes(browserContextId)
265
+ ? browserContextId
266
+ : undefined;
267
+ } catch {
268
+ // Older/page-scoped CDP bridges may not expose getBrowserContexts. The
269
+ // persistent-context guard above handles their known default-context case.
270
+ return browserContextId;
271
+ }
272
+ }
273
+
203
274
  /**
204
275
  * Open a CDP session that can drive the Browser domain.
205
276
  *
@@ -26,6 +26,9 @@ export const DEFAULT_STAGING_TIMEOUT = 10000;
26
26
  /** How often the staged file is looked at while waiting. */
27
27
  export const DEFAULT_STAGING_POLL_INTERVAL = 25;
28
28
 
29
+ /** How long an unchanged size must remain observable before it is complete. */
30
+ export const DEFAULT_STAGING_STABILITY = 100;
31
+
29
32
  /** Suffixes Chromium uses for a file it is still writing. */
30
33
  export const STAGING_IN_PROGRESS_SUFFIXES = Object.freeze([
31
34
  '.crdownload',
@@ -77,14 +80,15 @@ async function observeStagedFile(filePath, suffixes) {
77
80
  /**
78
81
  * Wait until a staged download is readable and has stopped growing.
79
82
  *
80
- * Stability is decided by two consecutive readings of the same size, the same
81
- * evidence the Selenium directory watcher uses: a file that merely exists is
82
- * not evidence that its last byte was written.
83
+ * Stability is decided by an unchanged-size quiet period. Two equal readings
84
+ * only prove that no write landed between those particular polls; a stalled
85
+ * writer can pause for longer than one interval and resume afterwards.
83
86
  *
84
87
  * @param {Object} options - Wait options
85
88
  * @param {string} options.path - Path the engine staged the download under
86
89
  * @param {number} [options.timeout] - Total budget in milliseconds
87
90
  * @param {number} [options.interval] - Milliseconds between readings
91
+ * @param {number} [options.stability] - Required unchanged-size quiet period
88
92
  * @param {string[]} [options.inProgressSuffixes] - Suffixes of a partial file
89
93
  * @returns {Promise<{ready: boolean, bytes: number|null, reason: string|null, waitedMs: number}>} Outcome
90
94
  */
@@ -93,11 +97,13 @@ export async function waitForStagedFile(options = {}) {
93
97
  path: filePath,
94
98
  timeout = DEFAULT_STAGING_TIMEOUT,
95
99
  interval = DEFAULT_STAGING_POLL_INTERVAL,
100
+ stability = DEFAULT_STAGING_STABILITY,
96
101
  inProgressSuffixes = STAGING_IN_PROGRESS_SUFFIXES,
97
102
  } = options;
98
103
 
99
104
  const deadline = createDeadline({ timeout });
100
105
  let previousSize;
106
+ let stableSince;
101
107
  // Always set by the reading below before the deadline can be checked.
102
108
  let reason;
103
109
 
@@ -107,15 +113,21 @@ export async function waitForStagedFile(options = {}) {
107
113
  reason = seen.reason;
108
114
  // A file that vanished has to prove itself stable again from scratch.
109
115
  previousSize = undefined;
116
+ stableSince = undefined;
110
117
  } else if (seen.size === previousSize) {
111
- return {
112
- ready: true,
113
- bytes: seen.size,
114
- reason: null,
115
- waitedMs: deadline.elapsedMs(),
116
- };
118
+ const stableMs = deadline.elapsedMs() - stableSince;
119
+ if (stableMs >= stability) {
120
+ return {
121
+ ready: true,
122
+ bytes: seen.size,
123
+ reason: null,
124
+ waitedMs: deadline.elapsedMs(),
125
+ };
126
+ }
127
+ reason = `the file had been unchanged for only ${stableMs}ms`;
117
128
  } else {
118
129
  previousSize = seen.size;
130
+ stableSince = deadline.elapsedMs();
119
131
  reason = `the file was still growing at ${seen.size} bytes`;
120
132
  }
121
133
 
@@ -58,6 +58,7 @@ export async function openTraceBundle(options = {}) {
58
58
 
59
59
  const eventsPath = path.join(root, TRACE_FILES.EVENTS);
60
60
  const eventsHandle = await fs.open(eventsPath, 'a', TRACE_FILE_MODE);
61
+ let closeEventsPromise;
61
62
 
62
63
  let written = 0;
63
64
  let sequence = 0;
@@ -69,6 +70,12 @@ export async function openTraceBundle(options = {}) {
69
70
  const counts = { checkpoints: 0, events: 0, mutationBatches: 0 };
70
71
  const problems = [];
71
72
 
73
+ /** Close the timeline exactly once, after every queued append. */
74
+ function closeEvents() {
75
+ closeEventsPromise ??= appendQueue.then(() => eventsHandle.close());
76
+ return closeEventsPromise;
77
+ }
78
+
72
79
  const fits = (bytes) =>
73
80
  bytes <= maxResourceBytes && written + bytes <= maxBundleBytes;
74
81
 
@@ -308,6 +315,8 @@ export async function openTraceBundle(options = {}) {
308
315
  writeMutations,
309
316
  writeArtifact,
310
317
  drop,
318
+ /** Close an unfinished bundle without manufacturing a manifest. */
319
+ abort: closeEvents,
311
320
  /**
312
321
  * Write the manifest and close the timeline.
313
322
  *
@@ -325,8 +334,7 @@ export async function openTraceBundle(options = {}) {
325
334
  : manifest.outcome,
326
335
  };
327
336
  // Anything a listener reported on its way out is still queued.
328
- await appendQueue;
329
- await eventsHandle.close();
337
+ await closeEvents();
330
338
  await fs.writeFile(
331
339
  path.join(root, TRACE_FILES.MANIFEST),
332
340
  `${JSON.stringify(finished, null, 2)}\n`,
@@ -74,6 +74,18 @@ function normalizeEvents(events) {
74
74
  return [...new Set(events)];
75
75
  }
76
76
 
77
+ /**
78
+ * Reject a malformed side export before the bundle opens any file handles.
79
+ *
80
+ * @param {Object|null} links - Optional Links Notation export options
81
+ * @returns {void}
82
+ */
83
+ function validateLinksOptions(links) {
84
+ if (links && (typeof links.output !== 'string' || links.output === '')) {
85
+ throw new Error('trace links require an output path');
86
+ }
87
+ }
88
+
77
89
  /**
78
90
  * Start recording a session.
79
91
  *
@@ -111,6 +123,10 @@ export async function startTrace(options = {}) {
111
123
  }
112
124
  const eventSources = normalizeEvents(events);
113
125
  const privacyOptions = normalizePrivacyOptions(privacy);
126
+ // Validate the optional side export before opening the authoritative bundle.
127
+ // Otherwise a rejected start (for example `links: {}`) strands the bundle's
128
+ // events handle because no running trace is returned for the caller to stop.
129
+ validateLinksOptions(links);
114
130
  const engine = commander?.engine ?? null;
115
131
  const identity = createTraceIdentity({ page });
116
132
 
@@ -139,9 +155,6 @@ export async function startTrace(options = {}) {
139
155
  const startedAt = new Date(now()).toISOString();
140
156
 
141
157
  if (links) {
142
- if (typeof links.output !== 'string' || links.output === '') {
143
- throw new Error('trace links require an output path');
144
- }
145
158
  linksSink = await openTraceLinks({
146
159
  output: links.output,
147
160
  include: links.include,
@@ -17,8 +17,12 @@ import fs from 'node:fs/promises';
17
17
  import os from 'node:os';
18
18
  import path from 'node:path';
19
19
 
20
- import { openBrowserCdpSession } from '../../src/downloads/sources.js';
21
- import { launchE2EBrowser } from '../helpers/e2e-browser.js';
20
+ import {
21
+ browserContextIdForPage,
22
+ openBrowserCdpSession,
23
+ } from '../../src/downloads/sources.js';
24
+ import { makeBrowserCommander } from '../../src/factory.js';
25
+ import { launchE2EBrowser, SANDBOX_ARGS } from '../helpers/e2e-browser.js';
22
26
  import {
23
27
  BLOB_PDF_BODY,
24
28
  PDF_BODY,
@@ -202,9 +206,16 @@ function describeDownloads(engine) {
202
206
  // than through the engine's download API. Issue #88 asks for "one
203
207
  // lifecycle for automated *and* manual downloads".
204
208
  const session = await openBrowserCdpSession({ engine, browser, page });
209
+ const browserContextId = await browserContextIdForPage({
210
+ engine,
211
+ browser,
212
+ page,
213
+ browserSession: session,
214
+ });
205
215
  const captured = downloads.capture({ timeout: 20000 });
206
216
  await session.send('Target.createTarget', {
207
217
  url: `${server.baseUrl}/file/manual.pdf`,
218
+ ...(browserContextId ? { browserContextId } : {}),
208
219
  });
209
220
 
210
221
  const artifact = await captured;
@@ -231,5 +242,71 @@ describe(
231
242
  for (const engine of ENGINES) {
232
243
  describeDownloads(engine);
233
244
  }
245
+
246
+ it('should persist a Blob from browser.newPage() (issue #97)', async () => {
247
+ const { chromium } = await import('playwright');
248
+ const directory = await fs.mkdtemp(
249
+ path.join(os.tmpdir(), 'bc-e2e-context-downloads-')
250
+ );
251
+ const browser = await chromium.launch({
252
+ headless: true,
253
+ args: SANDBOX_ARGS,
254
+ });
255
+ const page = await browser.newPage();
256
+ const commander = makeBrowserCommander({
257
+ page,
258
+ enableNetworkTracking: false,
259
+ enableNavigationManager: false,
260
+ enableDialogManager: false,
261
+ });
262
+
263
+ try {
264
+ const downloads = await commander.configureDownloads({
265
+ directory,
266
+ persist: true,
267
+ });
268
+ const completed = [];
269
+ downloads.on('completed', (artifact) => completed.push(artifact));
270
+ await page.setContent(`
271
+ <button id="download">Download</button>
272
+ <script>
273
+ document.querySelector('#download').addEventListener('click', () => {
274
+ const blob = new Blob([${JSON.stringify(BLOB_PDF_BODY)}], {
275
+ type: 'application/pdf',
276
+ });
277
+ const anchor = document.createElement('a');
278
+ anchor.href = URL.createObjectURL(blob);
279
+ anchor.download = 'declaration.pdf';
280
+ anchor.click();
281
+ });
282
+ </script>
283
+ `);
284
+
285
+ const artifact = await downloads.capture({
286
+ action: page.click.bind(page, '#download'),
287
+ });
288
+
289
+ assert.strictEqual(artifact.state, 'completed');
290
+ assert.strictEqual(path.basename(artifact.path), 'declaration.pdf');
291
+ assert.strictEqual(
292
+ await fs.readFile(artifact.path, 'utf8'),
293
+ BLOB_PDF_BODY
294
+ );
295
+ assert.strictEqual(completed.length, 1);
296
+ const staged = await fs.readdir(
297
+ path.join(directory, '.browser-commander-staging')
298
+ );
299
+ assert.deepStrictEqual(staged, []);
300
+ assert.ok(
301
+ !(await fs.readdir(directory)).some((entry) =>
302
+ entry.endsWith('.partial')
303
+ )
304
+ );
305
+ } finally {
306
+ await commander.destroy();
307
+ await browser.close();
308
+ await fs.rm(directory, { recursive: true, force: true });
309
+ }
310
+ });
234
311
  }
235
312
  );
@@ -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
  *
@@ -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);
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
 
6
6
  import {
7
7
  DEFAULT_STAGING_POLL_INTERVAL,
8
+ DEFAULT_STAGING_STABILITY,
8
9
  DEFAULT_STAGING_TIMEOUT,
9
10
  describeStagingTimeout,
10
11
  STAGING_IN_PROGRESS_SUFFIXES,
@@ -61,11 +62,31 @@ describe('waiting for staged bytes (issue #92)', () => {
61
62
  clearInterval(grow);
62
63
 
63
64
  assert.strictEqual(settled.ready, true, settled.reason ?? '');
64
- // Two equal readings are what ends the wait, so the file had stopped
65
- // growing before it was declared ready.
65
+ // The quiet period ends the wait, so the file had stopped growing before
66
+ // it was declared ready.
66
67
  assert.strictEqual(settled.bytes, (await fs.stat(staged)).size);
67
68
  });
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
+
69
90
  it('should not claim a file whose partial sibling is still being written', async () => {
70
91
  const staged = path.join(directory.path, 'guid-partial');
71
92
  await fs.writeFile(staged, 'looks done');
@@ -111,6 +132,7 @@ describe('waiting for staged bytes (issue #92)', () => {
111
132
  it('should ship defaults a download can actually finish within', () => {
112
133
  assert.ok(DEFAULT_STAGING_TIMEOUT >= 1000);
113
134
  assert.ok(DEFAULT_STAGING_POLL_INTERVAL > 0);
135
+ assert.ok(DEFAULT_STAGING_STABILITY > DEFAULT_STAGING_POLL_INTERVAL);
114
136
  assert.ok(DEFAULT_STAGING_POLL_INTERVAL < DEFAULT_STAGING_TIMEOUT);
115
137
  assert.ok(STAGING_IN_PROGRESS_SUFFIXES.includes('.crdownload'));
116
138
  });
@@ -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',
@@ -16,10 +16,14 @@ import {
16
16
  TRACE_MODE,
17
17
  TRACE_OUTCOME,
18
18
  } from '../../../src/traces/schema.js';
19
- import { useTempTraceDirectory } from '../../helpers/trace-fixtures.js';
19
+ import {
20
+ useTempTraceDirectory,
21
+ useTraceBundleCleanup,
22
+ } from '../../helpers/trace-fixtures.js';
20
23
 
21
24
  describe('trace bundle writer (issue #87)', () => {
22
25
  const directory = useTempTraceDirectory();
26
+ const cleanup = useTraceBundleCleanup();
23
27
 
24
28
  /**
25
29
  * Open a bundle inside the suite's temporary directory.
@@ -27,8 +31,13 @@ describe('trace bundle writer (issue #87)', () => {
27
31
  * @param {Object} [options] - Options for `openTraceBundle`
28
32
  * @returns {Promise<Object>} The bundle writer
29
33
  */
30
- const open = (options = {}) =>
31
- openTraceBundle({ output: path.join(directory.path, 'run'), ...options });
34
+ const open = async (options = {}) =>
35
+ cleanup(
36
+ await openTraceBundle({
37
+ output: path.join(directory.path, 'run'),
38
+ ...options,
39
+ })
40
+ );
32
41
 
33
42
  /**
34
43
  * List a bundle's members, relative to its root.
@@ -66,6 +75,18 @@ describe('trace bundle writer (issue #87)', () => {
66
75
  await assert.rejects(openTraceBundle({}), /trace output must be a path/);
67
76
  });
68
77
 
78
+ it('should abort an unfinished bundle without writing a manifest', async () => {
79
+ const bundle = await open();
80
+ await bundle.appendEvent({ kind: TRACE_EVENT.CHECKPOINT });
81
+
82
+ await bundle.abort();
83
+ await bundle.abort();
84
+
85
+ await assert.rejects(
86
+ fs.access(path.join(bundle.root, TRACE_FILES.MANIFEST))
87
+ );
88
+ });
89
+
69
90
  it('should write the layout the format documents', async () => {
70
91
  const bundle = await open();
71
92
 
@@ -27,6 +27,7 @@ import {
27
27
  createFakePage,
28
28
  makeSnapshot,
29
29
  useTempTraceDirectory,
30
+ useTraceBundleCleanup,
30
31
  } from '../../helpers/trace-fixtures.js';
31
32
 
32
33
  /**
@@ -66,6 +67,7 @@ function parseExport(text) {
66
67
 
67
68
  describe('trace links export (issue #94)', () => {
68
69
  const directory = useTempTraceDirectory();
70
+ const trackOpenBundle = useTraceBundleCleanup();
69
71
 
70
72
  /**
71
73
  * Write a bundle holding one of everything the export has to say.
@@ -76,6 +78,7 @@ describe('trace links export (issue #94)', () => {
76
78
  const writeBundle = async ({ close = true } = {}) => {
77
79
  const root = path.join(directory.path, 'run.bc-trace');
78
80
  const bundle = await openTraceBundle({ output: root });
81
+ trackOpenBundle(bundle);
79
82
  const owner = {
80
83
  traceId: 'trace-1',
81
84
  browserContextId: 'context-1',
@@ -457,6 +460,10 @@ describe('trace links export (issue #94)', () => {
457
460
  );
458
461
  // No closing link, which is how a reader tells this run never finished.
459
462
  assert.ok(!links.some((link) => link.id === 'result'));
463
+
464
+ // The assertions above model the process being killed. The test process
465
+ // itself survives, so release its open descriptor explicitly.
466
+ await trace.stop();
460
467
  });
461
468
 
462
469
  it('should take the export away with a discarded bundle', async () => {
@@ -16,10 +16,14 @@ import {
16
16
  TRACE_MODE,
17
17
  TRACE_OUTCOME,
18
18
  } from '../../../src/traces/schema.js';
19
- import { useTempTraceDirectory } from '../../helpers/trace-fixtures.js';
19
+ import {
20
+ useTempTraceDirectory,
21
+ useTraceBundleCleanup,
22
+ } from '../../helpers/trace-fixtures.js';
20
23
 
21
24
  describe('trace reader (issue #87)', () => {
22
25
  const directory = useTempTraceDirectory();
26
+ const cleanup = useTraceBundleCleanup();
23
27
 
24
28
  /**
25
29
  * Write a small bundle the tests can read back.
@@ -29,9 +33,11 @@ describe('trace reader (issue #87)', () => {
29
33
  * @returns {Promise<Object>} The bundle writer
30
34
  */
31
35
  const writeBundle = async ({ close = true } = {}) => {
32
- const bundle = await openTraceBundle({
33
- output: path.join(directory.path, 'run'),
34
- });
36
+ const bundle = cleanup(
37
+ await openTraceBundle({
38
+ output: path.join(directory.path, 'run'),
39
+ })
40
+ );
35
41
  await bundle.appendEvent({
36
42
  kind: TRACE_EVENT.TRACE_START,
37
43
  mode: TRACE_MODE.CHECKPOINTS,
@@ -172,6 +172,7 @@ describe('trace recorder (issue #87)', () => {
172
172
  await trace.checkpoint('start');
173
173
 
174
174
  assert.strictEqual(page.state.screenshots, 0);
175
+ await trace.stop();
175
176
  });
176
177
 
177
178
  it('should take a screenshot only on failure when asked', async () => {
@@ -182,6 +183,7 @@ describe('trace recorder (issue #87)', () => {
182
183
 
183
184
  await trace.checkpoint('the end', { reason: 'failure' });
184
185
  assert.strictEqual(page.state.screenshots, 1);
186
+ await trace.stop();
185
187
  });
186
188
 
187
189
  it('should keep a gap rather than fail when the page has closed', async () => {
@@ -237,6 +239,7 @@ describe('trace recorder (issue #87)', () => {
237
239
  // Once at the start, and once after each of the two checkpoints - the
238
240
  // base one the recorder takes itself and the one named above.
239
241
  assert.strictEqual(installs.length, 3);
242
+ await trace.stop();
240
243
  });
241
244
  });
242
245
 
@@ -282,6 +282,10 @@ describe('offline trace viewer (issue #87)', () => {
282
282
 
283
283
  assert.match(body, /outcome truncated/);
284
284
  assert.match(body, /the last thing it did|1 checkpoints/);
285
+
286
+ // The assertions model an interrupted process; this test process keeps
287
+ // running, so explicitly release the descriptor afterwards.
288
+ await trace.stop();
285
289
  });
286
290
 
287
291
  it('should refuse a directory that is not a bundle', async () => {