diff --git a/apps/extension/src/lib/__tests__/step-buffer.test.ts b/apps/extension/src/lib/__tests__/step-buffer.test.ts index 32c538e3..aa0e8bf3 100644 --- a/apps/extension/src/lib/__tests__/step-buffer.test.ts +++ b/apps/extension/src/lib/__tests__/step-buffer.test.ts @@ -115,6 +115,37 @@ describe("recording-step-buffer", () => { }); }); + it("records a reload of the current page instead of dropping it as a same-URL navigation", () => { + const buffer = { + steps: [], + navigation: { currentUrl: "https://example.com/a", pendingNavigation: false }, + }; + const result = observeRecordedNavigation( + buffer, + "https://example.com/a", + undefined, + "reload", + [], + ); + expect(result).toEqual({ kind: "appended", index: 0 }); + expect(buffer.steps[0]).toMatchObject({ + op: "navigate", + url: "https://example.com/a", + transitionType: "reload", + }); + }); + + it("still collapses the completion that follows a reload onto the recorded step", () => { + const buffer = { + steps: [], + navigation: { currentUrl: "https://example.com/a", pendingNavigation: false }, + }; + observeRecordedNavigation(buffer, "https://example.com/a", undefined, "reload", []); + // webNavigation.onCompleted reports the same URL and carries no transition type. + expect(observeRecordedNavigation(buffer, "https://example.com/a")).toEqual({ kind: "noop" }); + expect(buffer.steps).toHaveLength(1); + }); + it("asks the recorder to coalesce redirect hops instead of emitting each one", () => { const buffer = { steps: [], diff --git a/apps/extension/src/lib/recording/step-buffer.ts b/apps/extension/src/lib/recording/step-buffer.ts index c1d9a000..e898c6eb 100644 --- a/apps/extension/src/lib/recording/step-buffer.ts +++ b/apps/extension/src/lib/recording/step-buffer.ts @@ -92,7 +92,12 @@ export function observeRecordedNavigation( transitionQualifiers?: string[], ): NavigationObserveResult { const navigation = buffer.navigation; - if (!url || url === navigation.currentUrl) return { kind: "noop" }; + // A reload commits to the URL the tab is already on, so the same-URL guard that + // collapses the onCommitted / onCompleted pair of one navigation would drop it + // too (issue #139). Only the committed event carries the transition type, so the + // completion that follows still collapses onto the recorded reload. + const isReload = transitionType === "reload"; + if (!url || (url === navigation.currentUrl && !isReload)) return { kind: "noop" }; navigation.currentUrl = url; const pendingIsCurrent = diff --git a/apps/extension/src/tools/__tests__/record-steps.test.ts b/apps/extension/src/tools/__tests__/record-steps.test.ts index f933b499..749fe199 100644 --- a/apps/extension/src/tools/__tests__/record-steps.test.ts +++ b/apps/extension/src/tools/__tests__/record-steps.test.ts @@ -416,6 +416,39 @@ describe("recorded user steps reach the exported trace", () => { expect(step?.result.state).toBeTruthy(); }); + it("records a reload of the page the recording is on", async () => { + // Issue #139: a reload commits to the URL the tab is already on, so it used to + // be dropped as the duplicate half of a navigation and the trace had no step + // for it, even though replaying the flow may depend on the refresh. + const chromeApi = installChrome(); + const manager = fakeManager(); + const tabsApi = makeTabsApi(); + const sendToTab = vi.fn(async () => ({ ok: true })); + + await handleRecordStart(manager, RECORD_START_V3, { tabsApi, sendToTab, cdp: makeFakeCdp() }); + + const details = { tabId: TAB_ID, frameId: 0, url: START_URL }; + chromeApi.webNavigationOnCommitted.emit({ + ...details, + transitionType: "reload", + transitionQualifiers: [], + } as unknown as chrome.webNavigation.WebNavigationTransitionCallbackDetails); + chromeApi.webNavigationOnCompleted.emit( + details as unknown as chrome.webNavigation.WebNavigationFramedCallbackDetails, + ); + // Let findRecordingForTab's tab lookup and the settle capture resolve. + await new Promise((resolve) => setTimeout(resolve, 0)); + + const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); + const trace = (stopped as RecordStopResult).trace as TraceV3; + + expect(trace.steps).toHaveLength(1); + const [step] = trace.steps; + expect(step).toMatchObject({ op: "navigate", to: START_URL, cause: "reload" }); + expect(step?.state).toBeTruthy(); + expect(step?.result.state).toBeTruthy(); + }); + it("reports an address-bar navigation from the page it started on, not the redirect hop", async () => { const chromeApi = installChrome(); const manager = fakeManager();