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