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 +11 -0
- package/package.json +7 -3
- package/scripts/run-tests.mjs +4 -2
- package/src/downloads/manager.js +9 -0
- package/src/downloads/sources.js +71 -0
- package/src/downloads/staging.js +21 -9
- package/src/traces/bundle.js +10 -2
- package/src/traces/recorder.js +16 -3
- package/tests/e2e/downloads.e2e.test.js +79 -2
- package/tests/helpers/trace-fixtures.js +18 -0
- package/tests/unit/downloads/manager.test.js +82 -9
- package/tests/unit/downloads/staging.test.js +24 -2
- package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
- package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
- package/tests/unit/scripts/pre-commit-config.test.js +2 -1
- package/tests/unit/traces/bundle.test.js +24 -3
- package/tests/unit/traces/links.test.js +7 -0
- package/tests/unit/traces/reader.test.js +10 -4
- package/tests/unit/traces/recorder.test.js +3 -0
- package/tests/unit/traces/viewer.test.js +4 -0
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.
|
|
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": ">=
|
|
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": "^
|
|
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
|
}
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -147,13 +147,15 @@ export function buildNodeTestArgs(
|
|
|
147
147
|
testFiles,
|
|
148
148
|
nodeVersion = process.versions.node
|
|
149
149
|
) {
|
|
150
|
-
|
|
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
|
|
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
|
}
|
package/src/downloads/manager.js
CHANGED
|
@@ -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
|
}
|
package/src/downloads/sources.js
CHANGED
|
@@ -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
|
*
|
package/src/downloads/staging.js
CHANGED
|
@@ -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
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
package/src/traces/bundle.js
CHANGED
|
@@ -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
|
|
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`,
|
package/src/traces/recorder.js
CHANGED
|
@@ -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 {
|
|
21
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
//
|
|
65
|
-
//
|
|
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'], '
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
33
|
-
|
|
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 () => {
|