browser-commander 0.26.3 → 0.28.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 +24 -23
- package/CHANGELOG.md +22 -0
- package/README.md +6 -0
- package/eslint.config.js +11 -0
- package/package.json +9 -4
- package/src/bindings.js +19 -0
- package/src/browser/attach/snapshot-launch.js +8 -6
- package/src/browser/attach/snapshot.js +39 -2
- package/src/browser/browser-cookie-database.js +71 -2
- package/src/browser/browser-cookie-session.js +183 -0
- package/src/browser/browser-cookies.js +29 -0
- package/src/browser/connector.js +117 -13
- package/src/browser/debugging-port.js +4 -2
- package/src/browser/default-browser.js +33 -9
- package/src/browser/launch-diagnostics.js +102 -0
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +54 -9
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/persistent-session.js +360 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +142 -70
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +70 -0
- package/src/browser/storage-state.js +32 -6
- 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 +66 -1
- package/src/cli/commands.js +113 -2
- package/src/cli/high-level-methods.js +40 -3
- package/src/cli/sessions.js +11 -3
- package/src/core/commander-features.js +105 -0
- package/src/core/constants.js +1 -0
- package/src/core/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +66 -80
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +68 -16
- package/src/core/navigation-safety.js +51 -0
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +130 -42
- package/src/core/readiness.js +76 -14
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +61 -37
- package/src/elements/reusable.js +92 -0
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +38 -21
- package/src/exports.js +14 -0
- package/src/factory.js +17 -16
- package/src/high-level/universal-logic.js +57 -21
- package/src/index.js +2 -0
- package/src/interactions/click.js +47 -0
- package/src/interactions/overlays.js +28 -0
- package/src/interactions/scroll.js +8 -2
- package/src/traces/bundle.js +14 -5
- package/src/traces/checkpoints.js +99 -0
- package/src/traces/links.js +117 -1
- package/src/traces/mutation-stream.js +1 -0
- package/src/traces/network.js +257 -0
- package/src/traces/page-capture.js +143 -52
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +221 -245
- package/src/traces/render.js +168 -0
- package/src/traces/rolling.js +180 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +159 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +47 -4
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/navigation-budget.e2e.test.js +132 -0
- package/tests/e2e/reusable-session.e2e.test.js +231 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
- package/tests/unit/browser/attach/snapshot.test.js +33 -0
- package/tests/unit/browser/default-browser.test.js +18 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launch-diagnostics.test.js +52 -0
- 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/real-browser.test.js +1 -1
- package/tests/unit/browser/session-workflows.test.js +206 -0
- package/tests/unit/browser/storage-state.test.js +7 -2
- 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/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -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/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- package/tests/unit/traces/links.test.js +2 -2
- package/tests/unit/traces/network.test.js +97 -0
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,8 @@ 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,
|
|
83
189
|
...(options.serverUrl ? { serverUrl: options.serverUrl } : {}),
|
|
84
190
|
...(options.driverPath ? { driverPath: options.driverPath } : {}),
|
|
85
191
|
...(options.bidi !== undefined ? { bidi: options.bidi } : {}),
|
|
@@ -145,7 +251,7 @@ async function launch(parsed, io) {
|
|
|
145
251
|
args: session.args,
|
|
146
252
|
...(opened.attach ? { attach: opened.attach } : {}),
|
|
147
253
|
};
|
|
148
|
-
if (!parsed.options.keepOpen) {
|
|
254
|
+
if (!parsed.options.keepOpen || session.persistent) {
|
|
149
255
|
return done(document);
|
|
150
256
|
}
|
|
151
257
|
io.print(document);
|
|
@@ -342,13 +448,18 @@ export const COMMAND_HANDLERS = Object.freeze({
|
|
|
342
448
|
expression: args.expression,
|
|
343
449
|
})),
|
|
344
450
|
screenshot: pageCommand('page.screenshot', ({ args, options }) => ({
|
|
451
|
+
...mediaOptions(options),
|
|
345
452
|
path: args.path,
|
|
346
|
-
fullPage: options.fullPage === true,
|
|
347
453
|
})),
|
|
348
454
|
pdf: pageCommand('page.pdf', ({ args }) => ({ path: args.path })),
|
|
349
455
|
'trace start': traceStart,
|
|
350
456
|
'trace stop': traceStop,
|
|
351
457
|
'trace view': traceView,
|
|
458
|
+
'trace summarize': traceSummarize,
|
|
459
|
+
'trace render': traceRender,
|
|
460
|
+
'record start': recordStart,
|
|
461
|
+
'record stop': recordStop,
|
|
462
|
+
gif,
|
|
352
463
|
'cookies import': pageCommand('cookies.import', ({ options }) => ({
|
|
353
464
|
from: requireOption(options, 'from', 'cookies import'),
|
|
354
465
|
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,9 @@ 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.singleTab !== undefined ? { singleTab: params.singleTab } : {}),
|
|
51
58
|
...(params.driverPath ? { driverPath: params.driverPath } : {}),
|
|
52
59
|
...(params.bidi !== undefined ? { bidi: params.bidi } : {}),
|
|
53
60
|
});
|
|
@@ -111,12 +118,40 @@ async function capture(params, write) {
|
|
|
111
118
|
}
|
|
112
119
|
|
|
113
120
|
function screenshot(state, params) {
|
|
114
|
-
const { page } = sessionOf(state, params);
|
|
121
|
+
const { page, engine } = sessionOf(state, params);
|
|
115
122
|
return capture(params, (options) =>
|
|
116
|
-
|
|
123
|
+
captureScreenshot({ ...params, ...options, page, engine })
|
|
117
124
|
);
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
async function recordStart(state, params) {
|
|
128
|
+
const session = sessionOf(state, params);
|
|
129
|
+
if (session.recording) {
|
|
130
|
+
throw invalidParams('A recording is already active');
|
|
131
|
+
}
|
|
132
|
+
session.recording = await startRecording({
|
|
133
|
+
...params,
|
|
134
|
+
page: session.page,
|
|
135
|
+
engine: session.engine,
|
|
136
|
+
});
|
|
137
|
+
return { recording: true };
|
|
138
|
+
}
|
|
139
|
+
async function recordStop(state, params) {
|
|
140
|
+
const session = sessionOf(state, params);
|
|
141
|
+
if (!session.recording) {
|
|
142
|
+
throw invalidParams('No recording is active');
|
|
143
|
+
}
|
|
144
|
+
const recording = session.recording;
|
|
145
|
+
session.recording = null;
|
|
146
|
+
const result = await recording.stop(params);
|
|
147
|
+
return {
|
|
148
|
+
path: result.path,
|
|
149
|
+
bytes: result.bytes?.length ?? 0,
|
|
150
|
+
frames: result.frames.length,
|
|
151
|
+
truncated: result.truncated,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
120
155
|
function pdf(state, params) {
|
|
121
156
|
const { page } = sessionOf(state, params);
|
|
122
157
|
return capture(params, (options) => page.pdf(options));
|
|
@@ -340,6 +375,8 @@ export const HIGH_LEVEL_METHODS = Object.freeze({
|
|
|
340
375
|
'page.pdf': pdf,
|
|
341
376
|
'trace.start': traceStart,
|
|
342
377
|
'trace.stop': traceStop,
|
|
378
|
+
'record.start': recordStart,
|
|
379
|
+
'record.stop': recordStop,
|
|
343
380
|
'cookies.import': importCookies,
|
|
344
381
|
'cookies.sources': cookieSources,
|
|
345
382
|
'profile.migrate': migrateProfile,
|
package/src/cli/sessions.js
CHANGED
|
@@ -44,6 +44,9 @@ const LAUNCH_PASSTHROUGH = Object.freeze([
|
|
|
44
44
|
'driverPath',
|
|
45
45
|
'bidi',
|
|
46
46
|
'seedCookies',
|
|
47
|
+
'disableFeatures',
|
|
48
|
+
'keepOpen',
|
|
49
|
+
'idleTimeoutMs',
|
|
47
50
|
]);
|
|
48
51
|
|
|
49
52
|
/** Validate an engine name, defaulting to Playwright. */
|
|
@@ -121,20 +124,24 @@ export function buildLaunchOptions(params = {}) {
|
|
|
121
124
|
* CDP-connected `Browser` as `connectedBrowser`; the engine launch returns a
|
|
122
125
|
* persistent context whose `browser()` is null. Puppeteer returns a Browser.
|
|
123
126
|
*/
|
|
124
|
-
export function sessionFromLaunch(engine, launched) {
|
|
127
|
+
export function sessionFromLaunch(engine, launched, { keepOpen = false } = {}) {
|
|
125
128
|
engine = launched.engine ?? engine;
|
|
126
129
|
const playwright = engine === 'playwright';
|
|
127
130
|
const handle = launched.browser;
|
|
131
|
+
const persistent = keepOpen && Boolean(launched.detach);
|
|
128
132
|
return {
|
|
129
133
|
engine,
|
|
130
134
|
browser: playwright
|
|
131
135
|
? (launched.connectedBrowser ?? handle?.browser?.() ?? null)
|
|
132
136
|
: handle,
|
|
133
|
-
context: playwright
|
|
137
|
+
context: playwright
|
|
138
|
+
? (launched.page?.context?.() ?? handle)
|
|
139
|
+
: (handle?.defaultBrowserContext?.() ?? null),
|
|
134
140
|
page: launched.page,
|
|
135
141
|
driver: launched.driver ?? (engine === 'selenium' ? handle : null),
|
|
136
142
|
args: launched.args ?? [],
|
|
137
|
-
close: () => launched.close(),
|
|
143
|
+
close: () => (persistent ? launched.detach() : launched.close()),
|
|
144
|
+
persistent,
|
|
138
145
|
connected: false,
|
|
139
146
|
};
|
|
140
147
|
}
|
|
@@ -205,6 +212,7 @@ export class SessionTable {
|
|
|
205
212
|
this.latest = [...this.sessions.keys()].at(-1) ?? null;
|
|
206
213
|
}
|
|
207
214
|
await session.trace?.stop?.().catch(() => {});
|
|
215
|
+
await session.recording?.stop?.().catch(() => {});
|
|
208
216
|
await session.close();
|
|
209
217
|
}
|
|
210
218
|
|
|
@@ -0,0 +1,105 @@
|
|
|
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({ page, engine, overlays });
|
|
42
|
+
commander.startTrace = async (traceOptions = {}) =>
|
|
43
|
+
track(await startTrace({ commander, page, ...traceOptions }));
|
|
44
|
+
const output =
|
|
45
|
+
options.debug?.output ??
|
|
46
|
+
options.output ??
|
|
47
|
+
process.env.BROWSER_COMMANDER_TRACE;
|
|
48
|
+
let debug;
|
|
49
|
+
const ensureDebug = async () => {
|
|
50
|
+
if (!output || (!options.debug && !process.env.BROWSER_COMMANDER_TRACE)) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
debug ??= commander.startTrace({
|
|
54
|
+
output,
|
|
55
|
+
mode: 'continuous',
|
|
56
|
+
network: { har: true },
|
|
57
|
+
links: { output: `${output}.links`, dom: 'text' },
|
|
58
|
+
checkpointOnNavigation: true,
|
|
59
|
+
});
|
|
60
|
+
await debug;
|
|
61
|
+
};
|
|
62
|
+
for (const name of [...INTERACTIONS, 'goto', 'evaluate', 'count']) {
|
|
63
|
+
const method = commander[name];
|
|
64
|
+
if (!method) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
commander[name] = async (...args) => {
|
|
68
|
+
touchSessionPage(page);
|
|
69
|
+
await ensureDebug();
|
|
70
|
+
if (INTERACTIONS.has(name)) {
|
|
71
|
+
await commander.dismissOverlays();
|
|
72
|
+
}
|
|
73
|
+
return method(...args);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const destroy = commander.destroy;
|
|
77
|
+
commander.destroy = async () => {
|
|
78
|
+
try {
|
|
79
|
+
if (debug) {
|
|
80
|
+
await debug;
|
|
81
|
+
}
|
|
82
|
+
await Promise.all([...managed].map((item) => item.stop()));
|
|
83
|
+
} finally {
|
|
84
|
+
await destroy();
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
commander.reusePage = async (selection = {}) => {
|
|
88
|
+
if (!options.session?.reusePage) {
|
|
89
|
+
throw new Error('reusePage requires a persistent session');
|
|
90
|
+
}
|
|
91
|
+
const selected = await options.session.reusePage(selection);
|
|
92
|
+
if (selected !== page) {
|
|
93
|
+
await commander.destroy();
|
|
94
|
+
Object.assign(
|
|
95
|
+
commander,
|
|
96
|
+
recreate({ ...options, page: selected, downloads: null })
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
touchSessionPage(selected);
|
|
100
|
+
return selected;
|
|
101
|
+
};
|
|
102
|
+
if (options.debug || process.env.BROWSER_COMMANDER_TRACE) {
|
|
103
|
+
commander.ready = ensureDebug();
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/core/constants.js
CHANGED
|
@@ -111,6 +111,13 @@ export function createDialogManager(options = {}) {
|
|
|
111
111
|
}
|
|
112
112
|
handlers.push(handler);
|
|
113
113
|
log.debug(() => `🔌 Dialog handler registered (total: ${handlers.length})`);
|
|
114
|
+
let removed = false;
|
|
115
|
+
return () => {
|
|
116
|
+
if (!removed) {
|
|
117
|
+
removed = true;
|
|
118
|
+
offDialog(handler);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
/**
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createDeadline, urlStableFor, networkIdleFor } from './readiness.js';
|
|
2
|
+
import { navigationPhase } from './navigation-operation.js';
|
|
3
|
+
import { isNavigationError } from './navigation-safety.js';
|
|
4
|
+
|
|
5
|
+
/** Own a controlled navigation's deadline, policy and cancellation lifecycle. */
|
|
6
|
+
export function createManagedNavigator({
|
|
7
|
+
page,
|
|
8
|
+
state,
|
|
9
|
+
config,
|
|
10
|
+
waitForReady,
|
|
11
|
+
triggerNavigationStart,
|
|
12
|
+
updateCurrentUrl,
|
|
13
|
+
abandonNavigation,
|
|
14
|
+
}) {
|
|
15
|
+
async function navigate(opts = {}) {
|
|
16
|
+
const { url, waitUntil = 'domcontentloaded', timeout = 60000 } = opts;
|
|
17
|
+
if (!url) {
|
|
18
|
+
throw new Error('url is required in options');
|
|
19
|
+
}
|
|
20
|
+
state.activeOperation?.controller.abort();
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const signal = opts.signal
|
|
23
|
+
? AbortSignal.any([opts.signal, controller.signal])
|
|
24
|
+
: controller.signal;
|
|
25
|
+
const deadline = opts.deadline ?? createDeadline({ timeout, signal });
|
|
26
|
+
// Keep a caller-provided deadline's clock, adding operation cancellation.
|
|
27
|
+
const budget = { ...deadline, signal };
|
|
28
|
+
const operation = { controller };
|
|
29
|
+
state.activeOperation = operation;
|
|
30
|
+
const stable = () =>
|
|
31
|
+
urlStableFor({
|
|
32
|
+
stableForMs:
|
|
33
|
+
opts.checkInterval === undefined
|
|
34
|
+
? config.redirectStabilizationTime
|
|
35
|
+
: opts.checkInterval,
|
|
36
|
+
intervalMs: opts.checkInterval ?? 200,
|
|
37
|
+
consecutiveSamples: opts.stableChecks ?? 1,
|
|
38
|
+
});
|
|
39
|
+
try {
|
|
40
|
+
if (opts.waitForStableUrlBefore === true) {
|
|
41
|
+
const before = await waitForReady({
|
|
42
|
+
checks: [stable()],
|
|
43
|
+
deadline: budget,
|
|
44
|
+
reason: 'before goto',
|
|
45
|
+
observeOnly: true,
|
|
46
|
+
});
|
|
47
|
+
if (!before.ready) {
|
|
48
|
+
state.lastOutcome = before;
|
|
49
|
+
return opts.returnOutcome ? before : false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
await navigationPhase(budget, () =>
|
|
53
|
+
triggerNavigationStart({ url, isExternal: false, signal })
|
|
54
|
+
);
|
|
55
|
+
await navigationPhase(budget, () =>
|
|
56
|
+
page.goto(url, {
|
|
57
|
+
waitUntil,
|
|
58
|
+
timeout: Math.max(1, budget.remainingMs()),
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
updateCurrentUrl();
|
|
62
|
+
const checks = opts.checks ?? [
|
|
63
|
+
...(opts.waitForStableUrlAfter === false ? [] : [stable()]),
|
|
64
|
+
...(opts.waitForNetworkIdle === false ? [] : [networkIdleFor()]),
|
|
65
|
+
];
|
|
66
|
+
state.lastOutcome = await waitForReady({
|
|
67
|
+
deadline: budget,
|
|
68
|
+
checks,
|
|
69
|
+
reason: 'after goto',
|
|
70
|
+
shouldNotify: () => state.activeOperation === operation,
|
|
71
|
+
});
|
|
72
|
+
return opts.returnOutcome ? state.lastOutcome : state.lastOutcome.ready;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (
|
|
75
|
+
error.status ||
|
|
76
|
+
isNavigationError(error) ||
|
|
77
|
+
error.name === 'TimeoutError'
|
|
78
|
+
) {
|
|
79
|
+
const status =
|
|
80
|
+
error.status ??
|
|
81
|
+
(error.name === 'TimeoutError' ? 'timed_out' : 'interrupted');
|
|
82
|
+
state.lastOutcome = {
|
|
83
|
+
ready: false,
|
|
84
|
+
status,
|
|
85
|
+
reason: error.message,
|
|
86
|
+
elapsedMs: budget.elapsedMs(),
|
|
87
|
+
};
|
|
88
|
+
return opts.returnOutcome ? state.lastOutcome : false;
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
} finally {
|
|
92
|
+
controller.abort();
|
|
93
|
+
if (state.activeOperation === operation) {
|
|
94
|
+
state.activeOperation = null;
|
|
95
|
+
abandonNavigation('operation finished');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return navigate;
|
|
101
|
+
}
|