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.
Files changed (58) hide show
  1. package/.jscpd-baseline.json +24 -23
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +2 -0
  4. package/package.json +5 -3
  5. package/src/bindings.js +19 -0
  6. package/src/browser/attach/snapshot-launch.js +8 -6
  7. package/src/browser/attach/snapshot.js +39 -2
  8. package/src/browser/browser-cookie-database.js +71 -2
  9. package/src/browser/browser-cookie-session.js +183 -0
  10. package/src/browser/browser-cookies.js +29 -0
  11. package/src/browser/browser-sources.js +13 -0
  12. package/src/browser/browser-sources.json +13 -0
  13. package/src/browser/debugging-port.js +4 -2
  14. package/src/browser/default-browser.js +33 -9
  15. package/src/browser/launch-diagnostics.js +102 -0
  16. package/src/browser/launcher.js +28 -8
  17. package/src/browser/migration/sqlite-snapshot.js +5 -27
  18. package/src/browser/navigation.js +173 -98
  19. package/src/browser/real-browser.js +135 -68
  20. package/src/browser/session-cookies.js +78 -0
  21. package/src/browser/session-persistence.js +72 -0
  22. package/src/browser/storage-state.js +32 -6
  23. package/src/browser/system-browser.js +2 -2
  24. package/src/core/dialog-manager.js +7 -0
  25. package/src/core/managed-navigation.js +101 -0
  26. package/src/core/navigation-manager.js +62 -78
  27. package/src/core/navigation-operation.js +25 -0
  28. package/src/core/navigation-readiness.js +40 -16
  29. package/src/core/network-tracker.js +39 -92
  30. package/src/core/page-session.js +3 -2
  31. package/src/core/page-trigger-manager.js +15 -12
  32. package/src/core/readiness.js +67 -13
  33. package/src/core/subscriptions.js +20 -0
  34. package/src/core/webdriver-page.js +38 -0
  35. package/src/elements/locators.js +35 -5
  36. package/src/elements/reusable.js +92 -0
  37. package/src/elements/visibility.js +6 -11
  38. package/src/exports.js +14 -0
  39. package/src/factory.js +4 -4
  40. package/src/high-level/universal-logic.js +57 -21
  41. package/src/interactions/click.js +3 -0
  42. package/src/interactions/scroll.js +8 -2
  43. package/src/utilities/subprocess.js +15 -4
  44. package/tests/e2e/navigation-budget.e2e.test.js +132 -0
  45. package/tests/e2e/reusable-session.e2e.test.js +231 -0
  46. package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
  47. package/tests/unit/browser/attach/snapshot.test.js +33 -0
  48. package/tests/unit/browser/catalogue-launch.test.js +50 -2
  49. package/tests/unit/browser/default-browser.test.js +18 -0
  50. package/tests/unit/browser/launch-diagnostics.test.js +52 -0
  51. package/tests/unit/browser/real-browser.test.js +1 -1
  52. package/tests/unit/browser/session-workflows.test.js +206 -0
  53. package/tests/unit/browser/storage-state.test.js +7 -2
  54. package/tests/unit/core/navigation-budget.test.js +120 -0
  55. package/tests/unit/core/navigation-manager.test.js +23 -1
  56. package/tests/unit/core/webdriver-page.test.js +33 -0
  57. package/tests/unit/elements/reusable-helpers.test.js +83 -0
  58. 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
- await page.setCookie(...cookies);
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 cookies = await page.cookies();
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
- return context.storageState(filePath ? { path: filePath } : undefined);
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 writeFile(filePath, `${JSON.stringify(storageState, null, 2)}\n`);
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
- resolveBrowserRoots,
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
- resolveBrowserRoots(browser.id, {
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 { READINESS_STATUS } from './readiness.js';
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
- waitForPageReady,
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 triggerNavigationStart({ url: currentUrl, isExternal: false });
40
- await page.setContent(html, { waitUntil, timeout });
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
- await waitForPageReady({ timeout, reason: 'after setContent' });
45
- return true;
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({ reason: 'external navigation' });
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
- // Concurrent callers join the in-flight wait instead of racing a second
254
- // deadline against the first.
255
- if (pageReadyPromise) {
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
- async function navigate(opts = {}) {
347
- const { url, waitUntil = 'domcontentloaded', timeout = 60000 } = opts;
348
-
349
- if (!url) {
350
- throw new Error('url is required in options');
351
- }
352
-
353
- log.debug(() => `🚀 Navigating to: ${url}`);
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
- waitForPageReady,
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
- return await navigationPromise;
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.push(callback);
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
- if (listeners[event]) {
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
- if (listeners[event]) {
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
+ }