// Navigation tools — `tool.navigate`, `tool.navigate_back`, // `tool.navigate_forward`, `tool.reload` (design §7, plan M7.1 / M7.2). // // Each handler: // 1. Resolves the target tab (sandbox: must be in the session's Agent // Window — review M7). // 2. Attaches CDP, enables `Page` lifecycle events. // 3. Kicks off the actual navigation (`Page.navigate`, // `Page.navigateToHistoryEntry`, or `Page.reload`). // 4. Awaits the requested `wait_until` lifecycle phase, bounded by // `timeout_ms` and the caller's `AbortSignal`. Listener cleanup is // explicit so an aborted call never leaves a dangling listener // behind (review M7 abort guidance). // // The returned shape mirrors bsk-protocol's `NavigateResult` / // `NavigateBackResult` / `NavigateForwardResult` / `ReloadResult`. import { ChromiumCdp } from "@/browser-driver/chromium-cdp"; import type { SessionManager } from "@/session-manager/manager"; import type { NavigateBackParams, NavigateForwardParams, NavigateHistoryResult, NavigateParams, NavigateResult, ReloadParams, ReloadResult, RpcError, WaitUntil, } from "@/transport/types"; import { type BrowserNavigationApi, chromeBrowserNavigationApi, navigateWithBrowserApi, } from "./browser-navigation"; import { attachDialogs, markDialogCursor } from "./dialogs"; import { cdpError, isCdpExtensionAccessDenied } from "./errors"; import { type CdpRunner, type ChromeTabsApi, chromeTabsApi, enforceAgentWindow, isRpcError, lookupSession, resolveTargetTab, } from "./shared"; export interface NavigationDeps { cdp: CdpRunner; tabsApi: ChromeTabsApi; browserNavigation?: BrowserNavigationApi; /** Optional AbortSignal — M7 abort hook (M10.2 will wire the full chain). */ signal?: AbortSignal; /** Override default timeout when the caller omits `timeout_ms`. */ defaultTimeoutMs?: number; } const DEFAULT_NAV_TIMEOUT_MS = 30_000; const DEFAULT_HISTORY_TIMEOUT_MS = 15_000; /** * Translate a wire `wait_until` value into the lifecycle event name * CDP emits. Exported for unit tests. */ export function cdpLifecycleName(wu: WaitUntil): string { switch (wu) { case "load": return "load"; case "domcontentloaded": return "DOMContentLoaded"; case "networkidle": return "networkIdle"; case "commit": return "commit"; } } interface ReadyStateProbeReply { result?: { value?: unknown }; } /** * Read `document.readyState` on the tab's main execution context. * Returns `null` when CDP cannot evaluate (detached tab, etc.). */ export async function probeMainFrameReadyState( cdp: CdpRunner, tabId: number, ): Promise { try { await cdp.send(tabId, "Runtime.enable", {}); const reply = await cdp.send(tabId, "Runtime.evaluate", { expression: "document.readyState", returnByValue: true, }); const value = reply.result?.value; return typeof value === "string" ? value : null; } catch { return null; } } /** `wait_until` phases in navigation order (paint events excluded). */ const WAIT_UNTIL_PHASE_ORDER = ["commit", "DOMContentLoaded", "load", "networkIdle"] as const; /** * Whether a lifecycle event name satisfies the requested phase (exact * match or a later phase in the navigation pipeline). */ export function lifecycleMeetsOrExceeds(observed: string, targetName: string): boolean { if (observed === targetName) return true; const oi = WAIT_UNTIL_PHASE_ORDER.indexOf(observed as (typeof WAIT_UNTIL_PHASE_ORDER)[number]); const ti = WAIT_UNTIL_PHASE_ORDER.indexOf(targetName as (typeof WAIT_UNTIL_PHASE_ORDER)[number]); if (oi < 0 || ti < 0) return false; return oi >= ti; } /** * Whether the main document has already reached (or passed) the CDP * lifecycle phase named by `targetName`, inferred from * `document.readyState`. */ export function lifecycleAlreadyReached(readyState: string, targetName: string): boolean { switch (targetName) { case "load": return readyState === "complete"; case "DOMContentLoaded": return readyState === "interactive" || readyState === "complete"; case "commit": return readyState === "interactive" || readyState === "complete" || readyState === "loading"; case "networkIdle": // Must come from `Page.lifecycleEvent`; readyState does not imply network idle. return false; default: return false; } } /** When a readyState probe may short-circuit an in-flight lifecycle wait. */ export type LifecycleProbeMode = "passive" | "after-navigation"; /** * Decide whether `document.readyState` is safe to treat as "already * done" for the requested lifecycle target. * * * `passive` — `wait_for_navigation` on a settled page (no navigation * initiated by this handler). * * `after-navigation` — following `navigate` / `reload` / history; * rejects `complete`→`complete` with no observed lifecycle events so * we do not confuse the previous document for the new load. */ export function shouldTrustReadyStateProbe( readyState: string, targetName: string, context: { mode: LifecycleProbeMode; beforeReadyState: string | null; sawRelevantLifecycle: boolean; }, ): boolean { if (!lifecycleAlreadyReached(readyState, targetName)) return false; if (context.mode === "passive") return true; if (context.sawRelevantLifecycle) return true; if (context.beforeReadyState === "complete" && readyState === "complete") { return false; } return true; } let defaultDeps: { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } | null = null; function getDefaultDeps(): { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } { if (!defaultDeps) { defaultDeps = { cdp: new ChromiumCdp(), tabsApi: chromeTabsApi, }; } return defaultDeps; } /** * Wait for the lifecycle event matching `targetName` on `frameId`, or * resolve early when the AbortSignal fires / `timeoutMs` elapses. * Returns either `{ reached: "load" }` on success, or * `{ reached: "timeout", lastReached?: "" }` if the * timeout hit before the requested name. */ interface WaitOutcome { reached: "match" | "timeout" | "cancelled"; lastLifecycle?: string; } interface LifecycleWait { promise: Promise; refresh(): void; tryProbe(mode: LifecycleProbeMode, beforeReadyState?: string | null): Promise; } /** Loader / frame guards for active navigations (stale document rejection). */ export interface LifecycleWaitGuard { loaderId?: string | (() => string | null | undefined); beforeLoaderId?: string | null; } function currentLoaderId(guard: LifecycleWaitGuard | undefined): string { if (!guard?.loaderId) return ""; return typeof guard.loaderId === "function" ? (guard.loaderId() ?? "") : guard.loaderId; } /** Reject lifecycle events from the pre-navigation loader or wrong loader. */ export function eventLoaderIsRelevant( eventLoaderId: string | undefined, guard: LifecycleWaitGuard | undefined, ): boolean { const before = guard?.beforeLoaderId ?? null; if (eventLoaderId && before && eventLoaderId === before) return false; const expected = currentLoaderId(guard); if (expected.length > 0) { if (!eventLoaderId) return false; return eventLoaderId === expected; } return true; } function startLifecycleWait( cdp: CdpRunner, expectedTabId: number, frameId: string | (() => string | null | undefined), targetName: string, timeoutMs: number, signal: AbortSignal | undefined, guard?: LifecycleWaitGuard, ): LifecycleWait { let refresh = () => {}; let tryProbe = async (_mode: LifecycleProbeMode, _beforeReadyState?: string | null) => {}; const promise = new Promise((resolve) => { let settled = false; let sawRelevantLifecycle = false; let lastLifecycle: string | undefined; let pendingLifecycle: { name: string; frameId?: string; loaderId?: string } | null = null; let listenerSub: { dispose(): void } | null = null; let timer: ReturnType | null = null; let abortHandler: (() => void) | null = null; const currentFrameId = () => (typeof frameId === "function" ? (frameId() ?? "") : frameId); const noteRelevantLifecycle = (name: string) => { sawRelevantLifecycle = true; lastLifecycle = name; }; const lifecycleEventMatchesFrame = (eventFrameId?: string): boolean => { const expectedFrameId = currentFrameId(); if (expectedFrameId.length === 0) return true; return eventFrameId === expectedFrameId; }; const acceptLifecycleEvent = ( name: string, eventFrameId?: string, eventLoaderId?: string, ): boolean => { if (!eventLoaderIsRelevant(eventLoaderId, guard)) return false; if (!lifecycleEventMatchesFrame(eventFrameId)) return false; noteRelevantLifecycle(name); return true; }; const cleanup = () => { if (listenerSub) { listenerSub.dispose(); listenerSub = null; } if (timer) { clearTimeout(timer); timer = null; } if (abortHandler && signal) { signal.removeEventListener("abort", abortHandler); abortHandler = null; } }; const finish = (outcome: WaitOutcome) => { if (settled) return; settled = true; cleanup(); resolve(outcome); }; const maybeFinishPending = () => { if (settled || !pendingLifecycle) return; if ( !acceptLifecycleEvent( pendingLifecycle.name, pendingLifecycle.frameId, pendingLifecycle.loaderId, ) ) { return; } if (lifecycleMeetsOrExceeds(pendingLifecycle.name, targetName)) { finish({ reached: "match", lastLifecycle: pendingLifecycle.name }); } }; refresh = maybeFinishPending; tryProbe = async (mode: LifecycleProbeMode, beforeReadyState: string | null = null) => { if (settled) return; const readyState = await probeMainFrameReadyState(cdp, expectedTabId); if (readyState === null) return; if ( shouldTrustReadyStateProbe(readyState, targetName, { mode, beforeReadyState, sawRelevantLifecycle, }) ) { finish({ reached: "match", lastLifecycle: targetName }); } }; if (signal?.aborted) { finish({ reached: "cancelled", lastLifecycle }); return; } if (cdp.onEvent) { listenerSub = cdp.onEvent( (source: chrome.debugger.Debuggee, method: string, params: unknown) => { if (settled) return; if (source.tabId !== expectedTabId) return; if (targetName === "commit" && method === "Page.frameNavigated") { const p = params as { frame?: { id?: string; parentId?: string; loaderId?: string } }; const expectedFrameId = currentFrameId(); if (expectedFrameId.length > 0) { if (p.frame?.id !== expectedFrameId) return; } else if (p.frame?.parentId) { return; } if (!acceptLifecycleEvent("commit", p.frame?.id, p.frame?.loaderId)) return; finish({ reached: "match", lastLifecycle: "commit" }); return; } if (method !== "Page.lifecycleEvent") return; const p = params as { name?: string; frameId?: string; loaderId?: string }; if (!p?.name) return; if (currentFrameId().length === 0) { // A static empty `frameId` means the caller does not know // which frame to filter on (M9.2 `wait_for_navigation` // observes a navigation it did not initiate). Match by // tab-id only in that case. A callback form that // currently returns "" is the M7 navigate path — the // frame becomes known after `Page.navigate` resolves, so // we still stash until `refresh()` runs. if (typeof frameId !== "function") { if (!acceptLifecycleEvent(p.name, p.frameId, p.loaderId)) return; if (lifecycleMeetsOrExceeds(p.name, targetName)) { finish({ reached: "match", lastLifecycle: p.name }); } return; } if (!eventLoaderIsRelevant(p.loaderId, guard)) return; if (!lifecycleEventMatchesFrame(p.frameId)) return; pendingLifecycle = { name: p.name, frameId: p.frameId, loaderId: p.loaderId }; return; } if (!acceptLifecycleEvent(p.name, p.frameId, p.loaderId)) return; if (lifecycleMeetsOrExceeds(p.name, targetName)) { finish({ reached: "match", lastLifecycle: p.name }); } }, ); } if (signal) { abortHandler = () => { finish({ reached: "cancelled", lastLifecycle }); }; signal.addEventListener("abort", abortHandler, { once: true }); } timer = setTimeout(() => { finish({ reached: "timeout", lastLifecycle }); }, timeoutMs); }); return { promise, refresh: () => refresh(), tryProbe: (mode, beforeReadyState) => tryProbe(mode, beforeReadyState), }; } /** * Wait for a lifecycle phase on an already-settled page (`passive` * readyState probe). Used by `wait_for_navigation`. */ export async function waitForLifecyclePassive( cdp: CdpRunner, expectedTabId: number, frameId: string, targetName: string, timeoutMs: number, signal: AbortSignal | undefined, ): Promise { const wait = startLifecycleWait(cdp, expectedTabId, frameId, targetName, timeoutMs, signal); await wait.tryProbe("passive"); return wait.promise; } interface MainFrameInfo { frameId: string | null; loaderId: string | null; } async function readMainFrameInfo(cdp: CdpRunner, tabId: number): Promise { try { const tree = await cdp.send<{ frameTree?: { frame?: { id?: string; loaderId?: string } }; }>(tabId, "Page.getFrameTree", {}); return { frameId: tree.frameTree?.frame?.id ?? null, loaderId: tree.frameTree?.frame?.loaderId ?? null, }; } catch { return { frameId: null, loaderId: null }; } } /** Shared tail for navigate / reload / history after the CDP command ran. */ async function finishLifecycleWait( wait: LifecycleWait, waitPromise: Promise, beforeReadyState: string | null, ): Promise { await wait.tryProbe("after-navigation", beforeReadyState); return waitPromise; } function linkedAbortSignal(signal: AbortSignal | undefined): { signal: AbortSignal; abort(): void; cleanup(): void; } { const controller = new AbortController(); let abortHandler: (() => void) | null = null; if (signal?.aborted) { controller.abort(); } else if (signal) { abortHandler = () => controller.abort(); signal.addEventListener("abort", abortHandler, { once: true }); } return { signal: controller.signal, abort: () => controller.abort(), cleanup: () => { if (abortHandler && signal) { signal.removeEventListener("abort", abortHandler); abortHandler = null; } }, }; } /** * Subscribe to `Page.lifecycleEvent` so the listener is already * attached BEFORE the navigate / reload command is sent. Returns a * disposable + a way to fetch the latest known frameId — useful when * we don't know the frameId up front (history navigation re-uses the * existing frame, so we filter by tabId only). */ export async function ensureCdpReady(cdp: CdpRunner, tabId: number): Promise { await cdp.send(tabId, "Page.enable", {}); await cdp.send(tabId, "Page.setLifecycleEventsEnabled", { enabled: true }); } async function readTabUrl(api: ChromeTabsApi, tabId: number): Promise { try { const t = await api.get(tabId); return t.url ?? undefined; } catch { return undefined; } } /** Recover only a failed preflight; never replay an already-dispatched navigation. */ async function recoverBrowserNavigation( deps: NavigationDeps, tabId: number, action: (api: BrowserNavigationApi) => Promise, waitUntil: WaitUntil, timeoutMs: number, ): Promise { const abort = linkedAbortSignal(deps.signal); const deadline = Date.now() + timeoutMs; const api = deps.browserNavigation ?? chromeBrowserNavigationApi; try { let outcome = await navigateWithBrowserApi( api, tabId, () => action(api), waitUntil === "networkidle" ? "commit" : waitUntil, timeoutMs, abort.signal, ); if (outcome.reached === "failed") return outcome.error; if (outcome.reached === "cancelled" || abort.signal.aborted) { return { code: "cancelled", message: "navigation recovery aborted" }; } if (outcome.reached === "match") { // A completed reload is not a recovery if the restricted frame remains. await deps.cdp.send(tabId, "Page.enable", {}); if (abort.signal.aborted) return { code: "cancelled", message: "navigation recovery aborted" }; if (waitUntil === "networkidle") { // webNavigation cannot prove network idle. Subscribe on the new // document before enabling CDP lifecycle events after its commit. const frame = await readMainFrameInfo(deps.cdp, tabId); if (!frame.frameId) return cdpError("no main frame after navigation"); const remaining = deadline - Date.now(); if (remaining <= 0) { outcome = { reached: "timeout", lastLifecycle: "commit" }; } else { const wait = startLifecycleWait( deps.cdp, tabId, frame.frameId, "networkIdle", remaining, abort.signal, { loaderId: frame.loaderId ?? undefined }, ); await deps.cdp.send(tabId, "Page.setLifecycleEventsEnabled", { enabled: true }); outcome = await wait.promise; } } } if (outcome.reached === "cancelled" || abort.signal.aborted) { return { code: "cancelled", message: "navigation recovery aborted" }; } return { tab_id: tabId, final_url: await readTabUrl(deps.tabsApi, tabId), reached: outcome.reached === "match" ? waitUntil : "timeout", ...(outcome.reached === "timeout" ? { error_text: `timed out waiting for ${waitUntil} during navigation recovery after ${timeoutMs}ms`, } : {}), }; } catch (error) { return abort.signal.aborted ? { code: "cancelled", message: "navigation recovery aborted" } : cdpError(error); } finally { abort.abort(); abort.cleanup(); } } // --------------------------------------------------------------------------- // tool.navigate // --------------------------------------------------------------------------- export async function handleNavigate( manager: SessionManager, params: NavigateParams, deps: NavigationDeps = getDefaultDeps(), ): Promise { if (!params || typeof params.url !== "string" || params.url.length === 0) { return { code: "invalid_params", message: "navigate requires a url" }; } const ctxOrErr = lookupSession(manager, params, "navigate"); if (isRpcError(ctxOrErr)) return ctxOrErr; const ctx = ctxOrErr; const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi); if (isRpcError(target)) return target; const denied = enforceAgentWindow(ctx, target, "navigate"); if (denied) return denied; const dialogCursor = markDialogCursor(deps.cdp, target.tabId); const waitUntil: WaitUntil = params.wait_until ?? "load"; const timeoutMs = params.timeout_ms ?? deps.defaultTimeoutMs ?? DEFAULT_NAV_TIMEOUT_MS; try { deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); try { await ensureCdpReady(deps.cdp, target.tabId); } catch (error) { if (!isCdpExtensionAccessDenied(error)) throw error; const recovered = await recoverBrowserNavigation( deps, target.tabId, (api) => api.update(target.tabId, { url: params.url }), waitUntil, timeoutMs, ); if (isRpcError(recovered)) return recovered; return attachDialogs(deps.cdp, target.tabId, dialogCursor, { ...recovered, url: params.url }); } const expected = cdpLifecycleName(waitUntil); const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId); const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId); let frameId = ""; let loaderId = ""; const waitAbort = linkedAbortSignal(deps.signal); const wait = startLifecycleWait( deps.cdp, target.tabId, () => frameId, expected, timeoutMs, waitAbort.signal, { loaderId: () => loaderId, beforeLoaderId: beforeFrame.loaderId, }, ); const waitPromise = wait.promise; let nav: { frameId: string; loaderId?: string; errorText?: string }; try { nav = await deps.cdp.send<{ frameId: string; loaderId?: string; errorText?: string }>( target.tabId, "Page.navigate", { url: params.url }, ); } catch (err) { waitAbort.abort(); await waitPromise; waitAbort.cleanup(); throw err; } frameId = nav.frameId ?? ""; loaderId = nav.loaderId ?? ""; wait.refresh(); if (nav.errorText) { waitAbort.abort(); await waitPromise; waitAbort.cleanup(); return { code: "cdp_failed", message: `Page.navigate rejected: ${nav.errorText}`, }; } const outcome = await finishLifecycleWait(wait, waitPromise, beforeReadyState); waitAbort.cleanup(); if (outcome.reached === "cancelled") { return { code: "cancelled", message: "navigate aborted" }; } const finalUrl = await readTabUrl(deps.tabsApi, target.tabId); if (outcome.reached === "match") { return attachDialogs(deps.cdp, target.tabId, dialogCursor, { tab_id: target.tabId, url: params.url, final_url: finalUrl, reached: waitUntil, }); } return attachDialogs(deps.cdp, target.tabId, dialogCursor, { tab_id: target.tabId, url: params.url, final_url: finalUrl, reached: "timeout", error_text: `timed out waiting for lifecycle "${expected}" after ${timeoutMs}ms${ outcome.lastLifecycle ? `; last observed "${outcome.lastLifecycle}"` : "" }`, }); } catch (err) { return cdpError(err); } } // --------------------------------------------------------------------------- // Shared history navigation (back / forward / reload) // --------------------------------------------------------------------------- interface HistoryDeps extends NavigationDeps {} interface HistoryEntry { id: number; url: string; } async function handleHistory( manager: SessionManager, params: { session_id?: string; tab_id?: number; wait_until?: WaitUntil; timeout_ms?: number }, direction: "back" | "forward", deps: HistoryDeps, ): Promise { const ctxOrErr = lookupSession(manager, params, `navigate_${direction}`); if (isRpcError(ctxOrErr)) return ctxOrErr; const ctx = ctxOrErr; const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi); if (isRpcError(target)) return target; const denied = enforceAgentWindow(ctx, target, `navigate_${direction}`); if (denied) return denied; const dialogCursor = markDialogCursor(deps.cdp, target.tabId); const waitUntil: WaitUntil = params.wait_until ?? "load"; const timeoutMs = params.timeout_ms ?? deps.defaultTimeoutMs ?? DEFAULT_HISTORY_TIMEOUT_MS; try { deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); await ensureCdpReady(deps.cdp, target.tabId); const history = await deps.cdp.send<{ currentIndex: number; entries: HistoryEntry[] }>( target.tabId, "Page.getNavigationHistory", {}, ); const idx = history.currentIndex; const entries = history.entries ?? []; const targetIdx = direction === "back" ? idx - 1 : idx + 1; if (targetIdx < 0 || targetIdx >= entries.length) { return { code: "invalid_params", message: direction === "back" ? "no previous history entry to navigate back to" : "no forward history entry to navigate to", }; } const previousUrl = entries[idx]?.url; const targetEntry = entries[targetIdx]; const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId); const expected = cdpLifecycleName(waitUntil); const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId); const waitAbort = linkedAbortSignal(deps.signal); const wait = startLifecycleWait( deps.cdp, target.tabId, beforeFrame.frameId ?? "", expected, timeoutMs, waitAbort.signal, { beforeLoaderId: beforeFrame.loaderId }, ); const waitPromise = wait.promise; try { await deps.cdp.send(target.tabId, "Page.navigateToHistoryEntry", { entryId: targetEntry.id }); } catch (err) { waitAbort.abort(); await waitPromise; waitAbort.cleanup(); throw err; } const outcome = await finishLifecycleWait(wait, waitPromise, beforeReadyState); waitAbort.cleanup(); if (outcome.reached === "cancelled") { return { code: "cancelled", message: `navigate_${direction} aborted` }; } const finalUrl = await readTabUrl(deps.tabsApi, target.tabId); if (outcome.reached === "match") { return attachDialogs(deps.cdp, target.tabId, dialogCursor, { tab_id: target.tabId, previous_url: previousUrl, final_url: finalUrl, reached: waitUntil, }); } return attachDialogs(deps.cdp, target.tabId, dialogCursor, { tab_id: target.tabId, previous_url: previousUrl, final_url: finalUrl, reached: "timeout", error_text: `timed out waiting for lifecycle "${expected}" after ${timeoutMs}ms${ outcome.lastLifecycle ? `; last observed "${outcome.lastLifecycle}"` : "" }`, }); } catch (err) { return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err), }; } } export function handleNavigateBack( manager: SessionManager, params: NavigateBackParams, deps: NavigationDeps = getDefaultDeps(), ): Promise { return handleHistory(manager, params, "back", deps); } export function handleNavigateForward( manager: SessionManager, params: NavigateForwardParams, deps: NavigationDeps = getDefaultDeps(), ): Promise { return handleHistory(manager, params, "forward", deps); } // --------------------------------------------------------------------------- // tool.reload // --------------------------------------------------------------------------- export async function handleReload( manager: SessionManager, params: ReloadParams, deps: NavigationDeps = getDefaultDeps(), ): Promise { const ctxOrErr = lookupSession(manager, params, "reload"); if (isRpcError(ctxOrErr)) return ctxOrErr; const ctx = ctxOrErr; const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi); if (isRpcError(target)) return target; const denied = enforceAgentWindow(ctx, target, "reload"); if (denied) return denied; const dialogCursor = markDialogCursor(deps.cdp, target.tabId); const waitUntil: WaitUntil = params.wait_until ?? "load"; const timeoutMs = params.timeout_ms ?? deps.defaultTimeoutMs ?? DEFAULT_HISTORY_TIMEOUT_MS; const ignoreCache = params.hard === true; try { deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); try { await ensureCdpReady(deps.cdp, target.tabId); } catch (error) { if (!isCdpExtensionAccessDenied(error)) throw error; const previousUrl = await readTabUrl(deps.tabsApi, target.tabId); const recovered = await recoverBrowserNavigation( deps, target.tabId, (api) => api.reload(target.tabId, { bypassCache: ignoreCache }), waitUntil, timeoutMs, ); if (isRpcError(recovered)) return recovered; return attachDialogs(deps.cdp, target.tabId, dialogCursor, { ...recovered, previous_url: previousUrl, }); } const previousUrl = await readTabUrl(deps.tabsApi, target.tabId); const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId); const expected = cdpLifecycleName(waitUntil); const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId); const waitAbort = linkedAbortSignal(deps.signal); const wait = startLifecycleWait( deps.cdp, target.tabId, beforeFrame.frameId ?? "", expected, timeoutMs, waitAbort.signal, { beforeLoaderId: beforeFrame.loaderId }, ); const waitPromise = wait.promise; try { await deps.cdp.send(target.tabId, "Page.reload", { ignoreCache }); } catch (err) { waitAbort.abort(); await waitPromise; waitAbort.cleanup(); throw err; } const outcome = await finishLifecycleWait(wait, waitPromise, beforeReadyState); waitAbort.cleanup(); if (outcome.reached === "cancelled") { return { code: "cancelled", message: "reload aborted" }; } const finalUrl = await readTabUrl(deps.tabsApi, target.tabId); if (outcome.reached === "match") { return attachDialogs(deps.cdp, target.tabId, dialogCursor, { tab_id: target.tabId, previous_url: previousUrl, final_url: finalUrl, reached: waitUntil, }); } return attachDialogs(deps.cdp, target.tabId, dialogCursor, { tab_id: target.tabId, previous_url: previousUrl, final_url: finalUrl, reached: "timeout", error_text: `timed out waiting for lifecycle "${expected}" after ${timeoutMs}ms${ outcome.lastLifecycle ? `; last observed "${outcome.lastLifecycle}"` : "" }`, }); } catch (err) { return cdpError(err); } } export const __testing__ = { DEFAULT_NAV_TIMEOUT_MS, DEFAULT_HISTORY_TIMEOUT_MS, };