From 237667bd51317c5a45515acbd8b86b559389c616 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Fri, 18 Sep 2026 18:54:30 +0800 Subject: [PATCH] fix(navigation): preserve readiness across cancelled redirects --- .../src/tools/__tests__/navigation.test.ts | 119 ++++++++++++++++++ apps/extension/src/tools/navigation.ts | 6 +- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/apps/extension/src/tools/__tests__/navigation.test.ts b/apps/extension/src/tools/__tests__/navigation.test.ts index 22c6f2f5..fef78586 100644 --- a/apps/extension/src/tools/__tests__/navigation.test.ts +++ b/apps/extension/src/tools/__tests__/navigation.test.ts @@ -762,3 +762,122 @@ it.each([ expect(await work).toMatchObject({ reached: "load" }); expect(fake.listeners).toHaveLength(0); }); + +it.each([ + ["domcontentloaded", "DOMContentLoaded"], + ["load", "load"], + ["networkidle", "networkIdle"], +] as const)("preserves buffered %s through unrelated lifecycle events and cancellation", async (phase, lifecycle) => { + const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await manager.start("aa11"); + const fake = makeFakeCdp(); + const send = fake.cdp.send.bind(fake.cdp); + let current = "loader-before"; + fake.cdp.send = (async (tabId, method, params) => { + if (method === "Page.getFrameTree") + return { frameTree: { frame: { id: "frame-1", loaderId: current } } }; + return send(tabId, method, params); + }) as CdpRunner["send"]; + const fire = (method: string, params: Record) => { + for (const listener of [...fake.listeners]) listener({ tabId: 4 }, method, params); + }; + let settled = false; + const work = handleNavigate( + manager, + { session_id: "aa11", url: "https://example.com/", wait_until: phase, timeout_ms: 1000 }, + { cdp: fake.cdp, tabsApi: fake.tabsApi }, + ).then((result) => { + settled = true; + return result; + }); + await vi.waitFor(() => expect(fake.sent.some((s) => s.method === "Page.navigate")).toBe(true)); + current = "loader-after"; + fake.fireFrameNavigated(); + fire("Page.frameRequestedNavigation", { frameId: "frame-1", disposition: "currentTab" }); + fire("Network.requestWillBeSent", { + frameId: "frame-1", + type: "Document", + loaderId: "attempt-loader", + requestId: "attempt", + }); + fake.fireLifecycle(lifecycle); + for (const name of ["networkAlmostIdle", "firstMeaningfulPaint", "InteractiveTime"]) + fake.fireLifecycle(name); + await Promise.resolve(); + expect(settled).toBe(false); + fire("Network.loadingFailed", { + requestId: "attempt", + canceled: true, + errorText: "net::ERR_ABORTED", + }); + expect(await work).toMatchObject({ reached: phase }); + expect(fake.listeners).toHaveLength(0); +}); + +it.each([ + ["load", "DOMContentLoaded", "load"], + ["networkidle", "load", "networkIdle"], +] as const)("does not reuse a predecessor's buffered %s after the successor commits", async (phase, earlierLifecycle, lifecycle) => { + const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await manager.start("aa11"); + const fake = makeFakeCdp(); + const send = fake.cdp.send.bind(fake.cdp); + let current = "loader-before"; + let frameReads = 0; + fake.cdp.send = (async (tabId, method, params) => { + if (method === "Page.getFrameTree") { + frameReads += 1; + return { frameTree: { frame: { id: "frame-1", loaderId: current } } }; + } + return send(tabId, method, params); + }) as CdpRunner["send"]; + const fire = (method: string, params: Record) => { + for (const listener of [...fake.listeners]) listener({ tabId: 4 }, method, params); + }; + const begin = (loaderId: string) => { + fire("Page.frameRequestedNavigation", { frameId: "frame-1", disposition: "currentTab" }); + fire("Network.requestWillBeSent", { + frameId: "frame-1", + type: "Document", + loaderId, + requestId: loaderId, + }); + }; + let settled = false; + const work = handleNavigate( + manager, + { session_id: "aa11", url: "https://example.com/", wait_until: phase, timeout_ms: 1000 }, + { cdp: fake.cdp, tabsApi: fake.tabsApi }, + ).then((result) => { + settled = true; + return result; + }); + await vi.waitFor(() => expect(fake.sent.some((s) => s.method === "Page.navigate")).toBe(true)); + current = "loader-after"; + fake.fireFrameNavigated(); + begin("second-loader"); + fake.fireLifecycle(lifecycle); + fake.fireLifecycle("firstMeaningfulPaint"); + + current = "second-loader"; + fake.fireFrameNavigated("frame-1", current); + begin("third-loader"); + fake.fireLifecycle(earlierLifecycle, "frame-1", current); + fake.fireLifecycle("firstMeaningfulPaint", "frame-1", current); + const beforeCancellation = frameReads; + fire("Network.loadingFailed", { + requestId: "third-loader", + canceled: true, + errorText: "net::ERR_ABORTED", + }); + await vi.waitFor(() => expect(frameReads).toBeGreaterThan(beforeCancellation)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(settled).toBe(false); + + fake.fireLifecycle(lifecycle, "frame-1", "loader-after"); + await Promise.resolve(); + expect(settled).toBe(false); + fake.fireLifecycle(lifecycle, "frame-1", current); + expect(await work).toMatchObject({ reached: phase }); + expect(fake.listeners).toHaveLength(0); +}); diff --git a/apps/extension/src/tools/navigation.ts b/apps/extension/src/tools/navigation.ts index a1305ed8..f954ba9e 100644 --- a/apps/extension/src/tools/navigation.ts +++ b/apps/extension/src/tools/navigation.ts @@ -459,7 +459,11 @@ function startLifecycleWait( p.loaderId === document.id && lifecycleEventMatchesFrame(p.frameId) ) { - pendingLifecycle = { name: p.name, frameId: p.frameId, loaderId: p.loaderId }; + // Keep evidence that this document met the wait condition until the + // pending navigation is cancelled or a successor commits and clears it. + if (!pendingLifecycle || !lifecycleMeetsOrExceeds(pendingLifecycle.name, targetName)) { + pendingLifecycle = { name: p.name, frameId: p.frameId, loaderId: p.loaderId }; + } void reconcilePending(); return; }