browser-commander 0.26.2 → 0.27.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 +12 -0
- package/README.md +2 -0
- package/package.json +5 -3
- 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/browser-sources.js +13 -0
- package/src/browser/browser-sources.json +13 -0
- 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/launcher.js +28 -8
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/real-browser.js +135 -68
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/storage-state.js +32 -6
- package/src/browser/system-browser.js +2 -2
- package/src/core/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +62 -78
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +40 -16
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +15 -12
- package/src/core/readiness.js +67 -13
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +35 -5
- package/src/elements/reusable.js +92 -0
- package/src/elements/visibility.js +6 -11
- package/src/exports.js +14 -0
- package/src/factory.js +4 -4
- package/src/high-level/universal-logic.js +57 -21
- package/src/interactions/click.js +3 -0
- package/src/interactions/scroll.js +8 -2
- package/src/utilities/subprocess.js +15 -4
- 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/catalogue-launch.test.js +50 -2
- package/tests/unit/browser/default-browser.test.js +18 -0
- package/tests/unit/browser/launch-diagnostics.test.js +52 -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/core/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -0
- package/tests/unit/traces/links.test.js +2 -2
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { matchesDomains } from './migration/domains.js';
|
|
2
|
+
import { restoreWebDriverStorageState } from './storage-state.js';
|
|
3
|
+
|
|
4
|
+
/** Normalize cookie shape without turning session cookies into expired cookies. */
|
|
5
|
+
export function normalizeSessionCookies(cookies, engine) {
|
|
6
|
+
if (!Array.isArray(cookies)) {
|
|
7
|
+
throw new TypeError('cookies must be an array');
|
|
8
|
+
}
|
|
9
|
+
return cookies.map(({ expires, ...cookie }) => ({
|
|
10
|
+
...cookie,
|
|
11
|
+
...(cookie.sameSite
|
|
12
|
+
? {
|
|
13
|
+
sameSite:
|
|
14
|
+
cookie.sameSite[0].toUpperCase() +
|
|
15
|
+
cookie.sameSite.slice(1).toLowerCase(),
|
|
16
|
+
}
|
|
17
|
+
: {}),
|
|
18
|
+
...(Number.isFinite(expires) && expires > 0
|
|
19
|
+
? { expires }
|
|
20
|
+
: engine === 'playwright' && expires !== undefined
|
|
21
|
+
? { expires: -1 }
|
|
22
|
+
: {}),
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Add cookies to the current context, including cookies for other origins. */
|
|
27
|
+
export async function setCookies({ page, engine, cookies } = {}) {
|
|
28
|
+
const normalized = normalizeSessionCookies(cookies, engine);
|
|
29
|
+
if (engine === 'playwright') {
|
|
30
|
+
return page.context().addCookies(normalized);
|
|
31
|
+
}
|
|
32
|
+
if (engine === 'selenium' && !(await page.bidi?.())) {
|
|
33
|
+
return restoreWebDriverStorageState({
|
|
34
|
+
page,
|
|
35
|
+
storageState: { cookies: normalized, origins: [] },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const context = page.browserContext?.();
|
|
39
|
+
if (typeof context?.setCookie === 'function') {
|
|
40
|
+
return context.setCookie(...normalized);
|
|
41
|
+
}
|
|
42
|
+
if (typeof page.browser?.().setCookie === 'function') {
|
|
43
|
+
return page.browser().setCookie(...normalized);
|
|
44
|
+
}
|
|
45
|
+
return page.setCookie(...normalized);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Clear the context or only cookies whose domain matches the requested site. */
|
|
49
|
+
export async function clearCookies({ page, engine, domain } = {}) {
|
|
50
|
+
if (engine === 'playwright' && !domain) {
|
|
51
|
+
return page.context().clearCookies();
|
|
52
|
+
}
|
|
53
|
+
const context =
|
|
54
|
+
engine === 'playwright' ? page.context() : page.browserContext?.();
|
|
55
|
+
const cookies =
|
|
56
|
+
engine === 'playwright'
|
|
57
|
+
? await context.cookies()
|
|
58
|
+
: typeof context?.cookies === 'function'
|
|
59
|
+
? await context.cookies()
|
|
60
|
+
: await page.cookies();
|
|
61
|
+
const selected = cookies.filter(
|
|
62
|
+
(cookie) => !domain || matchesDomains(cookie.domain, [domain])
|
|
63
|
+
);
|
|
64
|
+
if (engine === 'playwright') {
|
|
65
|
+
for (const cookie of selected) {
|
|
66
|
+
await context.clearCookies({
|
|
67
|
+
name: cookie.name,
|
|
68
|
+
domain: cookie.domain,
|
|
69
|
+
path: cookie.path,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
} else if (typeof context?.deleteCookie === 'function') {
|
|
73
|
+
await context.deleteCookie(...selected);
|
|
74
|
+
} else {
|
|
75
|
+
await page.deleteCookie(...selected);
|
|
76
|
+
}
|
|
77
|
+
return selected.length;
|
|
78
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { access } from 'node:fs/promises';
|
|
3
|
+
import { assertDedicatedUserDataDir } from './system-browser.js';
|
|
4
|
+
import {
|
|
5
|
+
saveStorageState,
|
|
6
|
+
writeRestrictedState,
|
|
7
|
+
loadStorageState,
|
|
8
|
+
restorePlaywrightStorageState,
|
|
9
|
+
restorePuppeteerStorageState,
|
|
10
|
+
restoreWebDriverStorageState,
|
|
11
|
+
} from './storage-state.js';
|
|
12
|
+
|
|
13
|
+
const installed = Symbol('sessionPersistence');
|
|
14
|
+
export function sessionPersistencePath(options) {
|
|
15
|
+
if (!options.persistSessionCookies) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (!options.userDataDir || options.attach) {
|
|
19
|
+
throw new TypeError(
|
|
20
|
+
'persistSessionCookies requires an explicit dedicated userDataDir'
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
assertDedicatedUserDataDir(options.userDataDir);
|
|
24
|
+
return typeof options.persistSessionCookies === 'string'
|
|
25
|
+
? options.persistSessionCookies
|
|
26
|
+
: path.join(options.userDataDir, 'browser-commander-session.json');
|
|
27
|
+
}
|
|
28
|
+
export async function installSessionPersistence(result, options) {
|
|
29
|
+
const file = sessionPersistencePath(options);
|
|
30
|
+
if (!file || result[installed]) {
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
const engine = options.engine ?? 'playwright';
|
|
34
|
+
if (
|
|
35
|
+
await access(file).then(
|
|
36
|
+
() => true,
|
|
37
|
+
() => false
|
|
38
|
+
)
|
|
39
|
+
) {
|
|
40
|
+
const storageState = await loadStorageState(file);
|
|
41
|
+
if (engine === 'playwright') {
|
|
42
|
+
await restorePlaywrightStorageState({
|
|
43
|
+
context: result.page.context(),
|
|
44
|
+
storageState,
|
|
45
|
+
});
|
|
46
|
+
} else if (engine === 'puppeteer') {
|
|
47
|
+
await restorePuppeteerStorageState({ page: result.page, storageState });
|
|
48
|
+
} else {
|
|
49
|
+
await restoreWebDriverStorageState({ page: result.page, storageState });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const close = result.close;
|
|
53
|
+
let closing;
|
|
54
|
+
const persistAndClose = () =>
|
|
55
|
+
(closing ??= (async () => {
|
|
56
|
+
try {
|
|
57
|
+
const state = await saveStorageState(result.page);
|
|
58
|
+
await writeRestrictedState(file, {
|
|
59
|
+
cookies: state.cookies.filter((cookie) => !(cookie.expires > 0)),
|
|
60
|
+
origins: [],
|
|
61
|
+
});
|
|
62
|
+
} finally {
|
|
63
|
+
await close();
|
|
64
|
+
}
|
|
65
|
+
})());
|
|
66
|
+
result.close = persistAndClose;
|
|
67
|
+
if (typeof result.browser?.close === 'function') {
|
|
68
|
+
result.browser.close = persistAndClose;
|
|
69
|
+
}
|
|
70
|
+
result[installed] = true;
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { readFile, writeFile, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { normalizeSessionCookies } from './session-cookies.js';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Load a Playwright-compatible storage state from a JSON path or object.
|
|
@@ -91,7 +93,9 @@ export async function restorePuppeteerStorageState({ page, storageState }) {
|
|
|
91
93
|
|
|
92
94
|
const cookies = storageState.cookies ?? [];
|
|
93
95
|
if (cookies.length > 0) {
|
|
94
|
-
|
|
96
|
+
const context = page.browserContext?.();
|
|
97
|
+
const target = typeof context?.setCookie === 'function' ? context : page;
|
|
98
|
+
await target.setCookie(...normalizeSessionCookies(cookies, 'puppeteer'));
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
const origins = storageState.origins ?? [];
|
|
@@ -115,7 +119,7 @@ export async function restorePlaywrightStorageState({ context, storageState }) {
|
|
|
115
119
|
|
|
116
120
|
const cookies = storageState.cookies ?? [];
|
|
117
121
|
if (cookies.length > 0) {
|
|
118
|
-
await context.addCookies(cookies);
|
|
122
|
+
await context.addCookies(normalizeSessionCookies(cookies, 'playwright'));
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
const origins = storageState.origins ?? [];
|
|
@@ -137,7 +141,11 @@ function readCurrentLocalStorage() {
|
|
|
137
141
|
}
|
|
138
142
|
|
|
139
143
|
async function collectPuppeteerStorageState(page) {
|
|
140
|
-
const
|
|
144
|
+
const context = page.browserContext?.();
|
|
145
|
+
const cookies =
|
|
146
|
+
typeof context?.cookies === 'function'
|
|
147
|
+
? await context.cookies()
|
|
148
|
+
: await page.cookies();
|
|
141
149
|
const pageUrl = page.url();
|
|
142
150
|
const origin = new URL(pageUrl).origin;
|
|
143
151
|
const origins = [];
|
|
@@ -161,7 +169,11 @@ async function collectPuppeteerStorageState(page) {
|
|
|
161
169
|
export async function saveStorageState(page, filePath) {
|
|
162
170
|
const context = typeof page.context === 'function' ? page.context() : null;
|
|
163
171
|
if (typeof context?.storageState === 'function') {
|
|
164
|
-
|
|
172
|
+
const state = await context.storageState();
|
|
173
|
+
if (filePath) {
|
|
174
|
+
await writeRestrictedState(filePath, state);
|
|
175
|
+
}
|
|
176
|
+
return state;
|
|
165
177
|
}
|
|
166
178
|
|
|
167
179
|
if (
|
|
@@ -174,7 +186,21 @@ export async function saveStorageState(page, filePath) {
|
|
|
174
186
|
|
|
175
187
|
const storageState = await collectPuppeteerStorageState(page);
|
|
176
188
|
if (filePath) {
|
|
177
|
-
await
|
|
189
|
+
await writeRestrictedState(filePath, storageState);
|
|
178
190
|
}
|
|
179
191
|
return storageState;
|
|
180
192
|
}
|
|
193
|
+
|
|
194
|
+
/** Replace state atomically with an owner-only file, without following a destination symlink. */
|
|
195
|
+
export async function writeRestrictedState(filePath, state) {
|
|
196
|
+
const temporary = `${filePath}.${randomUUID()}.tmp`;
|
|
197
|
+
try {
|
|
198
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, {
|
|
199
|
+
mode: 0o600,
|
|
200
|
+
flag: 'wx',
|
|
201
|
+
});
|
|
202
|
+
await rename(temporary, filePath);
|
|
203
|
+
} finally {
|
|
204
|
+
await rm(temporary, { force: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -2,7 +2,7 @@ import { constants } from 'node:fs';
|
|
|
2
2
|
import { physicalPath } from './browser-profile-files.js';
|
|
3
3
|
import {
|
|
4
4
|
BROWSER_SOURCES,
|
|
5
|
-
|
|
5
|
+
resolveBrowserProtectionRoots,
|
|
6
6
|
resolveBrowserExecutables,
|
|
7
7
|
} from './browser-sources.js';
|
|
8
8
|
import { access } from 'node:fs/promises';
|
|
@@ -46,7 +46,7 @@ export function knownDefaultUserDataDirs({
|
|
|
46
46
|
environment = process.env,
|
|
47
47
|
} = {}) {
|
|
48
48
|
return BROWSER_SOURCES.flatMap((browser) =>
|
|
49
|
-
|
|
49
|
+
resolveBrowserProtectionRoots(browser.id, {
|
|
50
50
|
platform,
|
|
51
51
|
homeDir,
|
|
52
52
|
environment,
|
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { removeCallback, subscribeCallbacks } from './subscriptions.js';
|
|
1
2
|
/**
|
|
2
3
|
* NavigationManager - Centralized navigation handling
|
|
3
4
|
*
|
|
@@ -10,7 +11,13 @@
|
|
|
10
11
|
|
|
11
12
|
import { isNavigationError } from './navigation-safety.js';
|
|
12
13
|
import { TIMING } from './constants.js';
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
READINESS_STATUS,
|
|
16
|
+
createDeadline,
|
|
17
|
+
runWithinDeadline,
|
|
18
|
+
} from './readiness.js';
|
|
19
|
+
import { createManagedNavigator } from './managed-navigation.js';
|
|
20
|
+
import { navigationPhase as runNavigationPhase } from './navigation-operation.js';
|
|
14
21
|
import { createReadinessWaiter } from './navigation-readiness.js';
|
|
15
22
|
|
|
16
23
|
async function setManagedContent(options = {}) {
|
|
@@ -23,7 +30,7 @@ async function setManagedContent(options = {}) {
|
|
|
23
30
|
currentUrl,
|
|
24
31
|
triggerNavigationStart,
|
|
25
32
|
updateCurrentUrl,
|
|
26
|
-
|
|
33
|
+
waitForReady,
|
|
27
34
|
abandonNavigation,
|
|
28
35
|
} = options;
|
|
29
36
|
|
|
@@ -32,22 +39,35 @@ async function setManagedContent(options = {}) {
|
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
log.debug(() => '🚀 Loading in-memory HTML content');
|
|
42
|
+
const deadline = createDeadline({ timeout, signal: options.signal });
|
|
35
43
|
|
|
36
44
|
try {
|
|
37
45
|
// Treat document replacement as a controlled navigation so active page
|
|
38
46
|
// triggers stop before the old document is discarded.
|
|
39
|
-
await
|
|
40
|
-
|
|
47
|
+
await runNavigationPhase(deadline, () =>
|
|
48
|
+
triggerNavigationStart({ url: currentUrl, isExternal: false })
|
|
49
|
+
);
|
|
50
|
+
await runNavigationPhase(deadline, () =>
|
|
51
|
+
page.setContent(html, {
|
|
52
|
+
waitUntil,
|
|
53
|
+
timeout: Math.max(1, deadline.remainingMs()),
|
|
54
|
+
})
|
|
55
|
+
);
|
|
41
56
|
|
|
42
57
|
// setContent normally preserves the URL, but inline scripts can navigate.
|
|
43
58
|
updateCurrentUrl();
|
|
44
|
-
|
|
45
|
-
|
|
59
|
+
return (
|
|
60
|
+
await waitForReady({
|
|
61
|
+
deadline,
|
|
62
|
+
reason: 'after setContent',
|
|
63
|
+
checks: options.checks,
|
|
64
|
+
})
|
|
65
|
+
).ready;
|
|
46
66
|
} catch (error) {
|
|
47
67
|
// Do not leave the manager in its loading state when setContent fails, but
|
|
48
68
|
// do not claim the page became ready either - it did not.
|
|
49
69
|
abandonNavigation('setContent failed');
|
|
50
|
-
if (isNavigationError(error)) {
|
|
70
|
+
if (error.status || isNavigationError(error)) {
|
|
51
71
|
log.debug(() => '⚠️ Content loading was interrupted, recovering...');
|
|
52
72
|
return false;
|
|
53
73
|
}
|
|
@@ -77,6 +97,7 @@ export function createNavigationManager(options = {}) {
|
|
|
77
97
|
let navigationStartTime = null;
|
|
78
98
|
let navigationPromise = null;
|
|
79
99
|
let navigationResolve = null;
|
|
100
|
+
const state = { activeOperation: null, lastOutcome: null };
|
|
80
101
|
|
|
81
102
|
// Session tracking
|
|
82
103
|
let sessionId = 0;
|
|
@@ -182,6 +203,10 @@ export function createNavigationManager(options = {}) {
|
|
|
182
203
|
}
|
|
183
204
|
sessionCleanupCallbacks = [];
|
|
184
205
|
|
|
206
|
+
if (details.signal?.aborted) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
185
210
|
// Start new session
|
|
186
211
|
sessionId++;
|
|
187
212
|
isNavigating = true;
|
|
@@ -210,12 +235,14 @@ export function createNavigationManager(options = {}) {
|
|
|
210
235
|
|
|
211
236
|
// If external navigation, wait for it to complete
|
|
212
237
|
if (isExternal) {
|
|
213
|
-
await waitForPageReady({
|
|
238
|
+
await waitForPageReady({
|
|
239
|
+
reason: 'external navigation',
|
|
240
|
+
timeout: config.networkIdleTimeout,
|
|
241
|
+
});
|
|
214
242
|
}
|
|
215
243
|
}
|
|
216
244
|
|
|
217
245
|
// Track if a readiness wait is currently running to prevent concurrent calls
|
|
218
|
-
let pageReadyPromise = null;
|
|
219
246
|
|
|
220
247
|
const { waitForReady } = createReadinessWaiter({
|
|
221
248
|
page,
|
|
@@ -250,22 +277,9 @@ export function createNavigationManager(options = {}) {
|
|
|
250
277
|
async function waitForPageReady(opts = {}) {
|
|
251
278
|
const { reason = 'page ready' } = opts;
|
|
252
279
|
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
log.debug(
|
|
257
|
-
() => `⏳ Waiting for existing page ready operation (${reason})...`
|
|
258
|
-
);
|
|
259
|
-
return pageReadyPromise;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
pageReadyPromise = waitForReady(opts).then((result) => result.ready);
|
|
263
|
-
|
|
264
|
-
try {
|
|
265
|
-
return await pageReadyPromise;
|
|
266
|
-
} finally {
|
|
267
|
-
pageReadyPromise = null;
|
|
268
|
-
}
|
|
280
|
+
// Independent policies and deadlines cannot safely share a readiness wait.
|
|
281
|
+
log.debug(() => `Waiting for page ready (${reason})`);
|
|
282
|
+
return (await waitForReady(opts)).ready;
|
|
269
283
|
}
|
|
270
284
|
|
|
271
285
|
/**
|
|
@@ -343,36 +357,15 @@ export function createNavigationManager(options = {}) {
|
|
|
343
357
|
* @param {number} options.timeout - Navigation timeout
|
|
344
358
|
* @returns {Promise<boolean>} - True if navigation succeeded
|
|
345
359
|
*/
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
try {
|
|
356
|
-
// Trigger navigation start
|
|
357
|
-
await triggerNavigationStart({ url, isExternal: false });
|
|
358
|
-
|
|
359
|
-
// Perform navigation
|
|
360
|
-
await page.goto(url, { waitUntil, timeout });
|
|
361
|
-
|
|
362
|
-
// Update current URL
|
|
363
|
-
currentUrl = page.url();
|
|
364
|
-
|
|
365
|
-
// Wait for page to be fully ready
|
|
366
|
-
return await waitForPageReady({ timeout, reason: 'after goto' });
|
|
367
|
-
} catch (error) {
|
|
368
|
-
if (isNavigationError(error)) {
|
|
369
|
-
log.debug(() => '⚠️ Navigation was interrupted, recovering...');
|
|
370
|
-
abandonNavigation('navigation interrupted');
|
|
371
|
-
return false;
|
|
372
|
-
}
|
|
373
|
-
throw error;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
360
|
+
const navigate = createManagedNavigator({
|
|
361
|
+
page,
|
|
362
|
+
state,
|
|
363
|
+
config,
|
|
364
|
+
waitForReady,
|
|
365
|
+
triggerNavigationStart,
|
|
366
|
+
updateCurrentUrl: () => (currentUrl = page.url()),
|
|
367
|
+
abandonNavigation,
|
|
368
|
+
});
|
|
376
369
|
|
|
377
370
|
const setContent = (opts = {}) =>
|
|
378
371
|
setManagedContent({
|
|
@@ -382,7 +375,7 @@ export function createNavigationManager(options = {}) {
|
|
|
382
375
|
currentUrl,
|
|
383
376
|
triggerNavigationStart,
|
|
384
377
|
updateCurrentUrl: () => (currentUrl = page.url()),
|
|
385
|
-
|
|
378
|
+
waitForReady,
|
|
386
379
|
abandonNavigation,
|
|
387
380
|
});
|
|
388
381
|
|
|
@@ -399,23 +392,15 @@ export function createNavigationManager(options = {}) {
|
|
|
399
392
|
return true; // Already ready
|
|
400
393
|
}
|
|
401
394
|
|
|
402
|
-
// Create a promise that resolves when navigation completes
|
|
403
395
|
if (!navigationPromise) {
|
|
404
396
|
navigationPromise = new Promise((resolve) => {
|
|
405
397
|
navigationResolve = resolve;
|
|
406
|
-
|
|
407
|
-
// Timeout handler
|
|
408
|
-
setTimeout(() => {
|
|
409
|
-
if (isNavigating) {
|
|
410
|
-
log.debug(() => '⚠️ waitForNavigation timeout');
|
|
411
|
-
abandonNavigation('waitForNavigation timeout');
|
|
412
|
-
resolve(false);
|
|
413
|
-
}
|
|
414
|
-
}, timeout);
|
|
415
398
|
});
|
|
416
399
|
}
|
|
417
|
-
|
|
418
|
-
|
|
400
|
+
const deadline =
|
|
401
|
+
opts.deadline ?? createDeadline({ timeout, signal: opts.signal });
|
|
402
|
+
const outcome = await runWithinDeadline(deadline, () => navigationPromise);
|
|
403
|
+
return !outcome.timedOut && !outcome.interrupted && outcome.value;
|
|
419
404
|
}
|
|
420
405
|
|
|
421
406
|
/**
|
|
@@ -450,28 +435,21 @@ export function createNavigationManager(options = {}) {
|
|
|
450
435
|
* Will be called before next navigation
|
|
451
436
|
*/
|
|
452
437
|
function onSessionCleanup(callback) {
|
|
453
|
-
sessionCleanupCallbacks
|
|
438
|
+
return subscribeCallbacks(sessionCleanupCallbacks, callback);
|
|
454
439
|
}
|
|
455
440
|
|
|
456
441
|
/**
|
|
457
442
|
* Add event listener
|
|
458
443
|
*/
|
|
459
444
|
function on(event, callback) {
|
|
460
|
-
|
|
461
|
-
listeners[event].push(callback);
|
|
462
|
-
}
|
|
445
|
+
return subscribeCallbacks(listeners[event], callback);
|
|
463
446
|
}
|
|
464
447
|
|
|
465
448
|
/**
|
|
466
449
|
* Remove event listener
|
|
467
450
|
*/
|
|
468
451
|
function off(event, callback) {
|
|
469
|
-
|
|
470
|
-
const index = listeners[event].indexOf(callback);
|
|
471
|
-
if (index !== -1) {
|
|
472
|
-
listeners[event].splice(index, 1);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
452
|
+
removeCallback(listeners[event], callback);
|
|
475
453
|
}
|
|
476
454
|
|
|
477
455
|
/**
|
|
@@ -486,6 +464,7 @@ export function createNavigationManager(options = {}) {
|
|
|
486
464
|
* Stop listening for navigation events
|
|
487
465
|
*/
|
|
488
466
|
function stopListening() {
|
|
467
|
+
state.activeOperation?.controller.abort();
|
|
489
468
|
page.off('framenavigated', handleFrameNavigation);
|
|
490
469
|
log.debug(() => '🔌 Navigation manager stopped');
|
|
491
470
|
}
|
|
@@ -500,6 +479,10 @@ export function createNavigationManager(options = {}) {
|
|
|
500
479
|
return {
|
|
501
480
|
// Navigation
|
|
502
481
|
navigate,
|
|
482
|
+
cancelNavigation: () => {
|
|
483
|
+
state.activeOperation?.controller.abort();
|
|
484
|
+
abandonNavigation('caller stopped navigation');
|
|
485
|
+
},
|
|
503
486
|
setContent,
|
|
504
487
|
waitForNavigation,
|
|
505
488
|
waitForPageReady,
|
|
@@ -507,6 +490,7 @@ export function createNavigationManager(options = {}) {
|
|
|
507
490
|
|
|
508
491
|
// State
|
|
509
492
|
isNavigating: () => isNavigating,
|
|
493
|
+
getLastOutcome: () => state.lastOutcome,
|
|
510
494
|
getCurrentUrl: () => currentUrl,
|
|
511
495
|
getSessionId: () => sessionId,
|
|
512
496
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { runWithinDeadline } from './readiness.js';
|
|
2
|
+
|
|
3
|
+
/** Stable outcome for cancellation or an exhausted operation budget. */
|
|
4
|
+
export class NavigationStoppedError extends Error {
|
|
5
|
+
constructor(status) {
|
|
6
|
+
super(
|
|
7
|
+
status === 'interrupted'
|
|
8
|
+
? 'Navigation interrupted'
|
|
9
|
+
: 'Navigation timed out'
|
|
10
|
+
);
|
|
11
|
+
this.name = 'NavigationStoppedError';
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Bound every navigation phase by the same deadline and caller signal. */
|
|
17
|
+
export async function navigationPhase(deadline, run) {
|
|
18
|
+
const result = await runWithinDeadline(deadline, run);
|
|
19
|
+
if (result.interrupted || result.timedOut) {
|
|
20
|
+
throw new NavigationStoppedError(
|
|
21
|
+
result.interrupted ? 'interrupted' : 'timed_out'
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return result.value;
|
|
25
|
+
}
|