diff --git a/packages/extension/e2e.mjs b/packages/extension/e2e.mjs index 1a5e18f..6e05434 100644 --- a/packages/extension/e2e.mjs +++ b/packages/extension/e2e.mjs @@ -161,6 +161,12 @@ await wiggle(inputBox); await page.mouse.click(inputBox.x + 10, inputBox.y + inputBox.height / 2); await page.keyboard.type("Hello recorder"); + // full navigation in the middle of the recording: capture must resume on + // the new page and the report must keep the *initial* URL + await page.goto(`http://localhost:${PAGE_PORT}/?page=2`); + await page.waitForTimeout(1500); // new document: recorder_state re-sent on complete + await wiggle(buttonBox); + await page.mouse.click(buttonBox.x + box.width / 2, buttonBox.y + buttonBox.height / 2); await page.waitForTimeout(600); // let the input debounce flush await worker.evaluate(() => self.__lttToggleRecorder()); await page.waitForTimeout(4000); // upload step screenshots + submit @@ -197,6 +203,13 @@ steps.some((s) => s.type === "click") && steps.some((s) => s.type === "input" && s.data?.value === "Hello recorder") && Boolean(gifAttachment); + // recording survives a full navigation: url_change recorded, and a click + // captured *after* it (capture listeners reinstalled on the new page); + // the report's page_url must be the URL where recording started + const urlChangeIndex = steps.findIndex((s) => s.type === "url_change"); + const resumedOk = urlChangeIndex >= 0 && steps.slice(urlChangeIndex + 1).some((s) => s.type === "click"); + const startUrlOk = (recordingSummary.page_url ?? "").endsWith("localhost:8899/") && !recordingSummary.page_url.includes("page=2"); + console.log("nav:", JSON.stringify({ urlChangeIndex, resumedOk, page_url: recordingSummary.page_url, startUrlOk })); // the recorded cursor path must be stored alongside the steps const trackPoints = recordingSummary.mouse_track?.points?.length ?? 0; const mouseTrackOk = trackPoints >= 2; @@ -271,19 +284,15 @@ if (cursorOk) break; await replayPage.waitForTimeout(300); } - // poll until the replayed input step has actually typed the value - let inputValue = null; - for (let i = 0; i < 16; i++) { - await replayPage.waitForTimeout(500); - if (replayPage.isClosed()) break; - inputValue = await replayPage.evaluate(() => document.querySelector("#name-input")?.value ?? null); - if (inputValue === "Hello recorder") break; - } - replayOk = inputValue === "Hello recorder"; } const result = await resultPromise; + // every step re-executed, including the mid-recording url_change + replayOk = result?.ok === true && result?.data?.played === result?.data?.total && result.data.total >= 4; + const inputValue = replayPage && !replayPage.isClosed() + ? await replayPage.evaluate(() => document.querySelector("#name-input")?.value ?? null).catch(() => null) + : null; replayResultOk = result?.ok === true; - console.log("panel replay:", JSON.stringify({ result, replayOk, cursorOk })); + console.log("panel replay:", JSON.stringify({ result, cursorOk })); } // --- delete the latest report from the popup (two-step: arm, confirm) --- @@ -297,10 +306,13 @@ const deleteOk = (itemsAfterDelete.items ?? itemsAfterDelete).length === all.length - 1; console.log("popup:", JSON.stringify({ hasRecord: hasRecord > 0, latestOk: popupOk, deleteOk })); - const ok = noteOk && recorderOk && clipboardOk && popupOk && relayOk && replayOk && replayResultOk && cursorOk && mouseTrackOk; + const ok = + noteOk && recorderOk && resumedOk && startUrlOk && clipboardOk && popupOk && relayOk && replayOk && replayResultOk && cursorOk && mouseTrackOk; console.log("background log:", JSON.stringify(await worker.evaluate(() => self.__lttDebug()), null, 1)); console.log(noteOk ? "note flow OK" : "note flow MISMATCH"); console.log(recorderOk ? "recorder flow OK" : "recorder flow MISMATCH"); + console.log(resumedOk ? "recording across navigation OK" : "recording across navigation MISMATCH"); + console.log(startUrlOk ? "start url OK" : "start url MISMATCH"); console.log(mouseTrackOk ? "mouse track OK" : "mouse track MISMATCH"); console.log(clipboardOk ? "clipboard link OK" : "clipboard link MISMATCH"); console.log(popupOk ? "popup OK" : "popup MISMATCH"); diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index f1da6bc..60abc18 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -335,6 +335,14 @@ if (!(await waitTabComplete(tabId))) { throw new Error(`Page did not load within 20 s: ${step.data.to_url}`); } + // the navigation destroyed the page's content script and cursor — + // reinstall both, resuming the cursor path from the current step + await ensureContentScript(tabId); + if (report.mouse_track?.points?.length) { + await browser.tabs + .sendMessage(tabId, { type: "replay_start", track: report.mouse_track, from_t: step.offset_ms }) + .catch(() => {}); + } played++; } else if (step.type === "click" || step.type === "input") { const response = (await browser.tabs.sendMessage(tabId, { type: "replay_step", data: step.data }).catch(() => null)) as @@ -347,6 +355,9 @@ } } // note/screenshot steps have no page effect — skip + // keep the cursor clock aligned with the (gap-capped) step clock so the + // virtual mouse doesn't drift behind the actions as the replay proceeds + await browser.tabs.sendMessage(tabId, { type: "replay_sync", recorded_ms: step.offset_ms }).catch(() => {}); } await new Promise((resolve) => setTimeout(resolve, 500)); @@ -434,13 +445,13 @@ }, }); -// keep the recorder's page metadata fresh on SPA navigations -browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { +// keep the recorder's page metadata fresh on SPA navigations; the report's +// page_url stays the URL where recording *started* (replay starts there) +browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { const buffer = recorders.get(tabId); if (!buffer?.recording) return; - if (changeInfo.url) buffer.pageUrl = changeInfo.url; - if (changeInfo.title) buffer.pageTitle = changeInfo.title; if (changeInfo.url && buffer.lastUrl !== changeInfo.url && tab.url) { + if (!buffer.pageUrl) buffer.pageUrl = tab.url; const eventIndex = buffer.events.push({ type: "url_change", at: Date.now(), @@ -449,6 +460,12 @@ buffer.lastUrl = tab.url; void captureForEvent(tabId, eventIndex); } + // a full navigation destroyed the page's capture listeners — reinstall them + // (and the recorder bar) once the new document is ready + if (changeInfo.status === "complete") { + await ensureContentScript(tabId); + await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: true }).catch(() => {}); + } }); browser.runtime.onMessage.addListener(async (message: unknown, sender: Runtime.MessageSender) => { diff --git a/packages/extension/src/content/index.ts b/packages/extension/src/content/index.ts index 1f0ec9a..2d6fef9 100644 --- a/packages/extension/src/content/index.ts +++ b/packages/extension/src/content/index.ts @@ -120,15 +120,26 @@ document.addEventListener("input", onInput, true); document.addEventListener("mousemove", onMouseMove, true); // the content script dies on full page navigations — flush regularly so - // each page contributes its own slice of the track - moveFlushTimer = window.setInterval(() => { + // each page contributes its own slice of the track, and squeeze out the + // tail when the page is being unloaded + const flushTimer = () => { if (!moveSamples.length) return; void browser.runtime.sendMessage({ type: "recorder_move_batch", points: takeMoveSamples() }).catch(() => {}); - }, 1000); + }; + const onPageHide = () => { + // flush pending input debounces too — otherwise "type, then navigate" + // loses the input step entirely + for (const element of [...pendingInputs.keys()]) flushInput(element); + const points = takeMoveSamples(); + if (points.length) void browser.runtime.sendMessage({ type: "recorder_move_batch", points }).catch(() => {}); + }; + moveFlushTimer = window.setInterval(flushTimer, 1000); + window.addEventListener("pagehide", onPageHide); captureListeners = () => { document.removeEventListener("click", onClick, true); document.removeEventListener("input", onInput, true); document.removeEventListener("mousemove", onMouseMove, true); + window.removeEventListener("pagehide", onPageHide); if (moveFlushTimer != null) window.clearInterval(moveFlushTimer); moveFlushTimer = null; for (const [element, pending] of pendingInputs) window.clearTimeout(pending.timer); @@ -199,14 +210,16 @@ } } -function startReplayCursor(track: CursorState["track"]) { +function startReplayCursor(track: CursorState["track"], fromT = 0) { stopReplayCursor(); if (!track?.points?.length || !track.viewport?.w || !track.viewport?.h) return; const el = ensureCursorEl(); - const state: CursorState = { el, raf: 0, track, startedAt: performance.now(), lastDispatch: 0 }; + const state: CursorState = { el, raf: 0, track, startedAt: performance.now() - fromT, lastDispatch: 0 }; cursor = state; const points = track.points; - let index = 0; + // resume mid-track (after a navigation the replay continues, not restarts) + let index = points.findIndex((p) => p.t >= fromT); + if (index < 0) index = points.length - 1; const loop = () => { if (!cursor || cursor !== state) return; const t = performance.now() - state.startedAt; @@ -229,7 +242,7 @@ setCursorPosition(state, x, y, true); state.raf = requestAnimationFrame(loop); }; - setCursorPosition(state, points[0].x * (window.innerWidth / track.viewport.w), points[0].y * (window.innerHeight / track.viewport.h), false); + setCursorPosition(state, points[index].x * (window.innerWidth / track.viewport.w), points[index].y * (window.innerHeight / track.viewport.h), false); state.raf = requestAnimationFrame(loop); } @@ -313,7 +326,7 @@ } case "replay_start": - startReplayCursor(msg.track as CursorState["track"]); + startReplayCursor(msg.track as CursorState["track"], typeof msg.from_t === "number" ? msg.from_t : 0); sendResponse({ ok: true }); return true; @@ -322,6 +335,17 @@ sendResponse({ ok: true }); return true; + case "replay_sync": { + // background nudges the cursor clock to the (gap-capped) step clock + // after every executed step, so the cursor doesn't drift behind + if (cursor && typeof msg.recorded_ms === "number") { + const drift = msg.recorded_ms - (performance.now() - cursor.startedAt); + cursor.startedAt -= Math.max(Math.min(drift, 400), -400); + } + sendResponse({ ok: true }); + return true; + } + case "show_flash": getOverlay().setFlash(typeof msg.text === "string" ? msg.text : null); sendResponse({ ok: true });