browser-commander 0.27.0 → 0.29.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 +5 -19
- package/CHANGELOG.md +22 -0
- package/README.md +23 -9
- package/eslint.config.js +11 -0
- package/package.json +5 -2
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +215 -36
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +26 -1
- package/src/browser/persistent-session.js +432 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +7 -2
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-adoption.js +96 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +82 -0
- package/src/browser/webdriver.js +4 -0
- package/src/capture/encoding.js +327 -0
- package/src/capture/errors.js +12 -0
- package/src/capture/files.js +9 -0
- package/src/capture/index.js +5 -0
- package/src/capture/recording.js +136 -0
- package/src/capture/screenshot.js +222 -0
- package/src/capture/video.js +186 -0
- package/src/capture/viewport.js +68 -0
- package/src/capture/worker.js +22 -0
- package/src/cli/args.js +69 -1
- package/src/cli/commands.js +116 -2
- package/src/cli/high-level-methods.js +47 -3
- package/src/cli/sessions.js +14 -3
- package/src/core/commander-features.js +110 -0
- package/src/core/constants.js +1 -0
- package/src/core/navigation-manager.js +4 -2
- package/src/core/navigation-readiness.js +30 -2
- package/src/core/navigation-safety.js +52 -0
- package/src/core/page-trigger-manager.js +280 -156
- package/src/core/readiness.js +10 -2
- package/src/elements/locators.js +26 -32
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +32 -10
- package/src/factory.js +13 -12
- package/src/index.js +4 -0
- package/src/interactions/click.js +44 -0
- package/src/interactions/overlays.js +73 -0
- package/src/traces/bundle.js +55 -8
- package/src/traces/checkpoints.js +103 -0
- package/src/traces/links.js +157 -2
- package/src/traces/mutation-stream.js +9 -2
- package/src/traces/network.js +309 -0
- package/src/traces/page-capture.js +203 -68
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +253 -247
- package/src/traces/redaction.js +89 -6
- package/src/traces/render.js +168 -0
- package/src/traces/rolling-bundle.js +240 -0
- package/src/traces/rolling.js +77 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +160 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +32 -0
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/issue-160.e2e.test.js +291 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
- package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
- package/tests/unit/browser/issue-160-connector.test.js +157 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launcher.test.js +5 -1
- package/tests/unit/browser/persistent-session.test.js +59 -0
- package/tests/unit/browser/quiet-ui.test.js +16 -0
- package/tests/unit/browser/tab-selection.test.js +38 -0
- package/tests/unit/capture/capture.test.js +52 -0
- package/tests/unit/capture/cleanup.test.js +24 -0
- package/tests/unit/capture/viewport.test.js +168 -0
- package/tests/unit/cli/dispatcher.test.js +25 -0
- package/tests/unit/core/interrupted-navigation.test.js +32 -0
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/trigger-stop-race.test.js +80 -0
- package/tests/unit/elements/visible-count.test.js +24 -0
- package/tests/unit/elements/wait-navigation.test.js +26 -0
- package/tests/unit/interactions/navigation-timeout.test.js +39 -0
- package/tests/unit/interactions/overlays.test.js +58 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- package/tests/unit/traces/issue-160-privacy.test.js +110 -0
- package/tests/unit/traces/issue-160-rotation.test.js +140 -0
- package/tests/unit/traces/network.test.js +149 -0
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
isNavigationError,
|
|
3
|
+
waitNavigationState,
|
|
4
|
+
recoverWaitError,
|
|
5
|
+
} from '../core/navigation-safety.js';
|
|
6
|
+
import { createPlaywrightLocator, requireSelector } from './locators.js';
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Query single element
|
|
@@ -9,67 +13,38 @@ import { createPlaywrightLocator } from './locators.js';
|
|
|
9
13
|
* @param {string} options.selector - CSS selector
|
|
10
14
|
* @returns {Promise<Object|null>} - Element handle or null
|
|
11
15
|
*/
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (!selector) {
|
|
16
|
-
throw new Error('selector is required in options');
|
|
17
|
-
}
|
|
16
|
+
export function querySelector(options = {}) {
|
|
17
|
+
return queryElements(options, true);
|
|
18
|
+
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const count = await locator.count();
|
|
23
|
-
return count > 0 ? locator : null;
|
|
24
|
-
} else {
|
|
25
|
-
return await page.$(selector);
|
|
26
|
-
}
|
|
27
|
-
} catch (error) {
|
|
28
|
-
if (isNavigationError(error)) {
|
|
29
|
-
console.log(
|
|
30
|
-
'⚠️ Navigation detected during querySelector, returning null'
|
|
31
|
-
);
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
throw error;
|
|
35
|
-
}
|
|
20
|
+
/** Return every matching element with the same navigation recovery as querySelector. */
|
|
21
|
+
export function querySelectorAll(options = {}) {
|
|
22
|
+
return queryElements(options, false);
|
|
36
23
|
}
|
|
37
24
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
* @param {Object} options - Configuration options
|
|
41
|
-
* @param {Object} options.page - Browser page object
|
|
42
|
-
* @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
|
|
43
|
-
* @param {string} options.selector - CSS selector
|
|
44
|
-
* @returns {Promise<Array>} - Array of element handles
|
|
45
|
-
*/
|
|
46
|
-
export async function querySelectorAll(options = {}) {
|
|
25
|
+
async function queryElements(options, single) {
|
|
26
|
+
requireSelector(options.selector);
|
|
47
27
|
const { page, engine, selector } = options;
|
|
48
|
-
|
|
49
|
-
if (!selector) {
|
|
50
|
-
throw new Error('selector is required in options');
|
|
51
|
-
}
|
|
52
|
-
|
|
53
28
|
try {
|
|
54
|
-
if (engine
|
|
55
|
-
|
|
56
|
-
const count = await locator.count();
|
|
57
|
-
const elements = [];
|
|
58
|
-
for (let i = 0; i < count; i++) {
|
|
59
|
-
elements.push(locator.nth(i));
|
|
60
|
-
}
|
|
61
|
-
return elements;
|
|
62
|
-
} else {
|
|
63
|
-
return await page.$$(selector);
|
|
29
|
+
if (engine !== 'playwright') {
|
|
30
|
+
return single ? await page.$(selector) : await page.$$(selector);
|
|
64
31
|
}
|
|
32
|
+
const matching = createPlaywrightLocator({ page, selector });
|
|
33
|
+
const locator = single ? matching.first() : matching;
|
|
34
|
+
const total = await locator.count();
|
|
35
|
+
return single
|
|
36
|
+
? total > 0
|
|
37
|
+
? locator
|
|
38
|
+
: null
|
|
39
|
+
: Array.from({ length: total }, (_, index) => locator.nth(index));
|
|
65
40
|
} catch (error) {
|
|
66
|
-
if (isNavigationError(error)) {
|
|
67
|
-
|
|
68
|
-
'⚠️ Navigation detected during querySelectorAll, returning empty array'
|
|
69
|
-
);
|
|
70
|
-
return [];
|
|
41
|
+
if (!isNavigationError(error)) {
|
|
42
|
+
throw error;
|
|
71
43
|
}
|
|
72
|
-
|
|
44
|
+
console.log(
|
|
45
|
+
`⚠️ Navigation detected during ${single ? 'querySelector' : 'querySelectorAll'}, recovering gracefully`
|
|
46
|
+
);
|
|
47
|
+
return single ? null : [];
|
|
73
48
|
}
|
|
74
49
|
}
|
|
75
50
|
|
|
@@ -175,9 +150,7 @@ function parsePlaywrightTextSelector(selector) {
|
|
|
175
150
|
export async function normalizeSelector(options = {}) {
|
|
176
151
|
const { page, engine, selector } = options;
|
|
177
152
|
|
|
178
|
-
|
|
179
|
-
throw new Error('selector is required in options');
|
|
180
|
-
}
|
|
153
|
+
requireSelector(selector);
|
|
181
154
|
|
|
182
155
|
// DEFENSIVE: Validate selector type - must be string or Puppeteer text selector object
|
|
183
156
|
// Arrays and other invalid types should be rejected to prevent downstream querySelectorAll errors
|
|
@@ -360,19 +333,11 @@ export function withTextSelectorSupport(fn, engine, page) {
|
|
|
360
333
|
* @returns {Promise<boolean>} - True if selector found, false on navigation
|
|
361
334
|
*/
|
|
362
335
|
export async function waitForSelector(options = {}) {
|
|
363
|
-
const {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
selector,
|
|
367
|
-
visible = true,
|
|
368
|
-
timeout = 5000,
|
|
369
|
-
throwOnNavigation = true,
|
|
370
|
-
} = options;
|
|
371
|
-
|
|
372
|
-
if (!selector) {
|
|
373
|
-
throw new Error('selector is required in options');
|
|
374
|
-
}
|
|
336
|
+
const { page, engine, selector, visible = true, timeout = 5000 } = options;
|
|
337
|
+
|
|
338
|
+
requireSelector(selector);
|
|
375
339
|
|
|
340
|
+
const initial = waitNavigationState(page, options.navigationManager);
|
|
376
341
|
try {
|
|
377
342
|
if (engine === 'playwright') {
|
|
378
343
|
const locator = createPlaywrightLocator({ page, selector });
|
|
@@ -384,16 +349,7 @@ export async function waitForSelector(options = {}) {
|
|
|
384
349
|
await page.waitForSelector(selector, { visible, timeout });
|
|
385
350
|
}
|
|
386
351
|
return true;
|
|
387
|
-
} catch (
|
|
388
|
-
|
|
389
|
-
console.log(
|
|
390
|
-
'⚠️ Navigation detected during waitForSelector, recovering gracefully'
|
|
391
|
-
);
|
|
392
|
-
if (throwOnNavigation) {
|
|
393
|
-
throw error;
|
|
394
|
-
}
|
|
395
|
-
return false;
|
|
396
|
-
}
|
|
397
|
-
throw error;
|
|
352
|
+
} catch (caught) {
|
|
353
|
+
return recoverWaitError(caught, options, initial, 'waitForSelector', false);
|
|
398
354
|
}
|
|
399
355
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TIMING } from '../core/constants.js';
|
|
2
2
|
import { isNavigationError } from '../core/navigation-safety.js';
|
|
3
|
-
import { getLocatorOrElement } from './locators.js';
|
|
3
|
+
import { getLocatorOrElement, requireSelector } from './locators.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Check if element is visible
|
|
@@ -13,9 +13,7 @@ import { getLocatorOrElement } from './locators.js';
|
|
|
13
13
|
export async function isVisible(options = {}) {
|
|
14
14
|
const { page, engine, selector } = options;
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
throw new Error('selector is required in options');
|
|
18
|
-
}
|
|
16
|
+
requireSelector(selector);
|
|
19
17
|
|
|
20
18
|
try {
|
|
21
19
|
if (engine === 'playwright') {
|
|
@@ -62,9 +60,7 @@ export async function isVisible(options = {}) {
|
|
|
62
60
|
export async function isEnabled(options = {}) {
|
|
63
61
|
const { page, engine, selector, disabledClasses = ['disabled'] } = options;
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
throw new Error('selector is required in options');
|
|
67
|
-
}
|
|
63
|
+
requireSelector(selector);
|
|
68
64
|
|
|
69
65
|
try {
|
|
70
66
|
if (engine === 'playwright') {
|
|
@@ -119,9 +115,7 @@ export async function isEnabled(options = {}) {
|
|
|
119
115
|
export async function count(options = {}) {
|
|
120
116
|
const { page, engine, selector } = options;
|
|
121
117
|
|
|
122
|
-
|
|
123
|
-
throw new Error('selector is required in options');
|
|
124
|
-
}
|
|
118
|
+
requireSelector(selector);
|
|
125
119
|
|
|
126
120
|
try {
|
|
127
121
|
// Handle Puppeteer text selectors
|
|
@@ -150,9 +144,37 @@ export async function count(options = {}) {
|
|
|
150
144
|
}
|
|
151
145
|
|
|
152
146
|
if (engine === 'playwright') {
|
|
147
|
+
if (options.visible) {
|
|
148
|
+
return await page
|
|
149
|
+
.locator(selector)
|
|
150
|
+
.evaluateAll(
|
|
151
|
+
(elements) =>
|
|
152
|
+
elements.filter(
|
|
153
|
+
(element) =>
|
|
154
|
+
element.getClientRects().length > 0 &&
|
|
155
|
+
element.checkVisibility({ visibilityProperty: true })
|
|
156
|
+
).length
|
|
157
|
+
);
|
|
158
|
+
}
|
|
153
159
|
return await page.locator(selector).count();
|
|
154
160
|
} else {
|
|
155
161
|
const elements = await page.$$(selector);
|
|
162
|
+
if (options.visible) {
|
|
163
|
+
let visible = 0;
|
|
164
|
+
for (const element of elements) {
|
|
165
|
+
if (
|
|
166
|
+
await page.evaluate(
|
|
167
|
+
(element) =>
|
|
168
|
+
element.getClientRects().length > 0 &&
|
|
169
|
+
element.checkVisibility({ visibilityProperty: true }),
|
|
170
|
+
element
|
|
171
|
+
)
|
|
172
|
+
) {
|
|
173
|
+
visible += 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return visible;
|
|
177
|
+
}
|
|
156
178
|
return elements.length;
|
|
157
179
|
}
|
|
158
180
|
} catch (error) {
|
package/src/factory.js
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from './core/page-trigger-manager.js';
|
|
23
23
|
import { createBoundFunctions } from './bindings.js';
|
|
24
24
|
import { attachDownloads } from './downloads/attach.js';
|
|
25
|
-
import {
|
|
25
|
+
import { attachCommanderFeatures } from './core/commander-features.js';
|
|
26
26
|
import { createCdpSession } from './browser/cdp-session.js';
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -66,7 +66,7 @@ export function makeBrowserCommander(options = {}) {
|
|
|
66
66
|
page,
|
|
67
67
|
engine,
|
|
68
68
|
log,
|
|
69
|
-
idleTimeout: 30000, // Wait 30 seconds without requests before considering network idle
|
|
69
|
+
idleTimeout: options.networkIdleForMs ?? 30000, // Wait 30 seconds without requests before considering network idle
|
|
70
70
|
});
|
|
71
71
|
networkTracker.startTracking();
|
|
72
72
|
}
|
|
@@ -92,6 +92,7 @@ export function makeBrowserCommander(options = {}) {
|
|
|
92
92
|
log,
|
|
93
93
|
networkTracker,
|
|
94
94
|
});
|
|
95
|
+
navigationManager.configure(navigationOptions(options));
|
|
95
96
|
navigationManager.startListening();
|
|
96
97
|
|
|
97
98
|
// Create PageSession factory
|
|
@@ -159,7 +160,6 @@ export function makeBrowserCommander(options = {}) {
|
|
|
159
160
|
|
|
160
161
|
// All bound functions
|
|
161
162
|
...boundFunctions,
|
|
162
|
-
|
|
163
163
|
// Managed downloads (issue #88). Present once a manager is attached,
|
|
164
164
|
// either by passing the one launchBrowser() returned or by calling
|
|
165
165
|
// configureDownloads() on an existing commander.
|
|
@@ -192,15 +192,6 @@ export function makeBrowserCommander(options = {}) {
|
|
|
192
192
|
*/
|
|
193
193
|
createCdpSession: () => createCdpSession(page, { engine }),
|
|
194
194
|
|
|
195
|
-
/**
|
|
196
|
-
* Start recording a privacy-aware trace of this session (issue #87).
|
|
197
|
-
*
|
|
198
|
-
* @param {Object} [traceOptions] - {output, mode, screenshots, dom, events, privacy, limits}
|
|
199
|
-
* @returns {Promise<Object>} The running trace: {checkpoint, event, stop}
|
|
200
|
-
*/
|
|
201
|
-
startTrace: (traceOptions = {}) =>
|
|
202
|
-
startTrace({ commander, page, log, ...traceOptions }),
|
|
203
|
-
|
|
204
195
|
// Lifecycle
|
|
205
196
|
destroy,
|
|
206
197
|
|
|
@@ -295,5 +286,15 @@ export function makeBrowserCommander(options = {}) {
|
|
|
295
286
|
pageTriggerManager.initialize(commander);
|
|
296
287
|
}
|
|
297
288
|
|
|
289
|
+
attachCommanderFeatures(commander, options, makeBrowserCommander);
|
|
298
290
|
return commander;
|
|
299
291
|
}
|
|
292
|
+
|
|
293
|
+
function navigationOptions(options) {
|
|
294
|
+
return {
|
|
295
|
+
...(options.navigationManager ?? {}),
|
|
296
|
+
...(options.networkIdleTimeout === undefined
|
|
297
|
+
? {}
|
|
298
|
+
: { networkIdleTimeout: options.networkIdleTimeout }),
|
|
299
|
+
};
|
|
300
|
+
}
|
package/src/index.js
CHANGED
|
@@ -18,3 +18,7 @@ export {
|
|
|
18
18
|
|
|
19
19
|
// Re-export all public APIs from exports module
|
|
20
20
|
export * from './exports.js';
|
|
21
|
+
export * from './capture/index.js';
|
|
22
|
+
export { connectOrLaunch } from './browser/persistent-session.js';
|
|
23
|
+
|
|
24
|
+
export { renderTrace, summarizeTrace } from './traces/render.js';
|
|
@@ -31,6 +31,25 @@ function isInterrupted(error) {
|
|
|
31
31
|
return isNavigationError(error) || isActionStoppedError(error);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function waitInterruptedByNavigation({
|
|
35
|
+
error,
|
|
36
|
+
page,
|
|
37
|
+
navigationManager,
|
|
38
|
+
startUrl,
|
|
39
|
+
startSessionId,
|
|
40
|
+
preparationFinished,
|
|
41
|
+
}) {
|
|
42
|
+
const timedOut =
|
|
43
|
+
error.name === 'TimeoutError' ||
|
|
44
|
+
error.name === 'NavigationInterruptedError' ||
|
|
45
|
+
/timeout|timed out/i.test(error.message);
|
|
46
|
+
const navigationChanged =
|
|
47
|
+
page.url() !== startUrl ||
|
|
48
|
+
(startSessionId !== undefined &&
|
|
49
|
+
navigationManager?.getSessionId?.() !== startSessionId);
|
|
50
|
+
return timedOut && navigationChanged && !preparationFinished;
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
/**
|
|
35
54
|
* Read the target element's observable state under the click's own budget.
|
|
36
55
|
*
|
|
@@ -975,6 +994,7 @@ export async function clickButton(options = {}) {
|
|
|
975
994
|
|
|
976
995
|
const startUrl = page.url();
|
|
977
996
|
const startSessionId = navigationManager?.getSessionId?.();
|
|
997
|
+
let preparationFinished = false;
|
|
978
998
|
|
|
979
999
|
try {
|
|
980
1000
|
const prepareResult = await prepareElement({
|
|
@@ -1012,6 +1032,7 @@ export async function clickButton(options = {}) {
|
|
|
1012
1032
|
});
|
|
1013
1033
|
}
|
|
1014
1034
|
|
|
1035
|
+
preparationFinished = true;
|
|
1015
1036
|
const clickResult = await clickElement({
|
|
1016
1037
|
page,
|
|
1017
1038
|
engine,
|
|
@@ -1055,6 +1076,29 @@ export async function clickButton(options = {}) {
|
|
|
1055
1076
|
actionId,
|
|
1056
1077
|
});
|
|
1057
1078
|
} catch (error) {
|
|
1079
|
+
if (
|
|
1080
|
+
waitInterruptedByNavigation({
|
|
1081
|
+
error,
|
|
1082
|
+
page,
|
|
1083
|
+
navigationManager,
|
|
1084
|
+
startUrl,
|
|
1085
|
+
startSessionId,
|
|
1086
|
+
preparationFinished,
|
|
1087
|
+
})
|
|
1088
|
+
) {
|
|
1089
|
+
return makeClickResult({
|
|
1090
|
+
status: CLICK_STATUS.INTERRUPTED,
|
|
1091
|
+
dispatched: false,
|
|
1092
|
+
effect: CLICK_EFFECT.NOT_OBSERVED,
|
|
1093
|
+
navigated: true,
|
|
1094
|
+
reason: 'navigation interrupted the element wait before the click',
|
|
1095
|
+
evidence: [
|
|
1096
|
+
evidence('interrupted', { message: error.message, phase: 'prepare' }),
|
|
1097
|
+
],
|
|
1098
|
+
elapsedMs: elapsed(),
|
|
1099
|
+
actionId,
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1058
1102
|
if (isInterrupted(error)) {
|
|
1059
1103
|
log.debug(
|
|
1060
1104
|
() =>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { withDeadline } from '../traces/deadline.js';
|
|
2
|
+
import { isVisible } from '../elements/visibility.js';
|
|
3
|
+
|
|
4
|
+
async function dismissVisible({ page, engine, selector, timeout = 1000 }) {
|
|
5
|
+
if (engine === 'playwright') {
|
|
6
|
+
const matches = page.locator(selector);
|
|
7
|
+
for (let index = 0; index < (await matches.count()); index++) {
|
|
8
|
+
const target = matches.nth(index);
|
|
9
|
+
if (await target.isVisible()) {
|
|
10
|
+
await target.click({ timeout });
|
|
11
|
+
return 'dismissed';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return 'absent';
|
|
15
|
+
}
|
|
16
|
+
const matches = await page.$$(selector);
|
|
17
|
+
try {
|
|
18
|
+
for (const target of matches) {
|
|
19
|
+
if (await isVisible({ page, engine, selector: target })) {
|
|
20
|
+
await target.click({ timeout });
|
|
21
|
+
return 'dismissed';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return 'absent';
|
|
25
|
+
} finally {
|
|
26
|
+
await Promise.allSettled(
|
|
27
|
+
matches.map(async (target) => await target.dispose?.())
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Dismiss caller-named overlays; a failed dismissal never blocks an action. */
|
|
33
|
+
export async function dismissOverlays({
|
|
34
|
+
page,
|
|
35
|
+
engine,
|
|
36
|
+
overlays = [],
|
|
37
|
+
onDismiss,
|
|
38
|
+
}) {
|
|
39
|
+
if (!Array.isArray(overlays)) {
|
|
40
|
+
throw new TypeError('overlays must be an array');
|
|
41
|
+
}
|
|
42
|
+
const reports = [];
|
|
43
|
+
for (const item of overlays) {
|
|
44
|
+
const options = typeof item === 'string' ? { selector: item } : item;
|
|
45
|
+
if (!options?.selector) {
|
|
46
|
+
throw new TypeError('overlay requires selector');
|
|
47
|
+
}
|
|
48
|
+
const report = { selector: options.selector, status: 'absent' };
|
|
49
|
+
try {
|
|
50
|
+
report.status = await withDeadline(
|
|
51
|
+
dismissVisible({ page, engine, ...options }),
|
|
52
|
+
options.timeout || 1000,
|
|
53
|
+
`overlay ${options.selector}`
|
|
54
|
+
);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
report.status = 'error';
|
|
57
|
+
report.error = error;
|
|
58
|
+
}
|
|
59
|
+
reports.push(report);
|
|
60
|
+
for (const callback of [options.onDismiss, onDismiss]) {
|
|
61
|
+
try {
|
|
62
|
+
await withDeadline(
|
|
63
|
+
Promise.resolve(callback?.(report)),
|
|
64
|
+
options.timeout || 1000,
|
|
65
|
+
'overlay report'
|
|
66
|
+
);
|
|
67
|
+
} catch {
|
|
68
|
+
// Reporting must not interrupt the caller's requested interaction.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return reports;
|
|
73
|
+
}
|
package/src/traces/bundle.js
CHANGED
|
@@ -43,6 +43,7 @@ export async function openTraceBundle(options = {}) {
|
|
|
43
43
|
now = () => Date.now(),
|
|
44
44
|
monotonic = () => performance.now(),
|
|
45
45
|
onEvent = null,
|
|
46
|
+
nextSequence = null,
|
|
46
47
|
} = options;
|
|
47
48
|
|
|
48
49
|
if (typeof output !== 'string' || output === '') {
|
|
@@ -70,6 +71,9 @@ export async function openTraceBundle(options = {}) {
|
|
|
70
71
|
let dropped = 0;
|
|
71
72
|
const counts = { checkpoints: 0, events: 0, mutationBatches: 0 };
|
|
72
73
|
const problems = [];
|
|
74
|
+
let mutationIndex = null;
|
|
75
|
+
let mutationBytes = 0;
|
|
76
|
+
let mutationTruncated = false;
|
|
73
77
|
|
|
74
78
|
/** Close the timeline exactly once, after every queued append. */
|
|
75
79
|
function closeEvents() {
|
|
@@ -117,11 +121,13 @@ export async function openTraceBundle(options = {}) {
|
|
|
117
121
|
* @returns {Promise<Object|null>} The event as written
|
|
118
122
|
*/
|
|
119
123
|
async function appendEvent(event, { retry = true } = {}) {
|
|
124
|
+
const number = nextSequence?.() ?? ++sequence;
|
|
120
125
|
const record = {
|
|
121
|
-
sequence:
|
|
126
|
+
sequence: number,
|
|
122
127
|
at: new Date(now()).toISOString(),
|
|
123
128
|
monotonicMs: Math.round(monotonic()),
|
|
124
129
|
...event,
|
|
130
|
+
...(nextSequence ? { sequence: number } : {}),
|
|
125
131
|
};
|
|
126
132
|
const line = `${JSON.stringify(record)}\n`;
|
|
127
133
|
const bytes = Buffer.byteLength(line);
|
|
@@ -181,10 +187,17 @@ export async function openTraceBundle(options = {}) {
|
|
|
181
187
|
* @param {string|Buffer} contents - What to write
|
|
182
188
|
* @returns {Promise<Object|null>} `{member, bytes}` or null when dropped
|
|
183
189
|
*/
|
|
184
|
-
async function writeMember(member, contents) {
|
|
190
|
+
async function writeMember(member, contents, { append = false } = {}) {
|
|
185
191
|
const data = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
|
|
192
|
+
const target = path.join(root, member);
|
|
193
|
+
const previous = append
|
|
194
|
+
? await fs.stat(target).then(
|
|
195
|
+
(value) => value.size,
|
|
196
|
+
() => 0
|
|
197
|
+
)
|
|
198
|
+
: 0;
|
|
186
199
|
|
|
187
|
-
if (!fits(data.length)) {
|
|
200
|
+
if (!fits(data.length) || previous + data.length > maxResourceBytes) {
|
|
188
201
|
await drop({
|
|
189
202
|
reason: TRACE_DROP_REASON.SIZE_LIMIT,
|
|
190
203
|
member,
|
|
@@ -193,13 +206,15 @@ export async function openTraceBundle(options = {}) {
|
|
|
193
206
|
return null;
|
|
194
207
|
}
|
|
195
208
|
|
|
196
|
-
const target = path.join(root, member);
|
|
197
209
|
try {
|
|
198
210
|
await fs.mkdir(path.dirname(target), {
|
|
199
211
|
recursive: true,
|
|
200
212
|
mode: TRACE_DIRECTORY_MODE,
|
|
201
213
|
});
|
|
202
|
-
await fs.writeFile(target, data, {
|
|
214
|
+
await fs.writeFile(target, data, {
|
|
215
|
+
mode: TRACE_FILE_MODE,
|
|
216
|
+
flag: append ? 'a' : 'w',
|
|
217
|
+
});
|
|
203
218
|
written += data.length;
|
|
204
219
|
return { member, bytes: data.length };
|
|
205
220
|
} catch (error) {
|
|
@@ -266,10 +281,39 @@ export async function openTraceBundle(options = {}) {
|
|
|
266
281
|
return null;
|
|
267
282
|
}
|
|
268
283
|
const member = `${TRACE_FILES.MUTATIONS_DIR}/${sequenceName(index)}.ndjson`;
|
|
269
|
-
|
|
270
|
-
|
|
284
|
+
if (mutationIndex !== index) {
|
|
285
|
+
mutationIndex = index;
|
|
286
|
+
mutationBytes = 0;
|
|
287
|
+
mutationTruncated = false;
|
|
288
|
+
}
|
|
289
|
+
if (mutationTruncated) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
const ceiling = Math.min(
|
|
293
|
+
limits.maxMutationBytes ?? 4 * 1024 * 1024,
|
|
294
|
+
maxResourceBytes
|
|
295
|
+
);
|
|
296
|
+
const lines = [];
|
|
297
|
+
for (const batch of batches) {
|
|
298
|
+
const line = `${JSON.stringify(batch)}\n`;
|
|
299
|
+
if (mutationBytes + Buffer.byteLength(line) + 256 > ceiling) {
|
|
300
|
+
mutationTruncated = true;
|
|
301
|
+
const marker = `${JSON.stringify({ records: [{ kind: 'truncated', reason: 'mutation interval size limit' }] })}\n`;
|
|
302
|
+
if (mutationBytes + Buffer.byteLength(marker) <= ceiling) {
|
|
303
|
+
lines.push(marker);
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
lines.push(line);
|
|
308
|
+
mutationBytes += Buffer.byteLength(line);
|
|
309
|
+
}
|
|
310
|
+
if (!lines.length) {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
const body = lines.join('');
|
|
314
|
+
const result = await writeMember(member, body, { append: true });
|
|
271
315
|
if (result) {
|
|
272
|
-
counts.mutationBatches +=
|
|
316
|
+
counts.mutationBatches += lines.length;
|
|
273
317
|
}
|
|
274
318
|
return result ? result.member : null;
|
|
275
319
|
}
|
|
@@ -310,6 +354,9 @@ export async function openTraceBundle(options = {}) {
|
|
|
310
354
|
get bytesWritten() {
|
|
311
355
|
return written;
|
|
312
356
|
},
|
|
357
|
+
get mutationTruncated() {
|
|
358
|
+
return mutationTruncated;
|
|
359
|
+
},
|
|
313
360
|
appendEvent,
|
|
314
361
|
writeMember,
|
|
315
362
|
writeCheckpoint,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { captureSnapshotInPage } from './page-capture.js';
|
|
2
|
+
import { withDeadline } from './deadline.js';
|
|
3
|
+
import { redactText, redactValue, redactUrl, REDACTED } from './redaction.js';
|
|
4
|
+
import { TRACE_EVENT, TRACE_DROP_REASON } from './schema.js';
|
|
5
|
+
export function createCheckpointRecorder({
|
|
6
|
+
isStopped,
|
|
7
|
+
captureState,
|
|
8
|
+
mutations,
|
|
9
|
+
evaluateInPage,
|
|
10
|
+
privacyOptions,
|
|
11
|
+
domOptions,
|
|
12
|
+
links,
|
|
13
|
+
limits,
|
|
14
|
+
captureTimeoutMs,
|
|
15
|
+
bundle,
|
|
16
|
+
screenshot,
|
|
17
|
+
checkpoints,
|
|
18
|
+
record,
|
|
19
|
+
}) {
|
|
20
|
+
/**
|
|
21
|
+
* Capture a named checkpoint.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} name - What this moment is
|
|
24
|
+
* @param {Object} [checkpointOptions] - `{actor, reason, screenshots}`
|
|
25
|
+
* @returns {Promise<Object|null>} `{index, name, members}`
|
|
26
|
+
*/
|
|
27
|
+
async function checkpoint(name, checkpointOptions = {}) {
|
|
28
|
+
if (isStopped()) {
|
|
29
|
+
throw new Error('this trace has already been stopped');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { actor = 'automation', reason = 'checkpoint' } = checkpointOptions;
|
|
33
|
+
const index = ++captureState.index;
|
|
34
|
+
|
|
35
|
+
// Mutations are drained first so the batches belong to the interval that
|
|
36
|
+
// ended here, not to the one that starts now.
|
|
37
|
+
await mutations.drain(index - 1 > 0 ? index - 1 : 0);
|
|
38
|
+
|
|
39
|
+
let captured = null;
|
|
40
|
+
try {
|
|
41
|
+
captured = await withDeadline(
|
|
42
|
+
evaluateInPage(captureSnapshotInPage, {
|
|
43
|
+
redactSelectors: privacyOptions.redactSelectors,
|
|
44
|
+
redactAttributes: privacyOptions.redactAttributes,
|
|
45
|
+
useDefaults: privacyOptions.useDefaults,
|
|
46
|
+
redacted: REDACTED,
|
|
47
|
+
html: domOptions.html,
|
|
48
|
+
liveControlState: domOptions.liveControlState,
|
|
49
|
+
openShadowRoots: domOptions.openShadowRoots,
|
|
50
|
+
ignoreSelectors: domOptions.ignoreSelectors ?? [],
|
|
51
|
+
captureText: links?.dom === 'text',
|
|
52
|
+
maxHtmlBytes: limits.maxHtmlBytes ?? 4 * 1024 * 1024,
|
|
53
|
+
}),
|
|
54
|
+
captureTimeoutMs,
|
|
55
|
+
'trace checkpoint capture'
|
|
56
|
+
);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
await bundle.drop({
|
|
59
|
+
reason: /closed/i.test(error.message)
|
|
60
|
+
? TRACE_DROP_REASON.PAGE_CLOSED
|
|
61
|
+
: TRACE_DROP_REASON.CAPTURE_FAILED,
|
|
62
|
+
member: `checkpoints/${index}`,
|
|
63
|
+
detail: error.message,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const shot = await screenshot(reason);
|
|
68
|
+
const state = captured?.state
|
|
69
|
+
? redactValue(captured.state, privacyOptions)
|
|
70
|
+
: null;
|
|
71
|
+
|
|
72
|
+
const members = await bundle.writeCheckpoint({
|
|
73
|
+
index,
|
|
74
|
+
html: redactText(captured?.html, privacyOptions, {
|
|
75
|
+
kind: 'dom',
|
|
76
|
+
name: 'html',
|
|
77
|
+
}),
|
|
78
|
+
state: state ? { ...state, name, actor, reason } : undefined,
|
|
79
|
+
screenshot: shot ?? undefined,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const entry = {
|
|
83
|
+
index,
|
|
84
|
+
name,
|
|
85
|
+
actor,
|
|
86
|
+
reason,
|
|
87
|
+
url: state ? redactUrl(state.url, privacyOptions) : null,
|
|
88
|
+
truncated: Boolean(captured?.truncated),
|
|
89
|
+
members,
|
|
90
|
+
};
|
|
91
|
+
checkpoints.push(entry);
|
|
92
|
+
await record(TRACE_EVENT.CHECKPOINT, entry);
|
|
93
|
+
|
|
94
|
+
// The init script covers every document created from here on; this covers
|
|
95
|
+
// a frame that was attached without one, which is cheap because installing
|
|
96
|
+
// over a recorder that is already observing does nothing.
|
|
97
|
+
await mutations.install();
|
|
98
|
+
|
|
99
|
+
return entry;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return checkpoint;
|
|
103
|
+
}
|