diff --git a/packages/extension/e2e.mjs b/packages/extension/e2e.mjs index 6e05434..b271409 100644 --- a/packages/extension/e2e.mjs +++ b/packages/extension/e2e.mjs @@ -18,13 +18,23 @@ const EMAIL = `ext-e2e-${stamp}@example.com`; const PASSWORD = "e2e-password-1"; -const pageHtml = `E2E target page +const pageHtml = `E2E target page +

E2E page

+ `; const httpServer = createServer((req, res) => { @@ -230,6 +240,8 @@ let cursorOk = false; let replayOk = false; let replayResultOk = false; + let jsHoverOk = false; + let cssHoverOk = 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 @@ -285,14 +297,36 @@ await replayPage.waitForTimeout(300); } } - const result = await resultPromise; + // wait for the replay to finish while sampling the sticky hover flags: + // __jsHoverSeen latches the synthetic mouseover family, __cssHoverSeen + // latches real CSS :hover (only possible when the debugger API attached) + let result; + for (;;) { + const winner = await Promise.race([ + resultPromise.then((r) => ({ done: true, r })), + new Promise((resolve) => setTimeout(() => resolve({ done: false }), 300)), + ]); + if (winner.done) { + result = winner.r; + break; + } + if (replayPage && !replayPage.isClosed()) { + const flags = await replayPage + .evaluate(() => ({ js: Boolean(window.__jsHoverSeen), css: Boolean(window.__cssHoverSeen) })) + .catch(() => null); + if (flags) { + jsHoverOk = jsHoverOk || flags.js; + cssHoverOk = cssHoverOk || flags.css; + } + } + } // 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, cursorOk })); + console.log("panel replay:", JSON.stringify({ result, cursorOk, jsHoverOk, cssHoverOk })); } // --- delete the latest report from the popup (two-step: arm, confirm) --- @@ -307,7 +341,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; + noteOk && recorderOk && resumedOk && startUrlOk && clipboardOk && popupOk && relayOk && replayOk && replayResultOk && cursorOk && mouseTrackOk && jsHoverOk; 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"); @@ -318,6 +352,11 @@ console.log(popupOk ? "popup OK" : "popup MISMATCH"); 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"); + // 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 + console.log(cssHoverOk ? "css :hover OK" : "css :hover NOT OBSERVED"); console.log(ok ? "E2E OK" : "E2E FAILED"); process.exitCode = ok ? 0 : 1; } catch (error) { diff --git a/packages/extension/manifest.template.json b/packages/extension/manifest.template.json index 9ffe58e..15afb40 100644 --- a/packages/extension/manifest.template.json +++ b/packages/extension/manifest.template.json @@ -3,7 +3,7 @@ "name": "BugTrail", "description": "Capture bug reports: element notes with screenshots and recorded reproduction steps.", "version": "0.1.0", - "permissions": ["activeTab", "scripting", "storage", "tabs"], + "permissions": ["activeTab", "scripting", "storage", "tabs", "debugger"], "host_permissions": [""], "action": { "default_title": "BugTrail", diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index 60abc18..c4bb0a3 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -255,6 +255,58 @@ // ---------- replay (extension users can reproduce a recorded track) ---------- +/** Minimal shape of the chrome.debugger API (absent in Firefox). */ +interface DebuggerApiLike { + attach(target: { tabId: number }, version: string): Promise; + detach(target: { tabId: number }): Promise; + sendCommand(target: { tabId: number }, method: string, params?: Record): Promise; +} + +// tabs with the debugger attached for CSS :hover replay during a running replay +const hoverDebuggerTabs = new Set(); + +/** + * Attaches the Chrome debugger to the replay tab so the virtual cursor's + * positions can be replayed as *trusted* input (Input.dispatchMouseEvent) — + * only trusted input applies CSS :hover, synthetic DOM events can't. + * Chrome shows its "started debugging" infobar; the user can turn this off + * via the hoverReplay setting. Best effort: a busy debugger (DevTools open) + * just disables CSS hover for the replay. + */ +async function attachHoverDebugger(tabId: number): Promise { + const settings = await getSettings(); + if (!settings.hoverReplay) return; + const api = (browser as unknown as { debugger?: DebuggerApiLike }).debugger; + if (!api) return; // Firefox has no debugger API — JS-level hover still works + try { + await api.attach({ tabId }, "1.3"); + hoverDebuggerTabs.add(tabId); + debugLog("hover debugger attached"); + } catch (error) { + debugLog(`hover debugger attach failed: ${error instanceof Error ? error.message : String(error)}`); + } +} + +async function detachHoverDebugger(tabId: number): Promise { + if (!hoverDebuggerTabs.delete(tabId)) return; + const api = (browser as unknown as { debugger?: DebuggerApiLike }).debugger; + await api?.detach({ tabId }).catch(() => {}); +} + +function dispatchHoverMove(tabId: number, x: number, y: number): void { + const api = (browser as unknown as { debugger?: DebuggerApiLike }).debugger; + void api + ?.sendCommand({ tabId }, "Input.dispatchMouseEvent", { + type: "mouseMoved", + x: Math.round(x), + y: Math.round(y), + button: "none", + buttons: 0, + pointerType: "mouse", + }) + .catch(() => {}); +} + function waitTabComplete(tabId: number, timeoutMs = 20000): Promise { return new Promise((resolve) => { const finish = (completed: boolean) => { @@ -314,6 +366,17 @@ if (report.mouse_track?.points?.length) { await browser.tabs.sendMessage(tabId, { type: "replay_start", track: report.mouse_track }).catch(() => {}); } + // trusted-input hover replay (CSS :hover) — must detach in every exit path + await attachHoverDebugger(tabId); + try { + return await runReplaySteps(tabId, report); + } finally { + await detachHoverDebugger(tabId); + } +} + +/** Drives the recorded steps inside the replay tab; cursor already running. */ +async function runReplaySteps(tabId: number, report: ReportDetail): Promise { const steps = report.steps ?? []; const startedAt = Date.now(); @@ -527,6 +590,14 @@ } } + case "replay_hover_move": { + // virtual cursor position mirrored for trusted-input CSS :hover + if (tabId != null && hoverDebuggerTabs.has(tabId)) { + dispatchHoverMove(tabId, Number(msg.x), Number(msg.y)); + } + return { ok: true }; + } + case "capture": { debugLog("capture requested"); try { diff --git a/packages/extension/src/background/settings.ts b/packages/extension/src/background/settings.ts index 247283b..edb495c 100644 --- a/packages/extension/src/background/settings.ts +++ b/packages/extension/src/background/settings.ts @@ -10,6 +10,8 @@ tokenExpiresAt: string | null; defaultProjectId: string | null; user: { id: string; nickname: string; email: string } | null; + /** replay CSS :hover through the Chrome debugger API (shows an infobar) */ + hoverReplay: boolean; } const DEFAULTS: Settings = { @@ -20,6 +22,7 @@ tokenExpiresAt: null, defaultProjectId: null, user: null, + hoverReplay: true, }; export async function getSettings(): Promise { diff --git a/packages/extension/src/content/index.ts b/packages/extension/src/content/index.ts index 2d6fef9..f3d748f 100644 --- a/packages/extension/src/content/index.ts +++ b/packages/extension/src/content/index.ts @@ -178,6 +178,9 @@ track: { viewport: { w: number; h: number }; points: { t: number; x: number; y: number }[] }; startedAt: number; lastDispatch: number; + lastCdp: number; + /** element currently "hovered" by the virtual cursor */ + hoverTarget: Element | null; } let cursor: CursorState | null = null; @@ -199,14 +202,30 @@ function setCursorPosition(state: CursorState, x: number, y: number, dispatch: boolean) { state.el.style.transform = `translate(${x}px, ${y}px)`; if (!dispatch) return; - // synthetic mousemove keeps JS hover/move handlers live during replay; - // CSS :hover can't be triggered synthetically — that's a browser limitation + // synthetic JS hover events keep mouse handlers live during replay; CSS + // :hover needs trusted input — the background mirrors these positions to + // the Chrome debugger API (Input.dispatchMouseEvent) when hoverReplay is on const now = performance.now(); - if (now - state.lastDispatch < 30) return; - state.lastDispatch = now; const target = document.elementFromPoint(x, y); - if (target && target.id !== "ltt-cursor-host") { - target.dispatchEvent(new MouseEvent("mousemove", { clientX: x, clientY: y, bubbles: true })); + if (!target || target.id === "ltt-cursor-host") return; + const opts: MouseEventInit = { clientX: x, clientY: y, bubbles: true, cancelable: true, view: window }; + if (target !== state.hoverTarget) { + if (state.hoverTarget) { + state.hoverTarget.dispatchEvent(new MouseEvent("mouseout", { ...opts, relatedTarget: target })); + state.hoverTarget.dispatchEvent(new MouseEvent("mouseleave", { ...opts, relatedTarget: target, bubbles: false })); + } + target.dispatchEvent(new MouseEvent("mouseover", { ...opts, relatedTarget: state.hoverTarget })); + target.dispatchEvent(new MouseEvent("mouseenter", { ...opts, relatedTarget: state.hoverTarget, bubbles: false })); + state.hoverTarget = target; + } + if (now - state.lastDispatch >= 30) { + state.lastDispatch = now; + target.dispatchEvent(new MouseEvent("mousemove", opts)); + } + // mirror the position to the background at ~20 Hz for CSS :hover replay + if (now - state.lastCdp >= 50) { + state.lastCdp = now; + void browser.runtime.sendMessage({ type: "replay_hover_move", x, y }).catch(() => {}); } } @@ -214,7 +233,7 @@ 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() - fromT, lastDispatch: 0 }; + const state: CursorState = { el, raf: 0, track, startedAt: performance.now() - fromT, lastDispatch: 0, lastCdp: 0, hoverTarget: null }; cursor = state; const points = track.points; // resume mid-track (after a navigation the replay continues, not restarts) diff --git a/packages/extension/src/options/Options.vue b/packages/extension/src/options/Options.vue index 3990d14..ecb886e 100644 --- a/packages/extension/src/options/Options.vue +++ b/packages/extension/src/options/Options.vue @@ -1,7 +1,7 @@ @@ -133,6 +145,11 @@ icon="ph-monitor" type="url" /> +
Save server URL