browser-commander 0.17.2 → 0.18.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 (66) hide show
  1. package/.jscpd-baseline.json +4 -17
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +84 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +4 -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 +453 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +188 -0
  19. package/src/downloads/store.js +256 -0
  20. package/src/exports.js +72 -0
  21. package/src/factory.js +55 -0
  22. package/src/interactions/click-activation.js +317 -0
  23. package/src/interactions/click-result.js +108 -0
  24. package/src/interactions/click.js +766 -352
  25. package/src/tests/index.js +75 -11
  26. package/src/tests/tracing.js +236 -0
  27. package/src/traces/bundle.js +323 -0
  28. package/src/traces/observers.js +246 -0
  29. package/src/traces/page-capture.js +365 -0
  30. package/src/traces/reader.js +226 -0
  31. package/src/traces/recorder.js +437 -0
  32. package/src/traces/redaction.js +231 -0
  33. package/src/traces/schema.js +165 -0
  34. package/src/traces/viewer.js +302 -0
  35. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  36. package/tests/e2e/downloads.e2e.test.js +188 -0
  37. package/tests/e2e/traces.e2e.test.js +457 -0
  38. package/tests/helpers/click-fixtures.js +62 -0
  39. package/tests/helpers/download-fixtures.js +125 -0
  40. package/tests/helpers/download-server.js +109 -0
  41. package/tests/helpers/e2e-browser.js +67 -0
  42. package/tests/helpers/file-modes.js +31 -0
  43. package/tests/helpers/fixture-server.js +56 -0
  44. package/tests/helpers/mocks.js +63 -66
  45. package/tests/helpers/readiness-server.js +96 -0
  46. package/tests/helpers/temp-directory.js +44 -0
  47. package/tests/helpers/trace-fixtures.js +198 -0
  48. package/tests/helpers/trace-server.js +117 -0
  49. package/tests/unit/core/dialog-manager.test.js +118 -10
  50. package/tests/unit/core/navigation-manager.test.js +162 -0
  51. package/tests/unit/core/readiness.test.js +517 -0
  52. package/tests/unit/downloads/attach.test.js +220 -0
  53. package/tests/unit/downloads/destination.test.js +114 -0
  54. package/tests/unit/downloads/manager.test.js +354 -0
  55. package/tests/unit/downloads/naming.test.js +174 -0
  56. package/tests/unit/downloads/store.test.js +215 -0
  57. package/tests/unit/interactions/click-activation.test.js +283 -0
  58. package/tests/unit/interactions/click-result.test.js +90 -0
  59. package/tests/unit/interactions/click.test.js +409 -200
  60. package/tests/unit/tests/tracing.test.js +299 -0
  61. package/tests/unit/traces/bundle.test.js +260 -0
  62. package/tests/unit/traces/reader.test.js +208 -0
  63. package/tests/unit/traces/recorder.test.js +562 -0
  64. package/tests/unit/traces/redaction.test.js +170 -0
  65. package/tests/unit/traces/schema.test.js +92 -0
  66. package/tests/unit/traces/viewer.test.js +289 -0
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Readiness primitives - one monotonic deadline, composable checks, evidence.
3
+ *
4
+ * `waitForPageReady` used to answer "is the page ready?" with a hard-coded
5
+ * sequence of waits, a floor that could outlive the caller's timeout, and a
6
+ * constant `true`. This module supplies the pieces that make the answer
7
+ * truthful: a deadline that only ever hands out non-negative remaining budget,
8
+ * checks that report what they observed, and a result that distinguishes
9
+ * "ready" from "we ran out of time".
10
+ */
11
+
12
+ /** Statuses a readiness wait can end in. */
13
+ export const READINESS_STATUS = Object.freeze({
14
+ READY: 'ready',
15
+ TIMED_OUT: 'timed_out',
16
+ FAILED: 'failed',
17
+ });
18
+
19
+ /**
20
+ * Request classes that never go idle on their own. Waiting for them is waiting
21
+ * for the timeout, so network idle ignores them unless a caller opts back in.
22
+ */
23
+ export const LONG_LIVED_REQUEST_PATTERNS = Object.freeze([
24
+ /^wss?:\/\//iu,
25
+ /\/(?:socket\.io|sockjs|websocket|ws)(?:[/?]|$)/iu,
26
+ /\/(?:event-?stream|sse|stream)(?:[/?]|$)/iu,
27
+ /(?:^|\.)(?:google-analytics|googletagmanager|doubleclick|segment|mixpanel|amplitude|hotjar|sentry|datadoghq|newrelic)\./iu,
28
+ /\/(?:analytics|telemetry|beacon|collect|metrics|heartbeat|ping|poll|longpoll)(?:[/?]|$)/iu,
29
+ ]);
30
+
31
+ /**
32
+ * True when a URL belongs to a request class that is expected to stay open.
33
+ *
34
+ * @param {string} url - Request URL
35
+ * @param {RegExp[]} [patterns] - Patterns to test, defaults to the built-in list
36
+ * @returns {boolean} Whether the request should be ignored by idle checks
37
+ */
38
+ export function isLongLivedRequest(
39
+ url,
40
+ patterns = LONG_LIVED_REQUEST_PATTERNS
41
+ ) {
42
+ if (typeof url !== 'string' || url.length === 0) {
43
+ return false;
44
+ }
45
+ return patterns.some((pattern) => pattern.test(url));
46
+ }
47
+
48
+ /**
49
+ * Create a monotonic deadline shared by every check in one readiness wait.
50
+ *
51
+ * The clock is monotonic on purpose: a wall-clock jump (NTP correction, a
52
+ * suspended laptop) must not turn a five second budget into a five minute one.
53
+ *
54
+ * @param {Object} options - Configuration options
55
+ * @param {number} options.timeout - Total budget in milliseconds
56
+ * @param {Function} [options.now] - Monotonic clock, defaults to performance.now
57
+ * @returns {{startedAt: number, timeoutMs: number, elapsedMs: Function, remainingMs: Function, expired: Function}} Deadline handle
58
+ */
59
+ export function createDeadline(options = {}) {
60
+ const { timeout, now = () => performance.now() } = options;
61
+
62
+ if (!Number.isFinite(timeout) || timeout < 0) {
63
+ throw new TypeError('createDeadline requires a non-negative timeout');
64
+ }
65
+
66
+ const startedAt = now();
67
+ const elapsed = () => Math.max(0, now() - startedAt);
68
+ // `expired` is derived from `remainingMs` rather than measured separately:
69
+ // rounding otherwise lets a caller read a remaining budget of zero while the
70
+ // deadline still calls itself live, and a check that stopped because its
71
+ // budget was gone would then be reported as failed instead of timed out.
72
+ const remainingMs = () => Math.max(0, Math.round(timeout - elapsed()));
73
+
74
+ return {
75
+ startedAt,
76
+ timeoutMs: timeout,
77
+ elapsedMs: () => Math.round(elapsed()),
78
+ remainingMs,
79
+ expired: () => remainingMs() === 0,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Sleep for at most the deadline's remaining budget.
85
+ *
86
+ * @param {number} ms - Requested delay
87
+ * @param {Object} deadline - Deadline from {@link createDeadline}
88
+ * @returns {Promise<void>} Resolves after the capped delay
89
+ */
90
+ export async function sleepWithinDeadline(ms, deadline) {
91
+ const capped = Math.min(ms, deadline.remainingMs());
92
+ if (capped <= 0) {
93
+ return;
94
+ }
95
+ await new Promise((resolve) => setTimeout(resolve, capped));
96
+ }
97
+
98
+ /** Marker resolved by the expiry timer in {@link runWithinDeadline}. */
99
+ const DEADLINE_REACHED = Symbol('deadline-reached');
100
+
101
+ /**
102
+ * Run an operation under a deadline, reporting expiry instead of waiting.
103
+ *
104
+ * Engine probes carry timeouts of their own - Playwright's locator default is
105
+ * 30 seconds - so an operation given a 3 second budget could spend ten times
106
+ * that waiting for an element a navigation had already taken away. The budget
107
+ * the caller asked for has to win.
108
+ *
109
+ * @param {Object} deadline - Deadline from {@link createDeadline}, or null for no bound
110
+ * @param {Function} run - Zero-argument function returning a promise
111
+ * @returns {Promise<{timedOut: boolean, value: *}>} Outcome, or expiry
112
+ */
113
+ export async function runWithinDeadline(deadline, run) {
114
+ if (!deadline) {
115
+ return { timedOut: false, value: await run() };
116
+ }
117
+
118
+ const remaining = deadline.remainingMs();
119
+ if (remaining <= 0) {
120
+ return { timedOut: true, value: undefined };
121
+ }
122
+
123
+ const operation = run();
124
+ // An abandoned operation still settles on its own schedule. Handle its
125
+ // rejection up front so it cannot surface as an unhandled one after the
126
+ // deadline has already won the race.
127
+ Promise.resolve(operation).catch(() => {});
128
+
129
+ let timer;
130
+ const expiry = new Promise((resolve) => {
131
+ timer = setTimeout(() => resolve(DEADLINE_REACHED), remaining);
132
+ });
133
+
134
+ try {
135
+ const outcome = await Promise.race([operation, expiry]);
136
+ if (outcome === DEADLINE_REACHED) {
137
+ return { timedOut: true, value: undefined };
138
+ }
139
+ return { timedOut: false, value: outcome };
140
+ } finally {
141
+ clearTimeout(timer);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Resolve the engine adapter a check needs, building it lazily when the caller
147
+ * only supplied a factory.
148
+ *
149
+ * @param {Object} context - Check context
150
+ * @returns {Promise<Object>} Engine adapter
151
+ */
152
+ async function resolveAdapter(context) {
153
+ const adapter = context.adapter ?? (await context.getAdapter?.());
154
+ if (!adapter) {
155
+ throw new Error('readiness check requires an engine adapter');
156
+ }
157
+ return adapter;
158
+ }
159
+
160
+ function normalizeSampleResult(value) {
161
+ if (typeof value === 'boolean') {
162
+ return { stable: value, detail: {} };
163
+ }
164
+ return { stable: Boolean(value?.stable), detail: value?.detail ?? {} };
165
+ }
166
+
167
+ /**
168
+ * Build a check that requires a sampler to report stability for a period.
169
+ *
170
+ * @param {Object} options - Configuration options
171
+ * @param {string} options.name - Check name reported in evidence
172
+ * @param {Function} options.sample - Async sampler returning boolean or {stable, detail}
173
+ * @param {number} [options.stableForMs=500] - How long the sampler must stay stable
174
+ * @param {number} [options.intervalMs=100] - Sampling interval
175
+ * @param {number} [options.consecutiveSamples=1] - Extra consecutive stable samples required
176
+ * @returns {{name: string, run: Function}} Readiness check
177
+ */
178
+ export function stableCheck(options = {}) {
179
+ const {
180
+ name,
181
+ sample,
182
+ stableForMs = 500,
183
+ intervalMs = 100,
184
+ consecutiveSamples = 1,
185
+ } = options;
186
+
187
+ if (!name || typeof sample !== 'function') {
188
+ throw new TypeError('stableCheck requires a name and a sample function');
189
+ }
190
+
191
+ return {
192
+ name,
193
+ async run(context) {
194
+ const { deadline } = context;
195
+ let stableSince = null;
196
+ let streak = 0;
197
+ let lastDetail = {};
198
+
199
+ while (!deadline.expired()) {
200
+ const { stable, detail } = normalizeSampleResult(await sample(context));
201
+ lastDetail = detail;
202
+
203
+ if (stable) {
204
+ streak += 1;
205
+ stableSince ??= deadline.elapsedMs();
206
+ const heldFor = deadline.elapsedMs() - stableSince;
207
+ if (heldFor >= stableForMs && streak >= consecutiveSamples) {
208
+ return {
209
+ satisfied: true,
210
+ detail: { ...lastDetail, heldForMs: heldFor },
211
+ };
212
+ }
213
+ } else {
214
+ stableSince = null;
215
+ streak = 0;
216
+ }
217
+
218
+ if (deadline.remainingMs() === 0) {
219
+ break;
220
+ }
221
+ await sleepWithinDeadline(intervalMs, deadline);
222
+ }
223
+
224
+ return {
225
+ satisfied: false,
226
+ detail: { ...lastDetail, reason: 'deadline reached' },
227
+ };
228
+ },
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Require the page URL to stop changing for a period.
234
+ *
235
+ * @param {Object} [options] - Configuration options
236
+ * @param {number} [options.stableForMs=1000] - Required quiet period
237
+ * @param {number} [options.intervalMs=200] - Sampling interval
238
+ * @param {number} [options.consecutiveSamples=1] - Extra consecutive stable samples
239
+ * @returns {{name: string, run: Function}} Readiness check
240
+ */
241
+ export function urlStableFor(options = {}) {
242
+ const {
243
+ stableForMs = 1000,
244
+ intervalMs = 200,
245
+ consecutiveSamples = 1,
246
+ } = options;
247
+ let lastUrl;
248
+
249
+ return stableCheck({
250
+ name: 'urlStableFor',
251
+ stableForMs,
252
+ intervalMs,
253
+ consecutiveSamples,
254
+ sample: ({ page, onUrlSample }) => {
255
+ const url = page.url();
256
+ onUrlSample?.(url);
257
+ const stable = lastUrl === undefined ? false : url === lastUrl;
258
+ lastUrl = url;
259
+ return { stable, detail: { url } };
260
+ },
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Require the network tracker to report idle within the remaining budget.
266
+ *
267
+ * @param {Object} [options] - Configuration options
268
+ * @param {number} [options.idleForMs] - Idle window, defaults to the tracker's own
269
+ * @returns {{name: string, run: Function}} Readiness check
270
+ */
271
+ export function networkIdleFor(options = {}) {
272
+ const { idleForMs } = options;
273
+
274
+ return {
275
+ name: 'networkIdleFor',
276
+ async run({ deadline, networkTracker }) {
277
+ if (!networkTracker) {
278
+ return { skipped: true, detail: { reason: 'no network tracker' } };
279
+ }
280
+
281
+ const timeout = deadline.remainingMs();
282
+ if (timeout === 0) {
283
+ return { satisfied: false, detail: { reason: 'no remaining budget' } };
284
+ }
285
+
286
+ const idle = await networkTracker.waitForNetworkIdle({
287
+ timeout,
288
+ ...(idleForMs === undefined ? {} : { idleTime: idleForMs }),
289
+ });
290
+
291
+ return {
292
+ satisfied: Boolean(idle),
293
+ detail: {
294
+ pendingCount: networkTracker.getPendingCount?.() ?? null,
295
+ pendingUrls: idle ? [] : (networkTracker.getPendingUrls?.() ?? []),
296
+ },
297
+ };
298
+ },
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Require the DOM to stop mutating for a period. Uses a cheap structural
304
+ * fingerprint so it works identically on both engines and needs no injection.
305
+ *
306
+ * @param {Object} [options] - Configuration options
307
+ * @param {number} [options.stableForMs=500] - Required quiet period
308
+ * @param {number} [options.intervalMs=100] - Sampling interval
309
+ * @param {number} [options.consecutiveSamples=2] - Extra consecutive stable samples
310
+ * @returns {{name: string, run: Function}} Readiness check
311
+ */
312
+ export function domStableFor(options = {}) {
313
+ const {
314
+ stableForMs = 500,
315
+ intervalMs = 100,
316
+ consecutiveSamples = 2,
317
+ } = options;
318
+ let lastFingerprint;
319
+
320
+ return stableCheck({
321
+ name: 'domStableFor',
322
+ stableForMs,
323
+ intervalMs,
324
+ consecutiveSamples,
325
+ sample: async (context) => {
326
+ const adapter = await resolveAdapter(context);
327
+ const fingerprint = await adapter.evaluateOnPage(() => {
328
+ const { body } = document;
329
+ return {
330
+ nodes: document.getElementsByTagName('*').length,
331
+ length: body ? body.innerHTML.length : 0,
332
+ readyState: document.readyState,
333
+ };
334
+ });
335
+ const key = `${fingerprint.nodes}:${fingerprint.length}`;
336
+ const stable =
337
+ lastFingerprint === key && fingerprint.readyState !== 'loading';
338
+ lastFingerprint = key;
339
+ return { stable, detail: fingerprint };
340
+ },
341
+ });
342
+ }
343
+
344
+ /**
345
+ * Require every image currently in the viewport to have finished decoding.
346
+ *
347
+ * @param {Object} [options] - Configuration options
348
+ * @param {number} [options.intervalMs=150] - Sampling interval
349
+ * @returns {{name: string, run: Function}} Readiness check
350
+ */
351
+ export function visibleImages(options = {}) {
352
+ const { intervalMs = 150 } = options;
353
+
354
+ return stableCheck({
355
+ name: 'visibleImages',
356
+ stableForMs: 0,
357
+ intervalMs,
358
+ consecutiveSamples: 1,
359
+ sample: async (context) => {
360
+ const adapter = await resolveAdapter(context);
361
+ const state = await adapter.evaluateOnPage(() => {
362
+ const images = [...document.images];
363
+ const inViewport = images.filter((image) => {
364
+ const box = image.getBoundingClientRect();
365
+ return (
366
+ box.bottom > 0 &&
367
+ box.right > 0 &&
368
+ box.top < window.innerHeight &&
369
+ box.left < window.innerWidth
370
+ );
371
+ });
372
+ return {
373
+ total: inViewport.length,
374
+ pending: inViewport.filter((image) => !image.complete).length,
375
+ };
376
+ });
377
+ return { stable: state.pending === 0, detail: state };
378
+ },
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Wrap a caller-supplied predicate as a readiness check.
384
+ *
385
+ * @param {Object} options - Configuration options
386
+ * @param {Function} options.fn - Predicate receiving the check context
387
+ * @param {string} [options.name='predicate'] - Name reported in evidence
388
+ * @param {number} [options.intervalMs=100] - Polling interval
389
+ * @returns {{name: string, run: Function}} Readiness check
390
+ */
391
+ export function predicate(options = {}) {
392
+ const { fn, name = 'predicate', intervalMs = 100 } = options;
393
+
394
+ if (typeof fn !== 'function') {
395
+ throw new TypeError('predicate requires a function');
396
+ }
397
+
398
+ return stableCheck({
399
+ name,
400
+ stableForMs: 0,
401
+ intervalMs,
402
+ consecutiveSamples: 1,
403
+ sample: async (context) => ({ stable: Boolean(await fn(context)) }),
404
+ });
405
+ }
406
+
407
+ /**
408
+ * Run readiness checks in order against a single deadline and report evidence.
409
+ *
410
+ * Checks run to completion even after one fails, so the result explains the
411
+ * whole picture rather than only the first problem. Checks that never started
412
+ * are reported as pending, which is what tells a caller the wait was cut short
413
+ * rather than genuinely unsatisfied.
414
+ *
415
+ * @param {Object} options - Configuration options
416
+ * @param {Array} options.checks - Readiness checks to run
417
+ * @param {Object} options.deadline - Deadline from {@link createDeadline}
418
+ * @param {Object} options.context - Context handed to every check
419
+ * @returns {Promise<Object>} Structured readiness result
420
+ */
421
+ export async function runReadinessChecks(options = {}) {
422
+ const { checks = [], deadline, context = {} } = options;
423
+
424
+ const satisfied = [];
425
+ const failed = [];
426
+ const skipped = [];
427
+ const pending = [];
428
+ const evidence = [];
429
+
430
+ for (const [index, check] of checks.entries()) {
431
+ if (deadline.expired() && failed.length > 0) {
432
+ pending.push(...checks.slice(index).map((entry) => entry.name));
433
+ break;
434
+ }
435
+
436
+ const startedAtMs = deadline.elapsedMs();
437
+ let outcome;
438
+ try {
439
+ outcome = await check.run({ ...context, deadline });
440
+ } catch (error) {
441
+ outcome = { satisfied: false, detail: { error: error.message } };
442
+ }
443
+
444
+ const record = {
445
+ name: check.name,
446
+ satisfied: Boolean(outcome.satisfied),
447
+ skipped: Boolean(outcome.skipped),
448
+ startedAtMs,
449
+ elapsedMs: deadline.elapsedMs() - startedAtMs,
450
+ detail: outcome.detail ?? {},
451
+ };
452
+ evidence.push(record);
453
+
454
+ if (record.skipped) {
455
+ skipped.push(check.name);
456
+ } else if (record.satisfied) {
457
+ satisfied.push(check.name);
458
+ } else {
459
+ failed.push(check.name);
460
+ }
461
+ }
462
+
463
+ const ready = failed.length === 0 && pending.length === 0;
464
+ const timedOut = !ready && deadline.expired();
465
+
466
+ return {
467
+ status: ready
468
+ ? READINESS_STATUS.READY
469
+ : timedOut
470
+ ? READINESS_STATUS.TIMED_OUT
471
+ : READINESS_STATUS.FAILED,
472
+ ready,
473
+ checks: { satisfied, failed, skipped, pending },
474
+ evidence,
475
+ elapsedMs: deadline.elapsedMs(),
476
+ timeoutMs: deadline.timeoutMs,
477
+ };
478
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Attaching the download manager to a browser (issue #88).
3
+ *
4
+ * Every entry point - `launchBrowser()`, `connectBrowser()`,
5
+ * `launchRealBrowser()` and `commander.configureDownloads()` - goes through
6
+ * this one function, so the managed lifecycle is the same however the browser
7
+ * was obtained. The manager is built from the browser and page, never from the
8
+ * way they were created.
9
+ */
10
+
11
+ import { createDownloadManager } from './manager.js';
12
+
13
+ /**
14
+ * Normalize the `downloads` option into manager options.
15
+ *
16
+ * @param {boolean|Object} downloads - `true` for defaults, or an options object
17
+ * @returns {Object|null} Manager options, or null when downloads are off
18
+ */
19
+ export function normalizeDownloadOptions(downloads) {
20
+ if (downloads === undefined || downloads === false || downloads === null) {
21
+ return null;
22
+ }
23
+ if (downloads === true) {
24
+ return {};
25
+ }
26
+ if (typeof downloads !== 'object') {
27
+ throw new TypeError(
28
+ 'downloads must be true, false or an options object with a directory'
29
+ );
30
+ }
31
+ return downloads;
32
+ }
33
+
34
+ /**
35
+ * Build the download manager for a browser, if the caller asked for one.
36
+ *
37
+ * @param {Object} options - Attachment options
38
+ * @param {string} options.engine - 'playwright' or 'puppeteer'
39
+ * @param {Object} [options.browser] - Browser or persistent context
40
+ * @param {Object} [options.page] - A page belonging to the browser
41
+ * @param {boolean|Object} [options.downloads] - The caller's `downloads` option
42
+ * @param {Object} [options.log] - Logger
43
+ * @returns {Promise<Object>|null} Download manager, or null when not requested
44
+ */
45
+ export function attachDownloads({ engine, browser, page, downloads, log }) {
46
+ const managerOptions = normalizeDownloadOptions(downloads);
47
+ if (!managerOptions) {
48
+ return null;
49
+ }
50
+
51
+ return createDownloadManager({
52
+ engine,
53
+ browser,
54
+ page,
55
+ log,
56
+ ...managerOptions,
57
+ });
58
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Where managed downloads are written (issue #88).
3
+ *
4
+ * The directory is resolved, created and probed for writability *before* the
5
+ * first download starts, because a permission problem discovered after a click
6
+ * looks like a missing file and gets blamed on the page.
7
+ */
8
+
9
+ import fs from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ /** Presets accepted in place of an absolute path. */
14
+ export const DOWNLOAD_DIRECTORY_PRESETS = Object.freeze({
15
+ USER_DOWNLOADS: 'user-downloads',
16
+ TEMPORARY: 'temporary',
17
+ });
18
+
19
+ /** Owner-only file mode for saved artifacts. */
20
+ export const ARTIFACT_FILE_MODE = 0o600;
21
+
22
+ /** Owner-only directory mode for the download root. */
23
+ export const ARTIFACT_DIRECTORY_MODE = 0o700;
24
+
25
+ /**
26
+ * Read the XDG user directory configuration.
27
+ *
28
+ * Linux users move their Downloads folder and localize its name; reading
29
+ * `user-dirs.dirs` is what makes `user-downloads` mean their folder rather
30
+ * than an English path that happens to exist.
31
+ *
32
+ * @returns {Promise<string|undefined>} Configured download directory
33
+ */
34
+ async function xdgDownloadDirectory() {
35
+ const configHome =
36
+ process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
37
+
38
+ let contents;
39
+ try {
40
+ contents = await fs.readFile(
41
+ path.join(configHome, 'user-dirs.dirs'),
42
+ 'utf8'
43
+ );
44
+ } catch {
45
+ return undefined;
46
+ }
47
+
48
+ const match = contents.match(/^\s*XDG_DOWNLOAD_DIR\s*=\s*"(.*)"\s*$/m);
49
+ if (!match) {
50
+ return undefined;
51
+ }
52
+
53
+ return match[1].replace(/^\$HOME/, os.homedir());
54
+ }
55
+
56
+ /**
57
+ * Resolve a download directory setting to an absolute path.
58
+ *
59
+ * @param {string} [directory] - Absolute path or a preset
60
+ * @returns {Promise<string>} Absolute directory path
61
+ */
62
+ export async function resolveDownloadDirectory(
63
+ directory = DOWNLOAD_DIRECTORY_PRESETS.USER_DOWNLOADS
64
+ ) {
65
+ if (directory === DOWNLOAD_DIRECTORY_PRESETS.TEMPORARY) {
66
+ return path.join(os.tmpdir(), 'browser-commander-downloads');
67
+ }
68
+
69
+ if (directory === DOWNLOAD_DIRECTORY_PRESETS.USER_DOWNLOADS) {
70
+ return (
71
+ (process.platform === 'linux'
72
+ ? await xdgDownloadDirectory()
73
+ : undefined) || path.join(os.homedir(), 'Downloads')
74
+ );
75
+ }
76
+
77
+ if (typeof directory !== 'string' || !directory) {
78
+ throw new TypeError(
79
+ "downloads.directory must be an absolute path, 'user-downloads' or 'temporary'"
80
+ );
81
+ }
82
+
83
+ if (!path.isAbsolute(directory)) {
84
+ throw new Error(
85
+ `downloads.directory must be absolute, received "${directory}"`
86
+ );
87
+ }
88
+
89
+ return path.resolve(directory);
90
+ }
91
+
92
+ /**
93
+ * Create the download directory and prove it can be written to.
94
+ *
95
+ * A directory that exists is not the same as a directory we may write in, so
96
+ * the probe writes and removes a file rather than trusting the mode bits.
97
+ *
98
+ * @param {string} root - Absolute download directory
99
+ * @returns {Promise<string>} The same directory, once it is usable
100
+ * @throws {Error} When the directory cannot be created or written to
101
+ */
102
+ export async function prepareDownloadDirectory(root) {
103
+ try {
104
+ await fs.mkdir(root, { recursive: true, mode: ARTIFACT_DIRECTORY_MODE });
105
+ } catch (error) {
106
+ throw new Error(
107
+ `download directory ${root} could not be created: ${error.message}`,
108
+ { cause: error }
109
+ );
110
+ }
111
+
112
+ const probe = path.join(root, `.browser-commander-probe-${process.pid}`);
113
+ try {
114
+ await fs.writeFile(probe, '', { mode: ARTIFACT_FILE_MODE });
115
+ } catch (error) {
116
+ throw new Error(
117
+ `download directory ${root} is not writable: ${error.message}`,
118
+ { cause: error }
119
+ );
120
+ } finally {
121
+ await fs.rm(probe, { force: true });
122
+ }
123
+
124
+ return root;
125
+ }