diff --git a/packages/extension/e2e.mjs b/packages/extension/e2e.mjs index bca855b..2615d8b 100644 --- a/packages/extension/e2e.mjs +++ b/packages/extension/e2e.mjs @@ -32,6 +32,10 @@
+ +
+
inner content
+
`; @@ -200,6 +209,11 @@ // page scroll: must land as a "scroll" step (debounced per pause) await page.evaluate(() => window.scrollTo(0, 400)); await page.waitForTimeout(500); // let the scroll debounce flush + // inner container scroll: must land as its own "scroll" step with the element + await page.evaluate(() => { + document.getElementById("scroller").scrollTop = 500; + }); + await page.waitForTimeout(500); // let the container scroll debounce flush // console tap: the error must land as a "console" step of the recording await page.evaluate(() => { console.error("e2e console boom", { code: 42 }); @@ -321,15 +335,22 @@ }) ); const consoleOk = steps.some((s) => s.type === "console" && String(s.data?.text ?? "").includes("e2e console boom")); - const scrollStep = steps.find((s) => s.type === "scroll"); + const scrollStep = steps.find((s) => s.type === "scroll" && !s.data?.element); const scrollOk = Boolean(scrollStep) && Number(scrollStep?.data?.y) === 400; + // the inner container scroll must be recorded with its element context + const containerScrollStep = steps.find( + (s) => s.type === "scroll" && JSON.stringify(s.data?.element ?? {}).includes("scroller") + ); + const containerScrollOk = Boolean(containerScrollStep) && Number(containerScrollStep?.data?.y) === 500; const recorderOk = recordingSummary.type === "recording" && steps.some((s) => s.type === "click") && steps.some((s) => s.type === "input" && s.data?.value === "Hello recorder") && scrollOk && + containerScrollOk && Boolean(mediaAttachment); console.log("scroll step:", JSON.stringify(scrollStep?.data ?? null)); + console.log("container scroll step:", JSON.stringify(containerScrollStep?.data ?? null)); // 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 @@ -408,6 +429,9 @@ let replayResultOk = false; let jsHoverOk = false; let cssHoverOk = false; + // the container scroll replays before the mid-recording url_change reloads + // the page — the sticky flag is latched by the replay-side sampling loop + let containerScrollReplayed = false; const panelPage = await context.newPage(); await panelPage.goto(`http://localhost:5173/r/${all[0].share_token}`); await panelPage.waitForTimeout(2000); // let the relay content script install @@ -489,6 +513,15 @@ } } } + // the container scroll replays before the mid-recording url_change reloads + // the page — the latched sessionStorage max survives that reload + if (replayPage && !replayPage.isClosed()) { + const scrollerMax = await replayPage + .evaluate(() => Number(sessionStorage.getItem("ltt-scroller-max") ?? 0)) + .catch(() => 0); + containerScrollReplayed = scrollerMax >= 400; + console.log("container scroll replay:", JSON.stringify({ scrollerMax, ok: containerScrollReplayed })); + } // 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() @@ -640,7 +673,7 @@ console.log("popup:", JSON.stringify({ hasRecord: hasRecord > 0, latestOk: popupOk, deleteOk })); const ok = - noteOk && recorderOk && resumedOk && startUrlOk && clipboardOk && popupOk && relayOk && replayOk && replayResultOk && cursorOk && mouseTrackOk && jsHoverOk && videoOk && consoleOk && composerOk && discardOk && switcherOk && extAssetsOk; + noteOk && recorderOk && resumedOk && startUrlOk && clipboardOk && popupOk && relayOk && replayOk && replayResultOk && cursorOk && mouseTrackOk && jsHoverOk && videoOk && consoleOk && composerOk && discardOk && switcherOk && extAssetsOk && containerScrollReplayed; 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"); @@ -662,6 +695,7 @@ console.log(relayOk && replayResultOk ? "panel relay OK" : "panel relay MISMATCH"); console.log(replayOk ? "replay OK" : "replay MISMATCH"); console.log(jsHoverOk ? "synthetic hover OK" : "synthetic hover MISMATCH"); + console.log(containerScrollReplayed ? "container scroll replay OK" : "container scroll replay MISMATCH"); // the debugger API can legitimately be unavailable (Firefox, another // debugger attached, setting off) — JS-level hover still works, so the CSS // check is reported but doesn't fail the run diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index f1527ac..2f53cc9 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -697,16 +697,21 @@ } played++; } else if (step.type === "scroll") { - // page scroll: no element to resolve — the content script scrolls the - // window, scaling the recorded offsets to the current viewport - await browser.tabs + // page scroll (no element) or an inner container scroll (the step + // carries the container, resolved by the content script); window + // offsets are scaled to the current viewport by the content script + const response = (await browser.tabs .sendMessage(tabId, { type: "replay_scroll", x: step.data?.x, y: step.data?.y, + element: step.data?.element ?? null, viewport: (report.environment as { viewport?: { w: number; h: number } } | undefined)?.viewport, }) - .catch(() => {}); + .catch(() => null)) as { ok: boolean } | null; + if (response && !response.ok) { + failures.push({ index: stepIndex, type: "scroll", reason: "Scrolled element not found on the current page" }); + } // let the page repaint before the next step resolves its element — the // recorded scroll was smooth/human-paced, this one is instant await new Promise((resolve) => setTimeout(resolve, 200)); diff --git a/packages/extension/src/content/index.ts b/packages/extension/src/content/index.ts index 470b6a8..3cc6290 100644 --- a/packages/extension/src/content/index.ts +++ b/packages/extension/src/content/index.ts @@ -115,9 +115,12 @@ /** Longest input debounced per element; flushes a single input event per pause. */ const pendingInputs = new Map(); -/** Window scroll debounced like inputs: one "scroll" step per pause. */ +/** Page scroll debounced like inputs: one "scroll" step per pause. */ let pendingScroll: number | null = null; +/** Inner containers scroll without bubbling — debounced per element too. */ +const pendingScrolls = new Map(); + function flushScroll() { if (pendingScroll == null) return; window.clearTimeout(pendingScroll); @@ -125,6 +128,18 @@ sendRecorderEvent("scroll", null, { x: window.scrollX, y: window.scrollY }); } +function flushContainerScroll(element: Element) { + const pending = pendingScrolls.get(element); + if (!pending) return; + pendingScrolls.delete(element); + window.clearTimeout(pending.timer); + const container = element as HTMLElement; + sendRecorderEvent("scroll", pending.context, { + x: container.scrollLeft, + y: container.scrollTop, + }); +} + function sendRecorderEvent( type: "click" | "input" | "scroll", element: ElementContext | null, @@ -224,10 +239,21 @@ }; const onScroll = (event: Event) => { - // only page scroll — a scroll inside an inner container bubbles up too - if (event.target !== document && event.target !== document.documentElement) return; - if (pendingScroll != null) window.clearTimeout(pendingScroll); - pendingScroll = window.setTimeout(flushScroll, 250); + // the document's own scroll fires on document/documentElement; scroll + // events of inner containers don't bubble and carry the container itself + if (event.target === document || event.target === document.documentElement) { + if (pendingScroll != null) window.clearTimeout(pendingScroll); + pendingScroll = window.setTimeout(flushScroll, 250); + return; + } + const target = event.target; + if (!(target instanceof Element) || isOurOverlay(target)) return; + const existing = pendingScrolls.get(target); + if (existing) window.clearTimeout(existing.timer); + pendingScrolls.set(target, { + timer: window.setTimeout(() => flushContainerScroll(target), 250), + context: buildElementContext(target), + }); }; document.addEventListener("click", onClick, true); @@ -246,6 +272,7 @@ // loses the input step entirely for (const element of [...pendingInputs.keys()]) flushInput(element); flushScroll(); + for (const element of [...pendingScrolls.keys()]) flushContainerScroll(element); const points = takeMoveSamples(); if (points.length) void browser.runtime.sendMessage({ type: "recorder_move_batch", points }).catch(() => {}); }; @@ -261,6 +288,8 @@ moveFlushTimer = null; if (pendingScroll != null) window.clearTimeout(pendingScroll); pendingScroll = null; + for (const [element, pending] of pendingScrolls) window.clearTimeout(pending.timer); + pendingScrolls.clear(); for (const [element, pending] of pendingInputs) window.clearTimeout(pending.timer); pendingInputs.clear(); }; @@ -464,15 +493,25 @@ return true; case "replay_scroll": { - // scale the recorded offsets to the current window size (the recorded - // viewport comes from the report environment) + // a scroll step with an element scrolls that container (no viewport + // scaling — container offsets don't depend on the window size); + // without one it scrolls the window, scaled to the current viewport const viewport = (msg.viewport ?? null) as { w?: number; h?: number } | null; const x = typeof msg.x === "number" ? msg.x : 0; const y = typeof msg.y === "number" ? msg.y : 0; - window.scrollTo( - viewport?.w ? x * (window.innerWidth / viewport.w) : x, - viewport?.h ? y * (window.innerHeight / viewport.h) : y - ); + if (msg.element) { + const container = resolveRecordedElement(msg.element) as HTMLElement | null; + if (!container) { + sendResponse({ ok: false }); + return true; + } + container.scrollTo({ left: x, top: y }); + } else { + window.scrollTo( + viewport?.w ? x * (window.innerWidth / viewport.w) : x, + viewport?.h ? y * (window.innerHeight / viewport.h) : y + ); + } sendResponse({ ok: true }); return true; }