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.
- package/.jscpd-baseline.json +4 -17
- package/CHANGELOG.md +34 -0
- package/README.md +84 -0
- package/eslint.config.js +5 -0
- package/package.json +4 -1
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +453 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +188 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +72 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +323 -0
- package/src/traces/observers.js +246 -0
- package/src/traces/page-capture.js +365 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +437 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +165 -0
- package/src/traces/viewer.js +302 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +188 -0
- package/tests/e2e/traces.e2e.test.js +457 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +109 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +198 -0
- package/tests/helpers/trace-server.js +117 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +354 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +562 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- 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
|
+
}
|