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
package/src/cli/args.js
CHANGED
|
@@ -33,6 +33,11 @@ const BROWSER_OPTIONS = Object.freeze({
|
|
|
33
33
|
'first-run': FLAG,
|
|
34
34
|
'driver-path': STRING,
|
|
35
35
|
bidi: FLAG,
|
|
36
|
+
'remote-debugging-port': STRING,
|
|
37
|
+
'idle-timeout-ms': STRING,
|
|
38
|
+
'adopt-existing': FLAG,
|
|
39
|
+
'close-new-tabs': FLAG,
|
|
40
|
+
'no-defaults': FLAG,
|
|
36
41
|
});
|
|
37
42
|
|
|
38
43
|
/** Options accepted by every page command. */
|
|
@@ -41,8 +46,40 @@ const PAGE_OPTIONS = Object.freeze({
|
|
|
41
46
|
'cdp-endpoint': STRING,
|
|
42
47
|
'server-url': STRING,
|
|
43
48
|
url: STRING,
|
|
49
|
+
'target-id': STRING,
|
|
50
|
+
'single-tab': FLAG,
|
|
44
51
|
});
|
|
45
52
|
|
|
53
|
+
const CAPTURE_OPTIONS = {
|
|
54
|
+
format: STRING,
|
|
55
|
+
quality: STRING,
|
|
56
|
+
fps: STRING,
|
|
57
|
+
scale: STRING,
|
|
58
|
+
loop: STRING,
|
|
59
|
+
palette: STRING,
|
|
60
|
+
dither: FLAG,
|
|
61
|
+
optimize: FLAG,
|
|
62
|
+
ffmpeg: STRING,
|
|
63
|
+
size: STRING,
|
|
64
|
+
};
|
|
65
|
+
const SCREENSHOT_OPTIONS = {
|
|
66
|
+
...PAGE_OPTIONS,
|
|
67
|
+
'full-page': FLAG,
|
|
68
|
+
'stable-viewport': FLAG,
|
|
69
|
+
selector: STRING,
|
|
70
|
+
clip: STRING,
|
|
71
|
+
format: STRING,
|
|
72
|
+
quality: STRING,
|
|
73
|
+
scale: STRING,
|
|
74
|
+
'omit-background': FLAG,
|
|
75
|
+
animations: STRING,
|
|
76
|
+
caret: STRING,
|
|
77
|
+
'hide-scrollbars': FLAG,
|
|
78
|
+
'hide-caret': FLAG,
|
|
79
|
+
'disable-animations': FLAG,
|
|
80
|
+
'wait-for-fonts': FLAG,
|
|
81
|
+
};
|
|
82
|
+
|
|
46
83
|
/**
|
|
47
84
|
* Every command: its positional argument names and its options.
|
|
48
85
|
* Two-word commands (`trace start`) are keyed by both words.
|
|
@@ -66,12 +103,36 @@ export const COMMANDS = Object.freeze({
|
|
|
66
103
|
eval: { positionals: ['expression'], options: PAGE_OPTIONS },
|
|
67
104
|
screenshot: {
|
|
68
105
|
positionals: ['path'],
|
|
69
|
-
options:
|
|
106
|
+
options: SCREENSHOT_OPTIONS,
|
|
70
107
|
},
|
|
71
108
|
pdf: { positionals: ['path'], options: PAGE_OPTIONS },
|
|
72
109
|
'trace start': { positionals: [], options: { ...PAGE_OPTIONS, out: STRING } },
|
|
73
110
|
'trace stop': { positionals: [], options: { out: STRING } },
|
|
74
111
|
'trace view': { positionals: ['dir'], options: { out: STRING } },
|
|
112
|
+
'trace summarize': {
|
|
113
|
+
positionals: ['dir'],
|
|
114
|
+
options: { from: STRING, to: STRING, grep: STRING },
|
|
115
|
+
},
|
|
116
|
+
'trace render': {
|
|
117
|
+
positionals: ['dir'],
|
|
118
|
+
options: { ...CAPTURE_OPTIONS, out: STRING, from: STRING, to: STRING },
|
|
119
|
+
},
|
|
120
|
+
'record start': {
|
|
121
|
+
positionals: [],
|
|
122
|
+
options: {
|
|
123
|
+
...PAGE_OPTIONS,
|
|
124
|
+
...CAPTURE_OPTIONS,
|
|
125
|
+
out: STRING,
|
|
126
|
+
'max-frames': STRING,
|
|
127
|
+
'max-bytes': STRING,
|
|
128
|
+
'max-duration-ms': STRING,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
'record stop': { positionals: [], options: { out: STRING } },
|
|
132
|
+
gif: {
|
|
133
|
+
positionals: [],
|
|
134
|
+
options: { ...CAPTURE_OPTIONS, frame: STRINGS, out: STRING },
|
|
135
|
+
},
|
|
75
136
|
'cookies import': {
|
|
76
137
|
positionals: [],
|
|
77
138
|
options: {
|
|
@@ -255,6 +316,13 @@ export function launchParams(options) {
|
|
|
255
316
|
firstRun: options.firstRun,
|
|
256
317
|
driverPath: options.driverPath,
|
|
257
318
|
bidi: options.bidi,
|
|
319
|
+
keepOpen: options.keepOpen,
|
|
320
|
+
remoteDebuggingPort: integerOption(
|
|
321
|
+
options,
|
|
322
|
+
'remoteDebuggingPort',
|
|
323
|
+
'launch'
|
|
324
|
+
),
|
|
325
|
+
idleTimeoutMs: integerOption(options, 'idleTimeoutMs', 'launch'),
|
|
258
326
|
};
|
|
259
327
|
return Object.fromEntries(
|
|
260
328
|
Object.entries(params).filter(([, value]) => value !== undefined)
|
package/src/cli/commands.js
CHANGED
|
@@ -22,6 +22,110 @@ import { createDispatcher } from './dispatcher.js';
|
|
|
22
22
|
import { DEFAULT_DEPENDENCIES } from './modules.js';
|
|
23
23
|
import { runScript } from './script.js';
|
|
24
24
|
import { serveStdio } from './serve.js';
|
|
25
|
+
import { encodeAnimation } from '../capture/index.js';
|
|
26
|
+
import { renderTrace, summarizeTrace } from '../traces/render.js';
|
|
27
|
+
|
|
28
|
+
function mediaOptions(options) {
|
|
29
|
+
const result = { ...options };
|
|
30
|
+
for (const key of [
|
|
31
|
+
'quality',
|
|
32
|
+
'fps',
|
|
33
|
+
'loop',
|
|
34
|
+
'palette',
|
|
35
|
+
'maxFrames',
|
|
36
|
+
'maxBytes',
|
|
37
|
+
'maxDurationMs',
|
|
38
|
+
]) {
|
|
39
|
+
if (options[key] !== undefined) {
|
|
40
|
+
result[key] = Number(options[key]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (
|
|
44
|
+
options.scale !== undefined &&
|
|
45
|
+
!['css', 'device'].includes(options.scale)
|
|
46
|
+
) {
|
|
47
|
+
result.scale = Number(options.scale);
|
|
48
|
+
}
|
|
49
|
+
for (const key of ['clip', 'size']) {
|
|
50
|
+
if (options[key]) {
|
|
51
|
+
try {
|
|
52
|
+
result[key] = JSON.parse(options[key]);
|
|
53
|
+
} catch {
|
|
54
|
+
throw new UsageError(`${key} must be a JSON object`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function gif(parsed) {
|
|
62
|
+
const target = path.resolve(requireOption(parsed.options, 'out', 'gif'));
|
|
63
|
+
const bytes = await encodeAnimation(parsed.options.frame ?? [], {
|
|
64
|
+
...mediaOptions(parsed.options),
|
|
65
|
+
path: target,
|
|
66
|
+
});
|
|
67
|
+
return done({ path: target, bytes: bytes.length });
|
|
68
|
+
}
|
|
69
|
+
async function traceSummarize(parsed) {
|
|
70
|
+
return done(await summarizeTrace(parsed.args.dir, parsed.options));
|
|
71
|
+
}
|
|
72
|
+
async function traceRender(parsed) {
|
|
73
|
+
const target = path.resolve(
|
|
74
|
+
requireOption(parsed.options, 'out', 'trace render')
|
|
75
|
+
);
|
|
76
|
+
const options = { ...mediaOptions(parsed.options), path: target };
|
|
77
|
+
let browser;
|
|
78
|
+
try {
|
|
79
|
+
if (
|
|
80
|
+
['webm', 'mp4', 'mov'].includes(
|
|
81
|
+
options.format ?? path.extname(target).slice(1)
|
|
82
|
+
) &&
|
|
83
|
+
!options.ffmpeg
|
|
84
|
+
) {
|
|
85
|
+
const { chromium } = await import('playwright');
|
|
86
|
+
browser = await chromium.launch({ headless: true });
|
|
87
|
+
options.page = await browser.newPage();
|
|
88
|
+
}
|
|
89
|
+
const bytes = await renderTrace(parsed.args.dir, options);
|
|
90
|
+
return done({ path: target, bytes: bytes.length });
|
|
91
|
+
} finally {
|
|
92
|
+
await browser?.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function recordStart(parsed, io) {
|
|
96
|
+
const target = path.resolve(
|
|
97
|
+
requireOption(parsed.options, 'out', 'record start')
|
|
98
|
+
);
|
|
99
|
+
const marker = `${target}.stop`;
|
|
100
|
+
return withPageSession(parsed, io, async (session, dispatcher) => {
|
|
101
|
+
await rm(marker, { force: true });
|
|
102
|
+
await dispatcher.dispatch('record.start', {
|
|
103
|
+
session,
|
|
104
|
+
...mediaOptions(parsed.options),
|
|
105
|
+
format: parsed.options.format ?? path.extname(target).slice(1),
|
|
106
|
+
path: target,
|
|
107
|
+
});
|
|
108
|
+
let stopped = false;
|
|
109
|
+
try {
|
|
110
|
+
io.print({ recording: target });
|
|
111
|
+
await waitForStop({
|
|
112
|
+
signals: io.signals,
|
|
113
|
+
extra: [markerAppears(marker, () => stopped)],
|
|
114
|
+
});
|
|
115
|
+
return done(await dispatcher.dispatch('record.stop', { session }));
|
|
116
|
+
} finally {
|
|
117
|
+
stopped = true;
|
|
118
|
+
await rm(marker, { force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async function recordStop(parsed) {
|
|
123
|
+
const target = path.resolve(
|
|
124
|
+
requireOption(parsed.options, 'out', 'record stop')
|
|
125
|
+
);
|
|
126
|
+
await writeFile(`${target}.stop`, '', { mode: 0o600 });
|
|
127
|
+
return done({ recording: target, stopRequested: true });
|
|
128
|
+
}
|
|
25
129
|
|
|
26
130
|
/** Where `trace stop` asks a running `trace start` to finish. */
|
|
27
131
|
export const TRACE_STOP_MARKER = '.stop';
|
|
@@ -80,6 +184,11 @@ async function withPageSession(parsed, io, work, { skipUrl = false } = {}) {
|
|
|
80
184
|
? await dispatcher.dispatch('session.connect', {
|
|
81
185
|
cdpEndpoint: options.cdpEndpoint,
|
|
82
186
|
engine: options.engine,
|
|
187
|
+
targetId: options.targetId,
|
|
188
|
+
singleTab: options.singleTab,
|
|
189
|
+
...(options.noDefaults !== undefined
|
|
190
|
+
? { noDefaults: options.noDefaults }
|
|
191
|
+
: {}),
|
|
83
192
|
...(options.serverUrl ? { serverUrl: options.serverUrl } : {}),
|
|
84
193
|
...(options.driverPath ? { driverPath: options.driverPath } : {}),
|
|
85
194
|
...(options.bidi !== undefined ? { bidi: options.bidi } : {}),
|
|
@@ -145,7 +254,7 @@ async function launch(parsed, io) {
|
|
|
145
254
|
args: session.args,
|
|
146
255
|
...(opened.attach ? { attach: opened.attach } : {}),
|
|
147
256
|
};
|
|
148
|
-
if (!parsed.options.keepOpen) {
|
|
257
|
+
if (!parsed.options.keepOpen || session.persistent) {
|
|
149
258
|
return done(document);
|
|
150
259
|
}
|
|
151
260
|
io.print(document);
|
|
@@ -342,13 +451,18 @@ export const COMMAND_HANDLERS = Object.freeze({
|
|
|
342
451
|
expression: args.expression,
|
|
343
452
|
})),
|
|
344
453
|
screenshot: pageCommand('page.screenshot', ({ args, options }) => ({
|
|
454
|
+
...mediaOptions(options),
|
|
345
455
|
path: args.path,
|
|
346
|
-
fullPage: options.fullPage === true,
|
|
347
456
|
})),
|
|
348
457
|
pdf: pageCommand('page.pdf', ({ args }) => ({ path: args.path })),
|
|
349
458
|
'trace start': traceStart,
|
|
350
459
|
'trace stop': traceStop,
|
|
351
460
|
'trace view': traceView,
|
|
461
|
+
'trace summarize': traceSummarize,
|
|
462
|
+
'trace render': traceRender,
|
|
463
|
+
'record start': recordStart,
|
|
464
|
+
'record stop': recordStop,
|
|
465
|
+
gif,
|
|
352
466
|
'cookies import': pageCommand('cookies.import', ({ options }) => ({
|
|
353
467
|
from: requireOption(options, 'from', 'cookies import'),
|
|
354
468
|
profile: options.profile,
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* dispatcher state built by `createDispatcher()`.
|
|
8
8
|
*/
|
|
9
9
|
import path from 'node:path';
|
|
10
|
+
import {
|
|
11
|
+
screenshot as captureScreenshot,
|
|
12
|
+
startRecording,
|
|
13
|
+
} from '../capture/index.js';
|
|
10
14
|
|
|
11
15
|
import { invalidParams, requireString } from './rpc-error.js';
|
|
12
16
|
import {
|
|
@@ -24,7 +28,7 @@ async function launchSession(state, params) {
|
|
|
24
28
|
const options = buildLaunchOptions(params);
|
|
25
29
|
const launched = await state.dependencies.launchBrowser(options);
|
|
26
30
|
const session = state.sessions.add(
|
|
27
|
-
sessionFromLaunch(options.engine, launched)
|
|
31
|
+
sessionFromLaunch(options.engine, launched, options)
|
|
28
32
|
);
|
|
29
33
|
return {
|
|
30
34
|
session,
|
|
@@ -48,6 +52,16 @@ async function connectSession(state, params) {
|
|
|
48
52
|
const connected = await state.dependencies.connectBrowser({
|
|
49
53
|
engine,
|
|
50
54
|
...endpoint,
|
|
55
|
+
...(params.targetId ? { targetId: params.targetId } : {}),
|
|
56
|
+
...(params.urlMatcher ? { url: params.urlMatcher } : {}),
|
|
57
|
+
...(params.noDefaults !== undefined
|
|
58
|
+
? { noDefaults: params.noDefaults }
|
|
59
|
+
: {}),
|
|
60
|
+
...(params.urlMatchers !== undefined
|
|
61
|
+
? { urlMatchers: params.urlMatchers }
|
|
62
|
+
: {}),
|
|
63
|
+
...(params.fallback !== undefined ? { fallback: params.fallback } : {}),
|
|
64
|
+
...(params.singleTab !== undefined ? { singleTab: params.singleTab } : {}),
|
|
51
65
|
...(params.driverPath ? { driverPath: params.driverPath } : {}),
|
|
52
66
|
...(params.bidi !== undefined ? { bidi: params.bidi } : {}),
|
|
53
67
|
});
|
|
@@ -111,12 +125,40 @@ async function capture(params, write) {
|
|
|
111
125
|
}
|
|
112
126
|
|
|
113
127
|
function screenshot(state, params) {
|
|
114
|
-
const { page } = sessionOf(state, params);
|
|
128
|
+
const { page, engine } = sessionOf(state, params);
|
|
115
129
|
return capture(params, (options) =>
|
|
116
|
-
|
|
130
|
+
captureScreenshot({ ...params, ...options, page, engine })
|
|
117
131
|
);
|
|
118
132
|
}
|
|
119
133
|
|
|
134
|
+
async function recordStart(state, params) {
|
|
135
|
+
const session = sessionOf(state, params);
|
|
136
|
+
if (session.recording) {
|
|
137
|
+
throw invalidParams('A recording is already active');
|
|
138
|
+
}
|
|
139
|
+
session.recording = await startRecording({
|
|
140
|
+
...params,
|
|
141
|
+
page: session.page,
|
|
142
|
+
engine: session.engine,
|
|
143
|
+
});
|
|
144
|
+
return { recording: true };
|
|
145
|
+
}
|
|
146
|
+
async function recordStop(state, params) {
|
|
147
|
+
const session = sessionOf(state, params);
|
|
148
|
+
if (!session.recording) {
|
|
149
|
+
throw invalidParams('No recording is active');
|
|
150
|
+
}
|
|
151
|
+
const recording = session.recording;
|
|
152
|
+
session.recording = null;
|
|
153
|
+
const result = await recording.stop(params);
|
|
154
|
+
return {
|
|
155
|
+
path: result.path,
|
|
156
|
+
bytes: result.bytes?.length ?? 0,
|
|
157
|
+
frames: result.frames.length,
|
|
158
|
+
truncated: result.truncated,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
120
162
|
function pdf(state, params) {
|
|
121
163
|
const { page } = sessionOf(state, params);
|
|
122
164
|
return capture(params, (options) => page.pdf(options));
|
|
@@ -340,6 +382,8 @@ export const HIGH_LEVEL_METHODS = Object.freeze({
|
|
|
340
382
|
'page.pdf': pdf,
|
|
341
383
|
'trace.start': traceStart,
|
|
342
384
|
'trace.stop': traceStop,
|
|
385
|
+
'record.start': recordStart,
|
|
386
|
+
'record.stop': recordStop,
|
|
343
387
|
'cookies.import': importCookies,
|
|
344
388
|
'cookies.sources': cookieSources,
|
|
345
389
|
'profile.migrate': migrateProfile,
|
package/src/cli/sessions.js
CHANGED
|
@@ -44,6 +44,12 @@ const LAUNCH_PASSTHROUGH = Object.freeze([
|
|
|
44
44
|
'driverPath',
|
|
45
45
|
'bidi',
|
|
46
46
|
'seedCookies',
|
|
47
|
+
'disableFeatures',
|
|
48
|
+
'keepOpen',
|
|
49
|
+
'idleTimeoutMs',
|
|
50
|
+
'adoptExisting',
|
|
51
|
+
'closeNewTabs',
|
|
52
|
+
'noDefaults',
|
|
47
53
|
]);
|
|
48
54
|
|
|
49
55
|
/** Validate an engine name, defaulting to Playwright. */
|
|
@@ -121,20 +127,24 @@ export function buildLaunchOptions(params = {}) {
|
|
|
121
127
|
* CDP-connected `Browser` as `connectedBrowser`; the engine launch returns a
|
|
122
128
|
* persistent context whose `browser()` is null. Puppeteer returns a Browser.
|
|
123
129
|
*/
|
|
124
|
-
export function sessionFromLaunch(engine, launched) {
|
|
130
|
+
export function sessionFromLaunch(engine, launched, { keepOpen = false } = {}) {
|
|
125
131
|
engine = launched.engine ?? engine;
|
|
126
132
|
const playwright = engine === 'playwright';
|
|
127
133
|
const handle = launched.browser;
|
|
134
|
+
const persistent = keepOpen && Boolean(launched.detach);
|
|
128
135
|
return {
|
|
129
136
|
engine,
|
|
130
137
|
browser: playwright
|
|
131
138
|
? (launched.connectedBrowser ?? handle?.browser?.() ?? null)
|
|
132
139
|
: handle,
|
|
133
|
-
context: playwright
|
|
140
|
+
context: playwright
|
|
141
|
+
? (launched.page?.context?.() ?? handle)
|
|
142
|
+
: (handle?.defaultBrowserContext?.() ?? null),
|
|
134
143
|
page: launched.page,
|
|
135
144
|
driver: launched.driver ?? (engine === 'selenium' ? handle : null),
|
|
136
145
|
args: launched.args ?? [],
|
|
137
|
-
close: () => launched.close(),
|
|
146
|
+
close: () => (persistent ? launched.detach() : launched.close()),
|
|
147
|
+
persistent,
|
|
138
148
|
connected: false,
|
|
139
149
|
};
|
|
140
150
|
}
|
|
@@ -205,6 +215,7 @@ export class SessionTable {
|
|
|
205
215
|
this.latest = [...this.sessions.keys()].at(-1) ?? null;
|
|
206
216
|
}
|
|
207
217
|
await session.trace?.stop?.().catch(() => {});
|
|
218
|
+
await session.recording?.stop?.().catch(() => {});
|
|
208
219
|
await session.close();
|
|
209
220
|
}
|
|
210
221
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { startTrace } from '../traces/recorder.js';
|
|
2
|
+
import { touchSessionPage } from '../browser/persistent-session.js';
|
|
3
|
+
import { dismissOverlays } from '../interactions/overlays.js';
|
|
4
|
+
import {
|
|
5
|
+
screenshot,
|
|
6
|
+
startRecording,
|
|
7
|
+
encodeAnimation,
|
|
8
|
+
} from '../capture/index.js';
|
|
9
|
+
|
|
10
|
+
const INTERACTIONS = new Set([
|
|
11
|
+
'clickButton',
|
|
12
|
+
'clickElement',
|
|
13
|
+
'fillTextArea',
|
|
14
|
+
'check',
|
|
15
|
+
'pressKey',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/** Bind capture, persistence and opt-in debugging to the commander lifecycle. */
|
|
19
|
+
export function attachCommanderFeatures(commander, options, recreate) {
|
|
20
|
+
const { page, engine } = commander;
|
|
21
|
+
const managed = new Set();
|
|
22
|
+
function track(item) {
|
|
23
|
+
const stop = item.stop.bind(item);
|
|
24
|
+
let stopping;
|
|
25
|
+
item.stop = (...args) => {
|
|
26
|
+
stopping ??= Promise.resolve()
|
|
27
|
+
.then(() => stop(...args))
|
|
28
|
+
.finally(() => managed.delete(item));
|
|
29
|
+
return stopping;
|
|
30
|
+
};
|
|
31
|
+
managed.add(item);
|
|
32
|
+
return item;
|
|
33
|
+
}
|
|
34
|
+
commander.screenshot = (captureOptions = {}) =>
|
|
35
|
+
screenshot({ ...options.capture, ...captureOptions, page, engine });
|
|
36
|
+
commander.startRecording = async (recordingOptions = {}) =>
|
|
37
|
+
track(await startRecording({ ...recordingOptions, page, engine }));
|
|
38
|
+
commander.gif = (frames, animationOptions = {}) =>
|
|
39
|
+
encodeAnimation(frames, { ...animationOptions, format: 'gif' });
|
|
40
|
+
commander.dismissOverlays = (overlays = options.overlays) =>
|
|
41
|
+
dismissOverlays({
|
|
42
|
+
page,
|
|
43
|
+
engine,
|
|
44
|
+
overlays,
|
|
45
|
+
onDismiss: options.onOverlayDismiss,
|
|
46
|
+
});
|
|
47
|
+
commander.startTrace = async (traceOptions = {}) =>
|
|
48
|
+
track(await startTrace({ commander, page, ...traceOptions }));
|
|
49
|
+
const output =
|
|
50
|
+
options.debug?.output ??
|
|
51
|
+
options.output ??
|
|
52
|
+
process.env.BROWSER_COMMANDER_TRACE;
|
|
53
|
+
let debug;
|
|
54
|
+
const ensureDebug = async () => {
|
|
55
|
+
if (!output || (!options.debug && !process.env.BROWSER_COMMANDER_TRACE)) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
debug ??= commander.startTrace({
|
|
59
|
+
output,
|
|
60
|
+
mode: 'continuous',
|
|
61
|
+
network: { har: true },
|
|
62
|
+
links: { output: `${output}.links`, dom: 'text' },
|
|
63
|
+
checkpointOnNavigation: true,
|
|
64
|
+
});
|
|
65
|
+
await debug;
|
|
66
|
+
};
|
|
67
|
+
for (const name of [...INTERACTIONS, 'goto', 'evaluate', 'count']) {
|
|
68
|
+
const method = commander[name];
|
|
69
|
+
if (!method) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
commander[name] = async (...args) => {
|
|
73
|
+
touchSessionPage(page);
|
|
74
|
+
await ensureDebug();
|
|
75
|
+
if (INTERACTIONS.has(name)) {
|
|
76
|
+
await commander.dismissOverlays();
|
|
77
|
+
}
|
|
78
|
+
return method(...args);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const destroy = commander.destroy;
|
|
82
|
+
commander.destroy = async () => {
|
|
83
|
+
try {
|
|
84
|
+
if (debug) {
|
|
85
|
+
await debug;
|
|
86
|
+
}
|
|
87
|
+
await Promise.all([...managed].map((item) => item.stop()));
|
|
88
|
+
} finally {
|
|
89
|
+
await destroy();
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
commander.reusePage = async (selection = {}) => {
|
|
93
|
+
if (!options.session?.reusePage) {
|
|
94
|
+
throw new Error('reusePage requires a persistent session');
|
|
95
|
+
}
|
|
96
|
+
const selected = await options.session.reusePage(selection);
|
|
97
|
+
if (selected !== page) {
|
|
98
|
+
await commander.destroy();
|
|
99
|
+
Object.assign(
|
|
100
|
+
commander,
|
|
101
|
+
recreate({ ...options, page: selected, downloads: null })
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
touchSessionPage(selected);
|
|
105
|
+
return selected;
|
|
106
|
+
};
|
|
107
|
+
if (options.debug || process.env.BROWSER_COMMANDER_TRACE) {
|
|
108
|
+
commander.ready = ensureDebug();
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/core/constants.js
CHANGED
|
@@ -257,9 +257,11 @@ export function createNavigationManager(options = {}) {
|
|
|
257
257
|
log.debug(() => `🔄 Redirect detected: ${url}`);
|
|
258
258
|
}
|
|
259
259
|
},
|
|
260
|
-
onReady: () => {
|
|
260
|
+
onReady: (readyOn) => {
|
|
261
261
|
finishNavigationTracking({ ready: true });
|
|
262
|
-
|
|
262
|
+
if (readyOn === 'networkidle') {
|
|
263
|
+
emitPageReady();
|
|
264
|
+
}
|
|
263
265
|
},
|
|
264
266
|
onNotReady: () => finishNavigationTracking({ ready: false }),
|
|
265
267
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
networkIdleFor,
|
|
13
13
|
runReadinessChecks,
|
|
14
14
|
urlStableFor,
|
|
15
|
+
stableCheck,
|
|
15
16
|
} from './readiness.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -113,7 +114,7 @@ export function createReadinessWaiter(options = {}) {
|
|
|
113
114
|
let result;
|
|
114
115
|
try {
|
|
115
116
|
result = await runReadinessChecks({
|
|
116
|
-
checks: checks ??
|
|
117
|
+
checks: checks ?? checksForReadiness(opts.readyOn, config),
|
|
117
118
|
deadline,
|
|
118
119
|
context: {
|
|
119
120
|
page,
|
|
@@ -135,7 +136,7 @@ export function createReadinessWaiter(options = {}) {
|
|
|
135
136
|
|
|
136
137
|
if (outcome.ready) {
|
|
137
138
|
if (notify()) {
|
|
138
|
-
onReady();
|
|
139
|
+
onReady(opts.readyOn ?? 'networkidle');
|
|
139
140
|
}
|
|
140
141
|
log.debug(() => `✅ Page ready after ${outcome.elapsedMs}ms (${reason})`);
|
|
141
142
|
} else {
|
|
@@ -155,3 +156,30 @@ export function createReadinessWaiter(options = {}) {
|
|
|
155
156
|
|
|
156
157
|
return { waitForReady, getAdapter };
|
|
157
158
|
}
|
|
159
|
+
|
|
160
|
+
export function checksForReadiness(readyOn = 'networkidle', config) {
|
|
161
|
+
if (readyOn === 'networkidle') {
|
|
162
|
+
return defaultReadinessChecks(config);
|
|
163
|
+
}
|
|
164
|
+
if (readyOn === 'urlchange') {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
if (!['domcontentloaded', 'load'].includes(readyOn)) {
|
|
168
|
+
throw new RangeError(`Unknown readyOn: ${readyOn}`);
|
|
169
|
+
}
|
|
170
|
+
return [
|
|
171
|
+
stableCheck({
|
|
172
|
+
name: readyOn,
|
|
173
|
+
stableForMs: 0,
|
|
174
|
+
intervalMs: 50,
|
|
175
|
+
sample: async ({ page }) => {
|
|
176
|
+
const state = await page.evaluate(() => document.readyState);
|
|
177
|
+
return {
|
|
178
|
+
stable:
|
|
179
|
+
readyOn === 'load' ? state === 'complete' : state !== 'loading',
|
|
180
|
+
detail: { state },
|
|
181
|
+
};
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
];
|
|
185
|
+
}
|
|
@@ -8,12 +8,64 @@
|
|
|
8
8
|
* @param {Error} error - The error to check
|
|
9
9
|
* @returns {boolean} - True if this is a navigation error
|
|
10
10
|
*/
|
|
11
|
+
export class NavigationInterruptedError extends Error {
|
|
12
|
+
constructor(cause) {
|
|
13
|
+
super('Navigation interrupted the element wait', { cause });
|
|
14
|
+
this.name = 'NavigationInterruptedError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function waitNavigationState(page, manager) {
|
|
19
|
+
return { url: page.url?.(), session: manager?.getSessionId?.() };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function classifyWaitError(error, page, manager, initial) {
|
|
23
|
+
if (!isTimeoutError(error)) {
|
|
24
|
+
return error;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const current = waitNavigationState(page, manager);
|
|
28
|
+
if (current.url !== initial.url || current.session !== initial.session) {
|
|
29
|
+
return new NavigationInterruptedError(error);
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Keep the original timeout when the page identity is unavailable.
|
|
33
|
+
}
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function recoverWaitError(
|
|
38
|
+
caught,
|
|
39
|
+
options,
|
|
40
|
+
initial,
|
|
41
|
+
operation,
|
|
42
|
+
fallback
|
|
43
|
+
) {
|
|
44
|
+
const error = classifyWaitError(
|
|
45
|
+
caught,
|
|
46
|
+
options.page,
|
|
47
|
+
options.navigationManager,
|
|
48
|
+
initial
|
|
49
|
+
);
|
|
50
|
+
if (!isNavigationError(error) || options.throwOnNavigation !== false) {
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
console.log(
|
|
54
|
+
`⚠️ Navigation detected during ${operation}, recovering gracefully`
|
|
55
|
+
);
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
|
|
11
59
|
export function isNavigationError(error) {
|
|
60
|
+
if (error instanceof NavigationInterruptedError) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
12
63
|
if (!error || !error.message) {
|
|
13
64
|
return false;
|
|
14
65
|
}
|
|
15
66
|
|
|
16
67
|
const navigationErrorPatterns = [
|
|
68
|
+
'net::ERR_ABORTED',
|
|
17
69
|
'Execution context was destroyed',
|
|
18
70
|
'detached Frame',
|
|
19
71
|
'Target closed',
|