browser-commander 0.17.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.jscpd-baseline.json +12 -17
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +132 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +5 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +476 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +238 -0
  19. package/src/downloads/staging.js +146 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +86 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +338 -0
  29. package/src/traces/deadline.js +33 -0
  30. package/src/traces/identity.js +127 -0
  31. package/src/traces/init-script.js +95 -0
  32. package/src/traces/links.js +623 -0
  33. package/src/traces/mutation-stream.js +232 -0
  34. package/src/traces/observers.js +272 -0
  35. package/src/traces/page-capture.js +601 -0
  36. package/src/traces/reader.js +226 -0
  37. package/src/traces/recorder.js +454 -0
  38. package/src/traces/redaction.js +231 -0
  39. package/src/traces/schema.js +229 -0
  40. package/src/traces/viewer.js +448 -0
  41. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  42. package/tests/e2e/downloads.e2e.test.js +235 -0
  43. package/tests/e2e/traces.e2e.test.js +742 -0
  44. package/tests/helpers/click-fixtures.js +62 -0
  45. package/tests/helpers/download-fixtures.js +125 -0
  46. package/tests/helpers/download-server.js +129 -0
  47. package/tests/helpers/e2e-browser.js +67 -0
  48. package/tests/helpers/file-modes.js +31 -0
  49. package/tests/helpers/fixture-server.js +56 -0
  50. package/tests/helpers/mocks.js +63 -66
  51. package/tests/helpers/readiness-server.js +96 -0
  52. package/tests/helpers/temp-directory.js +44 -0
  53. package/tests/helpers/trace-fixtures.js +274 -0
  54. package/tests/helpers/trace-server.js +173 -0
  55. package/tests/unit/core/dialog-manager.test.js +118 -10
  56. package/tests/unit/core/navigation-manager.test.js +162 -0
  57. package/tests/unit/core/readiness.test.js +517 -0
  58. package/tests/unit/downloads/attach.test.js +220 -0
  59. package/tests/unit/downloads/destination.test.js +114 -0
  60. package/tests/unit/downloads/manager.test.js +485 -0
  61. package/tests/unit/downloads/naming.test.js +174 -0
  62. package/tests/unit/downloads/staging.test.js +117 -0
  63. package/tests/unit/downloads/store.test.js +215 -0
  64. package/tests/unit/interactions/click-activation.test.js +283 -0
  65. package/tests/unit/interactions/click-result.test.js +90 -0
  66. package/tests/unit/interactions/click.test.js +409 -200
  67. package/tests/unit/tests/tracing.test.js +299 -0
  68. package/tests/unit/traces/bundle.test.js +260 -0
  69. package/tests/unit/traces/links.test.js +557 -0
  70. package/tests/unit/traces/reader.test.js +208 -0
  71. package/tests/unit/traces/recorder.test.js +730 -0
  72. package/tests/unit/traces/redaction.test.js +170 -0
  73. package/tests/unit/traces/schema.test.js +92 -0
  74. package/tests/unit/traces/viewer.test.js +294 -0
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Naming and placement rules for managed downloads (issue #88).
3
+ *
4
+ * Everything here answers one question: given a name a *page* chose, where is
5
+ * it safe to write the bytes? A suggested filename is attacker-controlled
6
+ * input, so it is treated as a hint and never as a path.
7
+ */
8
+
9
+ import path from 'node:path';
10
+
11
+ /** The type a server sends when it cannot name the format either. */
12
+ const GENERIC_BINARY_TYPE = 'application/octet-stream';
13
+
14
+ /** Extensions derived from a declared or detected MIME type. */
15
+ const MIME_EXTENSIONS = new Map([
16
+ ['application/pdf', '.pdf'],
17
+ ['application/json', '.json'],
18
+ ['application/zip', '.zip'],
19
+ ['application/gzip', '.gz'],
20
+ ['application/x-tar', '.tar'],
21
+ [GENERIC_BINARY_TYPE, '.bin'],
22
+ ['application/vnd.ms-excel', '.xls'],
23
+ [
24
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
25
+ '.xlsx',
26
+ ],
27
+ ['application/msword', '.doc'],
28
+ [
29
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
30
+ '.docx',
31
+ ],
32
+ ['text/csv', '.csv'],
33
+ ['text/html', '.html'],
34
+ ['text/plain', '.txt'],
35
+ ['image/png', '.png'],
36
+ ['image/jpeg', '.jpg'],
37
+ ['image/gif', '.gif'],
38
+ ['image/svg+xml', '.svg'],
39
+ ['image/webp', '.webp'],
40
+ ['video/mp4', '.mp4'],
41
+ ['audio/mpeg', '.mp3'],
42
+ ]);
43
+
44
+ /** Leading bytes that identify a format regardless of what the page claimed. */
45
+ const MAGIC_NUMBERS = [
46
+ { extension: '.pdf', bytes: [0x25, 0x50, 0x44, 0x46] },
47
+ { extension: '.png', bytes: [0x89, 0x50, 0x4e, 0x47] },
48
+ { extension: '.gif', bytes: [0x47, 0x49, 0x46, 0x38] },
49
+ { extension: '.jpg', bytes: [0xff, 0xd8, 0xff] },
50
+ { extension: '.zip', bytes: [0x50, 0x4b, 0x03, 0x04] },
51
+ { extension: '.gz', bytes: [0x1f, 0x8b] },
52
+ ];
53
+
54
+ /** Name used when a page suggests nothing usable at all. */
55
+ const FALLBACK_NAME = 'download';
56
+
57
+ /** Windows device names that cannot be used as files even on other systems. */
58
+ const RESERVED_NAMES = new Set([
59
+ 'con',
60
+ 'prn',
61
+ 'aux',
62
+ 'nul',
63
+ ...Array.from({ length: 9 }, (_, index) => `com${index + 1}`),
64
+ ...Array.from({ length: 9 }, (_, index) => `lpt${index + 1}`),
65
+ ]);
66
+
67
+ /** A name that is only dots carries no information and is not a name. */
68
+ const ONLY_DOTS = /^\.+$/;
69
+
70
+ /** One whitespace character, tested one position at a time. */
71
+ const WHITESPACE = /\s/;
72
+
73
+ /**
74
+ * Trim the padding off a suggested name: leading whitespace, and trailing
75
+ * whitespace or dots.
76
+ *
77
+ * `/^\s+|[\s.]+$/` says the same thing in one line, but it backtracks
78
+ * quadratically over a long run of whitespace, and this string was chosen by
79
+ * the page. Walking in from both ends is one pass and cannot be made to cost
80
+ * more. Leading dots survive on purpose: `.bashrc` is a name, not padding.
81
+ *
82
+ * @param {string} value - Name to trim
83
+ * @returns {string} The name without its padding
84
+ */
85
+ function trimNameEdges(value) {
86
+ let start = 0;
87
+ let end = value.length;
88
+
89
+ while (start < end && WHITESPACE.test(value[start])) {
90
+ start += 1;
91
+ }
92
+ while (
93
+ end > start &&
94
+ (value[end - 1] === '.' || WHITESPACE.test(value[end - 1]))
95
+ ) {
96
+ end -= 1;
97
+ }
98
+
99
+ return value.slice(start, end);
100
+ }
101
+
102
+ /**
103
+ * Strip a page-supplied name down to something that can only ever be a file
104
+ * inside the download root.
105
+ *
106
+ * Path separators, drive letters, control characters and `..` segments are
107
+ * removed rather than rejected, because a rejected download is a lost download
108
+ * and the caller asked for the file, not for the page's spelling of it.
109
+ *
110
+ * @param {string} suggested - Name suggested by the page or engine
111
+ * @returns {string} A single safe path segment
112
+ */
113
+ export function sanitizeDownloadName(suggested) {
114
+ const raw = typeof suggested === 'string' ? suggested : '';
115
+
116
+ // Take the last segment under both separators: a page may suggest
117
+ // `../../etc/passwd` or `C:\Windows\system32\x`, and neither is a location
118
+ // we are willing to honor.
119
+ const lastSegment = raw.split(/[/\\]/).pop() ?? '';
120
+
121
+ const cleaned = trimNameEdges(
122
+ lastSegment
123
+ // eslint-disable-next-line no-control-regex -- control characters in a filename are exactly what we are removing
124
+ .replace(/[\u0000-\u001f\u007f]/g, '')
125
+ .replace(/[:*?"<>|]/g, '_')
126
+ );
127
+
128
+ if (!cleaned || ONLY_DOTS.test(cleaned)) {
129
+ return FALLBACK_NAME;
130
+ }
131
+
132
+ const stem = cleaned.slice(0, cleaned.lastIndexOf('.') + 1 || undefined);
133
+ if (RESERVED_NAMES.has(stem.replace(/\.$/, '').toLowerCase())) {
134
+ return `_${cleaned}`;
135
+ }
136
+
137
+ return cleaned;
138
+ }
139
+
140
+ /**
141
+ * Guess an extension from the first bytes of the file.
142
+ *
143
+ * @param {Buffer|Uint8Array|undefined} head - Leading bytes of the download
144
+ * @returns {string} Extension including the dot, or an empty string
145
+ */
146
+ export function extensionFromContent(head) {
147
+ if (!head || head.length === 0) {
148
+ return '';
149
+ }
150
+
151
+ const found = MAGIC_NUMBERS.find(({ bytes }) =>
152
+ bytes.every((byte, index) => head[index] === byte)
153
+ );
154
+ return found ? found.extension : '';
155
+ }
156
+
157
+ /**
158
+ * Give a name an extension when the page did not supply one.
159
+ *
160
+ * Pages hand out UUID-like names constantly; a file called
161
+ * `7f1c...-9ab2` is unusable to a human and unopenable by the OS, so the
162
+ * declared MIME type (or the bytes themselves) supplies the missing suffix.
163
+ *
164
+ * @param {Object} options - Naming options
165
+ * @param {string} options.name - Sanitized name
166
+ * @param {string} [options.mimeType] - MIME type declared by the server
167
+ * @param {Buffer|Uint8Array} [options.head] - Leading bytes of the file
168
+ * @returns {string} Name with an extension when one could be determined
169
+ */
170
+ export function withExtension({ name, mimeType, head }) {
171
+ if (path.extname(name)) {
172
+ return name;
173
+ }
174
+
175
+ const declaredType = String(mimeType ?? '')
176
+ .split(';')[0]
177
+ .trim()
178
+ .toLowerCase();
179
+ const declared = MIME_EXTENSIONS.get(declaredType);
180
+ const sniffed = extensionFromContent(head);
181
+ // `application/octet-stream` is what a server sends when it does not know
182
+ // either, so the bytes outrank it; any other declared type is a real claim.
183
+ const extension =
184
+ declaredType === GENERIC_BINARY_TYPE
185
+ ? sniffed || declared
186
+ : declared || sniffed;
187
+ return extension ? `${name}${extension}` : name;
188
+ }
189
+
190
+ /**
191
+ * Report whether a resolved path stays inside the download root.
192
+ *
193
+ * The check is done on resolved paths rather than on the name, so a symlinked
194
+ * or relative root cannot be used to step outside it.
195
+ *
196
+ * @param {string} root - Absolute download directory
197
+ * @param {string} candidate - Absolute candidate path
198
+ * @returns {boolean} Whether the candidate is inside the root
199
+ */
200
+ export function isInsideRoot(root, candidate) {
201
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
202
+ return (
203
+ relative !== '' &&
204
+ !relative.startsWith('..') &&
205
+ !path.isAbsolute(relative) &&
206
+ !relative.split(path.sep).includes('..')
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Resolve a safe absolute path for a download inside the root.
212
+ *
213
+ * @param {string} root - Absolute download directory
214
+ * @param {string} name - Sanitized file name
215
+ * @returns {string} Absolute path inside the root
216
+ * @throws {Error} When the name would escape the root
217
+ */
218
+ export function resolveInsideRoot(root, name) {
219
+ const candidate = path.resolve(root, name);
220
+ if (!isInsideRoot(root, candidate)) {
221
+ throw new Error(
222
+ `refusing to write "${name}" outside the download directory ${root}`
223
+ );
224
+ }
225
+ return candidate;
226
+ }
227
+
228
+ /**
229
+ * Produce the candidate names a rename-on-conflict policy tries, in order.
230
+ *
231
+ * `report.pdf`, `report (2).pdf`, `report (3).pdf`, … — the order is fixed, so
232
+ * two runs of the same scenario produce the same names.
233
+ *
234
+ * @param {string} name - Sanitized file name
235
+ * @param {number} attempt - Zero-based attempt number
236
+ * @returns {string} Candidate name for that attempt
237
+ */
238
+ export function renamedCandidate(name, attempt) {
239
+ if (attempt === 0) {
240
+ return name;
241
+ }
242
+
243
+ const extension = path.extname(name);
244
+ const stem = extension ? name.slice(0, -extension.length) : name;
245
+ return `${stem} (${attempt + 1})${extension}`;
246
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Engine-specific download event sources (issue #88).
3
+ *
4
+ * Each source turns one engine's download notifications into the same three
5
+ * calls - `started`, `finished`, `failed` - so the manager above them does not
6
+ * know or care whether the bytes came from Playwright or from CDP, and a
7
+ * download a human started by hand is reported exactly like an automated one.
8
+ */
9
+
10
+ import path from 'node:path';
11
+ import fs from 'node:fs/promises';
12
+
13
+ import { ARTIFACT_DIRECTORY_MODE } from './destination.js';
14
+ import {
15
+ DEFAULT_STAGING_POLL_INTERVAL,
16
+ DEFAULT_STAGING_TIMEOUT,
17
+ describeStagingTimeout,
18
+ waitForStagedFile,
19
+ } from './staging.js';
20
+
21
+ /** Why a download ended without a file. */
22
+ export const DOWNLOAD_FAILURE = Object.freeze({
23
+ CANCELLED: 'cancelled',
24
+ FAILED: 'failed',
25
+ });
26
+
27
+ /** Directory the engine writes into before we place the file. */
28
+ export const STAGING_DIRECTORY = '.browser-commander-staging';
29
+
30
+ /**
31
+ * Classify an engine's failure text.
32
+ *
33
+ * "A download UI entry is not completion evidence" (issue #88): the engine's
34
+ * own words are the only thing that distinguishes a user cancelling a download
35
+ * from a network error, so they are preserved and classified, not flattened.
36
+ *
37
+ * @param {string} [reason] - Engine-reported reason
38
+ * @returns {string} One of {@link DOWNLOAD_FAILURE}
39
+ */
40
+ export function classifyFailure(reason) {
41
+ return /cancel/i.test(String(reason ?? ''))
42
+ ? DOWNLOAD_FAILURE.CANCELLED
43
+ : DOWNLOAD_FAILURE.FAILED;
44
+ }
45
+
46
+ /**
47
+ * Attach to Playwright's context-level download event.
48
+ *
49
+ * @param {Object} options - Source options
50
+ * @param {Object} options.context - Playwright browser context
51
+ * @param {Object} options.sink - `{started, finished, failed}` callbacks
52
+ * @returns {Object} Handle with `detach()`
53
+ */
54
+ export function attachPlaywrightSource({ context, sink }) {
55
+ const onDownload = async (download) => {
56
+ const record = sink.started({
57
+ engineHandle: download,
58
+ url: download.url(),
59
+ suggestedFilename: download.suggestedFilename(),
60
+ page: download.page?.(),
61
+ });
62
+
63
+ try {
64
+ const failure = await download.failure();
65
+ if (failure) {
66
+ sink.failed(record, classifyFailure(failure), failure);
67
+ return;
68
+ }
69
+
70
+ // `path()` resolves only once the bytes are on disk, which is the
71
+ // engine's own evidence that the download completed.
72
+ const enginePath = await download.path();
73
+ sink.finished(record, { path: enginePath });
74
+ } catch (error) {
75
+ sink.failed(record, classifyFailure(error.message), error.message);
76
+ }
77
+ };
78
+
79
+ context.on('download', onDownload);
80
+ return {
81
+ detach: () => context.off('download', onDownload),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Point Chromium at a staging directory and report every download it starts.
87
+ *
88
+ * This is the only source that sees a download a *person* started, because
89
+ * `Browser.setDownloadBehavior` is browser-wide rather than per-automation.
90
+ *
91
+ * @param {Object} options - Source options
92
+ * @param {Object} options.session - CDP session with Browser domain access
93
+ * @param {string} options.root - Managed download directory
94
+ * @param {Object} options.sink - `{started, finished, failed, track}` callbacks
95
+ * @param {number} [options.stagingTimeout] - Budget for the bytes to land on disk
96
+ * @param {number} [options.stagingPollInterval] - Milliseconds between readings
97
+ * @returns {Promise<Object>} Handle with `detach()` and `stagingDirectory`
98
+ */
99
+ export async function attachCdpSource({
100
+ session,
101
+ root,
102
+ sink,
103
+ stagingTimeout,
104
+ stagingPollInterval,
105
+ }) {
106
+ const stagingDirectory = path.join(root, STAGING_DIRECTORY);
107
+ await fs.mkdir(stagingDirectory, {
108
+ recursive: true,
109
+ mode: ARTIFACT_DIRECTORY_MODE,
110
+ });
111
+
112
+ // `allowAndName` writes each file under its GUID, so two downloads that
113
+ // suggest the same name cannot overwrite each other before we have placed
114
+ // them, and the GUID is the identity we deduplicate on.
115
+ await session.send('Browser.setDownloadBehavior', {
116
+ behavior: 'allowAndName',
117
+ downloadPath: stagingDirectory,
118
+ eventsEnabled: true,
119
+ });
120
+
121
+ const byGuid = new Map();
122
+
123
+ const onWillBegin = ({ guid, url, suggestedFilename }) => {
124
+ byGuid.set(
125
+ guid,
126
+ sink.started({
127
+ engineHandle: guid,
128
+ url,
129
+ suggestedFilename,
130
+ })
131
+ );
132
+ };
133
+
134
+ /**
135
+ * Publish a completed download once its staged bytes are readable.
136
+ *
137
+ * `state: "completed"` is the browser's word, not the filesystem's. Chromium
138
+ * writes `<guid>.crdownload` and renames it, so opening `<guid>` the instant
139
+ * the event arrives could - and did, in issue #92 - fail with `ENOENT` on a
140
+ * download that had in fact succeeded. Waiting for the file turns that race
141
+ * into either a saved artifact or a named failure, never a lost one.
142
+ *
143
+ * @param {Object} record - Artifact record
144
+ * @param {string} stagedPath - Path Chromium staged the bytes under
145
+ * @returns {Promise<void>}
146
+ */
147
+ const publishWhenStaged = async (record, stagedPath) => {
148
+ const budget = stagingTimeout ?? DEFAULT_STAGING_TIMEOUT;
149
+ const settled = await waitForStagedFile({
150
+ path: stagedPath,
151
+ timeout: budget,
152
+ interval: stagingPollInterval ?? DEFAULT_STAGING_POLL_INTERVAL,
153
+ });
154
+
155
+ if (!settled.ready) {
156
+ sink.failed(
157
+ record,
158
+ DOWNLOAD_FAILURE.FAILED,
159
+ describeStagingTimeout({
160
+ path: stagedPath,
161
+ timeout: budget,
162
+ reason: settled.reason,
163
+ })
164
+ );
165
+ return;
166
+ }
167
+
168
+ await sink.finished(record, { path: stagedPath, removeSource: true });
169
+ };
170
+
171
+ const onProgress = ({ guid, state }) => {
172
+ const record = byGuid.get(guid);
173
+ if (!record || state === 'inProgress') {
174
+ return;
175
+ }
176
+
177
+ byGuid.delete(guid);
178
+ if (state === 'completed') {
179
+ // Tracked before it is awaited: the wait is part of the download's
180
+ // lifecycle, so `idle()` and `dispose()` must not return while it runs.
181
+ sink.track(publishWhenStaged(record, path.join(stagingDirectory, guid)));
182
+ return;
183
+ }
184
+ sink.failed(
185
+ record,
186
+ DOWNLOAD_FAILURE.CANCELLED,
187
+ `download ${state} by the browser`
188
+ );
189
+ };
190
+
191
+ session.on('Browser.downloadWillBegin', onWillBegin);
192
+ session.on('Browser.downloadProgress', onProgress);
193
+
194
+ return {
195
+ stagingDirectory,
196
+ detach: () => {
197
+ session.off?.('Browser.downloadWillBegin', onWillBegin);
198
+ session.off?.('Browser.downloadProgress', onProgress);
199
+ },
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Open a CDP session that can drive the Browser domain.
205
+ *
206
+ * Both engines can open a session that is not tied to a single page, which is
207
+ * what the Browser domain needs: one session then covers every page and every
208
+ * download no page started.
209
+ *
210
+ * @param {Object} options - Session options
211
+ * @param {string} options.engine - Engine name
212
+ * @param {Object} [options.browser] - Browser or persistent context
213
+ * @param {Object} [options.page] - Page to borrow a session from
214
+ * @returns {Promise<Object>|undefined} CDP session, or undefined when unavailable
215
+ */
216
+ export function openBrowserCdpSession({ engine, browser, page }) {
217
+ if (engine === 'puppeteer') {
218
+ const target = browser?.target?.();
219
+ if (target?.createCDPSession) {
220
+ return target.createCDPSession();
221
+ }
222
+ return page?.target?.().createCDPSession?.();
223
+ }
224
+
225
+ // A page-scoped session only hears about the downloads that page started,
226
+ // so a browser-wide session is what makes a download a *person* began - in a
227
+ // tab the automation never opened - observable at all.
228
+ const browserHandle = browser?.browser?.() ?? browser;
229
+ if (browserHandle?.newBrowserCDPSession) {
230
+ return browserHandle.newBrowserCDPSession();
231
+ }
232
+
233
+ const context = browser?.browserContext?.() ?? browser;
234
+ if (page && context?.newCDPSession) {
235
+ return context.newCDPSession(page);
236
+ }
237
+ return undefined;
238
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Waiting for a staged download to actually be on disk (issue #92).
3
+ *
4
+ * `Browser.downloadProgress` with `state: "completed"` is the browser saying it
5
+ * has stopped downloading, not the filesystem saying the bytes are there under
6
+ * the name we are about to open. Chromium writes into `<guid>.crdownload` and
7
+ * renames it, and the event can reach us before that rename is visible - which
8
+ * is how a completed download became
9
+ * `ENOENT: no such file or directory, open '<root>/.browser-commander-staging/<guid>'`.
10
+ *
11
+ * This module turns "the browser said completed" into "the file is readable and
12
+ * has stopped growing", within a bounded budget. Running out of that budget is
13
+ * an explicit failure, never a silent success: issue #88's rule that "a
14
+ * download UI entry is not completion evidence" is exactly the rule being
15
+ * applied one level lower down.
16
+ */
17
+
18
+ import fs from 'node:fs/promises';
19
+ import { setTimeout as delay } from 'node:timers/promises';
20
+
21
+ import { createDeadline } from '../core/readiness.js';
22
+
23
+ /** How long to wait for a completed download's bytes to appear, by default. */
24
+ export const DEFAULT_STAGING_TIMEOUT = 10000;
25
+
26
+ /** How often the staged file is looked at while waiting. */
27
+ export const DEFAULT_STAGING_POLL_INTERVAL = 25;
28
+
29
+ /** Suffixes Chromium uses for a file it is still writing. */
30
+ export const STAGING_IN_PROGRESS_SUFFIXES = Object.freeze([
31
+ '.crdownload',
32
+ '.partial',
33
+ ]);
34
+
35
+ /**
36
+ * Look at a staged file once.
37
+ *
38
+ * @param {string} filePath - Path the engine staged the download under
39
+ * @param {string[]} suffixes - Suffixes that mark a file still being written
40
+ * @returns {Promise<{size: number|null, reason: string|null}>} What was seen
41
+ */
42
+ async function observeStagedFile(filePath, suffixes) {
43
+ for (const suffix of suffixes) {
44
+ try {
45
+ await fs.stat(`${filePath}${suffix}`);
46
+ return { size: null, reason: `a ${suffix} file is still being written` };
47
+ } catch {
48
+ // Absent is what we want: the rename to the final staged name has
49
+ // already happened, or Chromium never used this suffix at all.
50
+ }
51
+ }
52
+
53
+ let handle;
54
+ try {
55
+ // Opening rather than stat'ing is deliberate: "readable" is the property
56
+ // `saveDownload()` needs, and a name that resolves is not the same thing
57
+ // as a file this process is allowed to read.
58
+ handle = await fs.open(filePath, 'r');
59
+ } catch (error) {
60
+ return {
61
+ size: null,
62
+ reason:
63
+ error.code === 'ENOENT'
64
+ ? 'the file has not appeared yet'
65
+ : `the file could not be opened: ${error.message}`,
66
+ };
67
+ }
68
+
69
+ try {
70
+ const stats = await handle.stat();
71
+ return { size: stats.size, reason: null };
72
+ } finally {
73
+ await handle.close();
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Wait until a staged download is readable and has stopped growing.
79
+ *
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
+ *
84
+ * @param {Object} options - Wait options
85
+ * @param {string} options.path - Path the engine staged the download under
86
+ * @param {number} [options.timeout] - Total budget in milliseconds
87
+ * @param {number} [options.interval] - Milliseconds between readings
88
+ * @param {string[]} [options.inProgressSuffixes] - Suffixes of a partial file
89
+ * @returns {Promise<{ready: boolean, bytes: number|null, reason: string|null, waitedMs: number}>} Outcome
90
+ */
91
+ export async function waitForStagedFile(options = {}) {
92
+ const {
93
+ path: filePath,
94
+ timeout = DEFAULT_STAGING_TIMEOUT,
95
+ interval = DEFAULT_STAGING_POLL_INTERVAL,
96
+ inProgressSuffixes = STAGING_IN_PROGRESS_SUFFIXES,
97
+ } = options;
98
+
99
+ const deadline = createDeadline({ timeout });
100
+ let previousSize;
101
+ // Always set by the reading below before the deadline can be checked.
102
+ let reason;
103
+
104
+ for (;;) {
105
+ const seen = await observeStagedFile(filePath, inProgressSuffixes);
106
+ if (seen.reason !== null) {
107
+ reason = seen.reason;
108
+ // A file that vanished has to prove itself stable again from scratch.
109
+ previousSize = undefined;
110
+ } else if (seen.size === previousSize) {
111
+ return {
112
+ ready: true,
113
+ bytes: seen.size,
114
+ reason: null,
115
+ waitedMs: deadline.elapsedMs(),
116
+ };
117
+ } else {
118
+ previousSize = seen.size;
119
+ reason = `the file was still growing at ${seen.size} bytes`;
120
+ }
121
+
122
+ const remaining = deadline.remainingMs();
123
+ if (remaining === 0) {
124
+ return {
125
+ ready: false,
126
+ bytes: null,
127
+ reason,
128
+ waitedMs: deadline.elapsedMs(),
129
+ };
130
+ }
131
+ await delay(Math.min(interval, remaining));
132
+ }
133
+ }
134
+
135
+ /**
136
+ * The failure text a staged download that never materialized is reported under.
137
+ *
138
+ * @param {Object} options - Failure details
139
+ * @param {string} options.path - Path the engine staged the download under
140
+ * @param {number} options.timeout - Budget that was exhausted
141
+ * @param {string} options.reason - What the last reading saw
142
+ * @returns {string} Message kept verbatim on the artifact
143
+ */
144
+ export function describeStagingTimeout({ path: filePath, timeout, reason }) {
145
+ return `the browser reported the download as completed, but ${filePath} was not readable within ${timeout}ms: ${reason}`;
146
+ }