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.
- package/.jscpd-baseline.json +8 -0
- package/CHANGELOG.md +45 -0
- package/README.md +50 -2
- package/package.json +8 -3
- package/scripts/run-tests.mjs +4 -2
- package/src/downloads/manager.js +40 -8
- package/src/downloads/sources.js +127 -6
- package/src/downloads/staging.js +158 -0
- package/src/exports.js +14 -0
- package/src/traces/bundle.js +26 -3
- package/src/traces/deadline.js +33 -0
- package/src/traces/identity.js +127 -0
- package/src/traces/init-script.js +95 -0
- package/src/traces/links.js +623 -0
- package/src/traces/mutation-stream.js +232 -0
- package/src/traces/observers.js +29 -3
- package/src/traces/page-capture.js +251 -15
- package/src/traces/recorder.js +124 -94
- package/src/traces/schema.js +65 -1
- package/src/traces/viewer.js +157 -11
- package/tests/e2e/downloads.e2e.test.js +126 -2
- package/tests/e2e/traces.e2e.test.js +296 -11
- package/tests/helpers/download-server.js +26 -6
- package/tests/helpers/trace-fixtures.js +97 -3
- package/tests/helpers/trace-server.js +57 -1
- package/tests/unit/downloads/manager.test.js +213 -9
- package/tests/unit/downloads/staging.test.js +139 -0
- 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 +564 -0
- package/tests/unit/traces/reader.test.js +10 -4
- package/tests/unit/traces/recorder.test.js +172 -1
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
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);
|
|
@@ -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'], '
|
|
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',
|