diff --git a/packages/extension/e2e.mjs b/packages/extension/e2e.mjs index b271409..8c510fd 100644 --- a/packages/extension/e2e.mjs +++ b/packages/extension/e2e.mjs @@ -171,6 +171,11 @@ await wiggle(inputBox); await page.mouse.click(inputBox.x + 10, inputBox.y + inputBox.height / 2); await page.keyboard.type("Hello recorder"); + // console tap: the error must land as a "console" step of the recording + await page.evaluate(() => { + console.error("e2e console boom", { code: 42 }); + console.warn("e2e console warn"); + }); // 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`); @@ -187,8 +192,9 @@ const all = itemsAfter.items ?? itemsAfter; const recordingSummary = await api(`/api/reports/${all[0].share_token}`); const steps = recordingSummary.steps ?? []; - // the track is delivered as an animated GIF attachment instead of step screenshots + // the track is delivered as an animated GIF attachment (512×512 cursor view) const gifAttachment = (recordingSummary.attachments ?? []).find((a) => a.mime === "image/gif"); + let videoOk = false; if (gifAttachment) { const fileResponse = await fetch( `${SERVER}/api/reports/by-token/${all[0].share_token}/files/${gifAttachment.file_id}` @@ -196,7 +202,16 @@ const { writeFile } = await import("node:fs/promises"); const buf = Buffer.from(await fileResponse.arrayBuffer()); await writeFile("/tmp/ltt-shots/25-recording.gif", buf); - console.log("track gif:", JSON.stringify({ size: buf.length, isGif: buf.subarray(0, 3).toString() === "GIF" })); + // GIF logical screen size lives at offset 6 (little-endian); frames each + // carry a Graphic Control Extension (0x21 0xF9) + const isGif = buf.subarray(0, 3).toString() === "GIF"; + const gifWidth = buf.readUInt16LE(6); + let frameCount = 0; + for (let i = 0; i < buf.length - 1; i++) { + if (buf[i] === 0x21 && buf[i + 1] === 0xf9) frameCount++; + } + videoOk = isGif && gifWidth === 512 && frameCount >= 3; + console.log("track gif:", JSON.stringify({ size: buf.length, isGif, gifWidth, frameCount })); } console.log( "recording:", @@ -208,6 +223,7 @@ hasGif: Boolean(gifAttachment), }) ); + const consoleOk = steps.some((s) => s.type === "console" && String(s.data?.text ?? "").includes("e2e console boom")); const recorderOk = recordingSummary.type === "recording" && steps.some((s) => s.type === "click") && @@ -341,13 +357,15 @@ 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; + noteOk && recorderOk && resumedOk && startUrlOk && clipboardOk && popupOk && relayOk && replayOk && replayResultOk && cursorOk && mouseTrackOk && jsHoverOk && videoOk && consoleOk; 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(videoOk ? "screen video OK" : "screen video MISMATCH"); + console.log(consoleOk ? "console dump OK" : "console dump MISMATCH"); console.log(clipboardOk ? "clipboard link OK" : "clipboard link MISMATCH"); console.log(popupOk ? "popup OK" : "popup MISMATCH"); console.log(relayOk && replayResultOk ? "panel relay OK" : "panel relay MISMATCH"); diff --git a/packages/extension/manifest.template.json b/packages/extension/manifest.template.json index 15afb40..231f052 100644 --- a/packages/extension/manifest.template.json +++ b/packages/extension/manifest.template.json @@ -24,8 +24,14 @@ "content_scripts": [ { "matches": ["http://*/*", "https://*/*"], + "js": ["assets/console-tap.js"], + "run_at": "document_start", + "world": "MAIN" + }, + { + "matches": ["http://*/*", "https://*/*"], "js": ["assets/relay.js"], - "run_at": "document_idle" + "run_at": "document_start" } ], "web_accessible_resources": [ diff --git a/packages/extension/scripts/build.mjs b/packages/extension/scripts/build.mjs index 8ee82e9..dcfce19 100644 --- a/packages/extension/scripts/build.mjs +++ b/packages/extension/scripts/build.mjs @@ -22,7 +22,7 @@ // order matters: every pass writes assets/style.css (cssCodeSplit:false), so // the content pass — whose stylesheet is the union of all styles — runs last -const entries = ["background", "options", "popup", "relay", "content"]; +const entries = ["background", "options", "popup", "relay", "console-tap", "content"]; function run(entry) { return new Promise((resolve, reject) => { diff --git a/packages/extension/src/background/gif.ts b/packages/extension/src/background/gif.ts index c6dd685..0ae0b65 100644 --- a/packages/extension/src/background/gif.ts +++ b/packages/extension/src/background/gif.ts @@ -27,10 +27,7 @@ const context = canvas.getContext("2d"); if (!context) throw new Error("no 2d context"); context.drawImage(image, 0, 0, size.w, size.h); - const { data } = context.getImageData(0, 0, size.w, size.h); - const palette = quantize(data, 256, { format: "rgb444" }); - const index = applyPalette(data, palette, "rgb444"); - encoder.writeFrame(index, size.w, size.h, { palette, delay: Math.round(frame.delayMs) }); + await encodeFrame(encoder, context, size.w, size.h, frame.delayMs); } finally { image.close(); } @@ -38,4 +35,109 @@ encoder.finish(); return new Blob([encoder.bytes().buffer as ArrayBuffer], { type: "image/gif" }); +} + +async function encodeFrame( + encoder: ReturnType, + context: OffscreenCanvasRenderingContext2D, + width: number, + height: number, + delayMs: number +): Promise { + const { data } = context.getImageData(0, 0, width, height); + const palette = quantize(data, 256, { format: "rgb444" }); + const index = applyPalette(data, palette, "rgb444"); + encoder.writeFrame(index, width, height, { palette, delay: Math.round(delayMs) }); +} + +/** A screencast frame captured while recording (JPEG bytes + device-pixel size). */ +export interface VideoFrame { + /** client timestamp (Date.now()) of the frame's arrival */ + at: number; + jpeg: Uint8Array; + w: number; + h: number; +} + +export interface CursorGifOptions { + /** output square size in px */ + size: number; + /** CSS viewport the cursor track was recorded in */ + viewport: { w: number; h: number }; + /** recording start (Date.now()) — frame times convert to track offsets */ + startedAt: number; + /** track points: {t (ms from start), x, y} in CSS px */ + track: { t: number; x: number; y: number }[]; + /** hard cap on GIF frames — keeps the upload under the server limit */ + maxFrames?: number; +} + +/** + * Builds a square GIF that follows the cursor: each screencast frame is + * cropped to a `size`×`size` CSS-px square centered on the cursor position + * (interpolated from the mouse track at the frame's time) and paces like the + * recording. All output frames share the same dimensions; when the viewport + * is smaller than the square, the crop is centered on the smaller canvas. + */ +export async function buildCursorGif(frames: VideoFrame[], options: CursorGifOptions): Promise { + if (!frames.length) return null; + + // decimate long recordings instead of producing a huge GIF + let picked = frames; + while (picked.length > (options.maxFrames ?? 240)) picked = picked.filter((_, i) => i % 2 === 0); + + const size = options.size; + const encoder = GIFEncoder(); + const canvas = new OffscreenCanvas(size, size); + const context = canvas.getContext("2d"); + if (!context) throw new Error("no 2d context"); + + for (let position = 0; position < picked.length; position++) { + const frame = picked[position]; + const nextAt = position + 1 < picked.length ? picked[position + 1].at : null; + const delayMs = nextAt != null ? Math.min(Math.max(nextAt - frame.at, 66), 2000) : 500; + + const image = await createImageBitmap(new Blob([frame.jpeg.buffer as ArrayBuffer], { type: "image/jpeg" })); + try { + // device px per CSS px for this frame (fallback: frame pixels are CSS px) + const scale = options.viewport.w > 0 ? frame.w / options.viewport.w : 1; + if (!Number.isFinite(scale) || scale <= 0) throw new Error("bad frame scale"); + + const cursor = cursorAt(options.track, frame.at - options.startedAt, options.viewport); + // clamp the square so it stays inside the viewport + const crop = Math.min(size, options.viewport.w, options.viewport.h); + const half = crop / 2; + const cx = Math.min(Math.max(cursor.x, half), Math.max(options.viewport.w - half, half)); + const cy = Math.min(Math.max(cursor.y, half), Math.max(options.viewport.h - half, half)); + + const sx = Math.min(Math.max((cx - half) * scale, 0), Math.max(frame.w - crop * scale, 0)); + const sy = Math.min(Math.max((cy - half) * scale, 0), Math.max(frame.h - crop * scale, 0)); + const s = Math.min(crop * scale, frame.w - sx, frame.h - sy); + + context.fillStyle = "#10121c"; + context.fillRect(0, 0, size, size); + context.drawImage(image, sx, sy, s, s, 0, 0, size, size); + await encodeFrame(encoder, context, size, size, delayMs); + } finally { + image.close(); + } + } + + encoder.finish(); + return new Blob([encoder.bytes().buffer as ArrayBuffer], { type: "image/gif" }); +} + +/** Last known cursor position at track offset t (ms); falls back progressively. */ +function cursorAt( + track: { t: number; x: number; y: number }[], + t: number, + viewport: { w: number; h: number } +): { x: number; y: number } { + if (!track.length) return { x: viewport.w / 2, y: viewport.h / 2 }; + let point = track[0]; + for (const candidate of track) { + if (candidate.t > t) break; + point = candidate; + } + return point; } \ No newline at end of file diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index c4bb0a3..91580dc 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -3,7 +3,7 @@ import type { ReportDetail } from "@ltt/shared"; import { submitNoteReport, submitRecordingReport } from "./api"; import { getSettings, saveSettings, login, logout, getHttpClient, panelBase } from "./settings"; -import { buildGif } from "./gif"; +import { buildGif, buildCursorGif, type VideoFrame } from "./gif"; import type { BackgroundResponse, RecordEvent, @@ -33,6 +33,12 @@ lastUrl: string | null; /** sampled cursor positions: {at, x, y} with client timestamps */ moveTrack: { at: number; x: number; y: number }[]; + /** screencast frames captured while recording (bounded, decimated on overflow) */ + video: VideoFrame[]; + /** false once the debugger detaches (DevTools opened, tab navigated away) */ + videoActive: boolean; + lastVideoAt: number; + consoleCount: number; } const recorders = new Map(); @@ -50,6 +56,10 @@ screenshots: new Map(), lastUrl: null, moveTrack: [], + video: [], + videoActive: false, + lastVideoAt: 0, + consoleCount: 0, }; recorders.set(tabId, buffer); } @@ -103,10 +113,16 @@ buffer.events = []; buffer.screenshots = new Map(); buffer.moveTrack = []; + buffer.video = []; + buffer.videoActive = false; + buffer.lastVideoAt = 0; + buffer.consoleCount = 0; buffer.pageUrl = tab.url ?? null; buffer.pageTitle = tab.title ?? null; buffer.lastUrl = tab.url ?? null; buffer.environment = null; // content script supplies it with the first event + // continuous screen video (Chrome only; falls back to per-event captures) + buffer.videoActive = await startScreenVideo(tabId); return recorderStateOf(buffer); } @@ -137,6 +153,9 @@ const buffer = recorder(tabId); ensureRecordingFreshness(buffer); if (!buffer.recording) return recorderStateOf(buffer); + // the screencast must stop before anything else can race its frames + void stopScreenVideo(tabId); + buffer.videoActive = false; // collect the tail of the cursor track before anything else can race it const flushed = (await browser.tabs.sendMessage(tabId, { type: "recorder_flush_moves" }).catch(() => null)) as @@ -179,11 +198,34 @@ buffer.events = []; buffer.screenshots = new Map(); buffer.moveTrack = []; + buffer.video = []; return recorderStateOf(buffer); } -/** Uploads the recorded track as an animated GIF; returns its file id. */ +/** + * Uploads the recorded screen as an animated GIF. Prefers the continuous + * screencast frames (cropped to a square following the cursor); when the + * video isn't available (Firefox, debugger busy, setting off) falls back to + * the per-event screenshots. Returns the uploaded file id. + */ async function uploadTrackGif(buffer: RecorderBuffer): Promise { + try { + if (buffer.video.length) { + const viewport = (buffer.environment?.viewport as { w?: number; h?: number } | undefined) ?? null; + const track = buildMouseTrack(buffer)?.points ?? []; + const blob = await buildCursorGif(buffer.video, { + size: 512, + viewport: viewport?.w && viewport?.h ? { w: viewport.w, h: viewport.h } : { w: 1280, h: 720 }, + startedAt: buffer.startedAt ?? Date.now(), + track, + }); + if (blob) return await uploadGifBlob(blob); + debugLog("cursor gif failed: no frames encoded"); + } + } catch (error) { + debugLog(`cursor gif failed: ${error instanceof Error ? error.message : String(error)}`); + } + const indices = [...buffer.screenshots.keys()].sort((a, b) => a - b); const frames = indices .filter((index) => buffer.screenshots.has(index)) @@ -198,17 +240,21 @@ try { const blob = await buildGif(frames); if (!blob) return null; - const client = await getHttpClient(); - const file = new File([blob], "recording.gif", { type: "image/gif" }); - const uploaded = await client.upload<{ file_id: string }>("/api/uploads", file); - debugLog(`track gif uploaded: ${uploaded.file_id} (${frames.length} frames)`); - return uploaded.file_id; + return await uploadGifBlob(blob); } catch (error) { debugLog(`track gif failed: ${error instanceof Error ? error.message : String(error)}`); return null; } } +async function uploadGifBlob(blob: Blob): Promise { + const client = await getHttpClient(); + const file = new File([blob], "recording.gif", { type: "image/gif" }); + const uploaded = await client.upload<{ file_id: string }>("/api/uploads", file); + debugLog(`track gif uploaded: ${uploaded.file_id}`); + return uploaded.file_id; +} + // ---------- content-script injection ---------- async function ensureContentScript(tabId: number): Promise { @@ -260,6 +306,16 @@ attach(target: { tabId: number }, version: string): Promise; detach(target: { tabId: number }): Promise; sendCommand(target: { tabId: number }, method: string, params?: Record): Promise; + onEvent: { + addListener(callback: (source: { tabId: number }, method: string, params?: Record) => void): void; + }; + onDetach: { + addListener(callback: (source: { tabId: number }) => void): void; + }; +} + +function debuggerApi(): DebuggerApiLike | null { + return (browser as unknown as { debugger?: DebuggerApiLike }).debugger ?? null; } // tabs with the debugger attached for CSS :hover replay during a running replay @@ -276,7 +332,7 @@ async function attachHoverDebugger(tabId: number): Promise { const settings = await getSettings(); if (!settings.hoverReplay) return; - const api = (browser as unknown as { debugger?: DebuggerApiLike }).debugger; + const api = debuggerApi(); if (!api) return; // Firefox has no debugger API — JS-level hover still works try { await api.attach({ tabId }, "1.3"); @@ -287,15 +343,102 @@ } } -async function detachHoverDebugger(tabId: number): Promise { - if (!hoverDebuggerTabs.delete(tabId)) return; - const api = (browser as unknown as { debugger?: DebuggerApiLike }).debugger; +// ---------- screen video (CDP screencast while recording) ---------- + +/** tabs with a recording screencast attached */ +const screencastTabs = new Set(); + +/** ~15 fps of 512px squares; bounded memory, decimated when overflowing */ +const VIDEO_MIN_INTERVAL_MS = 66; +const MAX_VIDEO_FRAMES = 600; +/** screencast JPEGs are downscaled by CDP; the crop needs some headroom */ +const VIDEO_MAX_DIMENSION = 800; + +/** + * Starts a CDP screencast on the tab so the recording gets continuous frames + * (captureVisibleTab is quota-limited to ~2/sec — nowhere near video). Chrome + * shows its "started debugging" infobar; the user can turn this off via the + * videoCapture setting. Best effort: a busy debugger just disables the video. + */ +async function startScreenVideo(tabId: number): Promise { + const settings = await getSettings(); + if (!settings.videoCapture) return false; + const api = debuggerApi(); + if (!api) return false; // Firefox: per-event screenshots still feed the GIF + try { + await api.attach({ tabId }, "1.3"); + await api.sendCommand({ tabId }, "Page.startScreencast", { + format: "jpeg", + quality: 55, + maxWidth: VIDEO_MAX_DIMENSION, + maxHeight: VIDEO_MAX_DIMENSION, + everyNthFrame: 1, + }); + screencastTabs.add(tabId); + debugLog("recording screencast started"); + return true; + } catch (error) { + debugLog(`recording screencast failed: ${error instanceof Error ? error.message : String(error)}`); + return false; + } +} + +async function stopScreenVideo(tabId: number): Promise { + if (!screencastTabs.delete(tabId)) return; + const api = debuggerApi(); + await api?.sendCommand({ tabId }, "Page.stopScreencast").catch(() => {}); await api?.detach({ tabId }).catch(() => {}); } +/** base64 (screencast wire format) → bytes, so frames take half the memory */ +function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +/** Global debugger event listener: screencast frames land in the tab's buffer. */ +function onDebuggerEvent(source: { tabId: number }, method: string, params?: Record): void { + if (method !== "Page.screencastFrame") return; + const api = debuggerApi(); + const sessionId = (params?.sessionId as number | undefined) ?? null; + // every frame must be acked or Chrome throttles the screencast to a crawl + if (api && sessionId != null) void api.sendCommand({ tabId: source.tabId }, "Page.screencastFrameAck", { sessionId }).catch(() => {}); + const buffer = recorders.get(source.tabId); + if (!buffer?.recording || !buffer.videoActive) return; + const data = params?.data as string | undefined; + const meta = params?.metadata as { deviceWidth?: number; deviceHeight?: number } | undefined; + if (!data || !meta?.deviceWidth || !meta.deviceHeight) return; + const now = Date.now(); + if (now - buffer.lastVideoAt < VIDEO_MIN_INTERVAL_MS) return; // hold ~15 fps + buffer.lastVideoAt = now; + buffer.video.push({ at: now, jpeg: base64ToBytes(data), w: meta.deviceWidth, h: meta.deviceHeight }); + // decimate in place instead of growing without bound + while (buffer.video.length > MAX_VIDEO_FRAMES) buffer.video = buffer.video.filter((_, i) => i % 2 === 0); +} + +/** The debugger left (DevTools opened, user clicked "cancel") — no more frames. */ +function onDebuggerDetach(source: { tabId: number }): void { + screencastTabs.delete(source.tabId); + const buffer = recorders.get(source.tabId); + if (buffer?.videoActive) { + buffer.videoActive = false; + debugLog(`screencast detached from tab ${source.tabId}`); + } +} + +const debugApi = debuggerApi(); +debugApi?.onEvent.addListener(onDebuggerEvent); +debugApi?.onDetach.addListener(onDebuggerDetach); + +async function detachHoverDebugger(tabId: number): Promise { + if (!hoverDebuggerTabs.delete(tabId)) return; + await debuggerApi()?.detach({ tabId }).catch(() => {}); +} + function dispatchHoverMove(tabId: number, x: number, y: number): void { - const api = (browser as unknown as { debugger?: DebuggerApiLike }).debugger; - void api + void debuggerApi() ?.sendCommand({ tabId }, "Input.dispatchMouseEvent", { type: "mouseMoved", x: Math.round(x), @@ -416,8 +559,10 @@ } else { failures.push({ index: stepIndex, type: step.type, reason: "Element not found on the current page" }); } + } else { + // note/screenshot/console steps have no page effect — still count as played + played++; } - // 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(() => {}); @@ -484,6 +629,7 @@ browser.tabs.onRemoved.addListener((tabId) => { OVERLAY_ACTIVE.delete(tabId); recorders.delete(tabId); + void stopScreenVideo(tabId); }); // debug/test hook: lets the e2e harness (and DevTools) trigger the action @@ -521,7 +667,7 @@ data: { from_url: buffer.lastUrl, to_url: tab.url, trigger: "tab_updated" }, }) - 1; buffer.lastUrl = tab.url; - void captureForEvent(tabId, eventIndex); + if (!buffer.videoActive) 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 @@ -629,13 +775,33 @@ ensureRecordingFreshness(buffer); const { event } = msg as unknown as { event: RecordEvent }; buffer.events.push(event); - // a frame per event feeds the track GIF (bounded to avoid runaway memory) - if (buffer.events.length <= 60) { + // a frame per event feeds the fallback GIF when the screencast is off + // (bounded to avoid runaway memory); the video path doesn't need them + if (!buffer.videoActive && buffer.events.length <= 60) { await captureForEvent(tabId!, buffer.events.length - 1); } return { ok: true, data: { stepCount: buffer.events.length } }; } + case "recorder_console": { + // console dump relayed from the MAIN-world tap — becomes timeline steps + const buffer = recorder(tabId!); + if (!buffer.recording) return { ok: true, data: { recording: false } }; + const settings = await getSettings(); + if (!settings.consoleCapture) return { ok: true, data: { recording: false } }; + if (buffer.consoleCount >= 200) return { ok: true }; + buffer.consoleCount++; + buffer.events.push({ + type: "console", + at: typeof msg.at === "number" ? msg.at : Date.now(), + data: { + level: msg.level === "warning" ? "warning" : "error", + text: String(msg.text ?? "").slice(0, 2000), + }, + }); + return { ok: true }; + } + case "recorder_environment": { const buffer = recorder(tabId!); buffer.environment = msg.environment as Record; diff --git a/packages/extension/src/background/settings.ts b/packages/extension/src/background/settings.ts index edb495c..6244d90 100644 --- a/packages/extension/src/background/settings.ts +++ b/packages/extension/src/background/settings.ts @@ -12,6 +12,10 @@ user: { id: string; nickname: string; email: string } | null; /** replay CSS :hover through the Chrome debugger API (shows an infobar) */ hoverReplay: boolean; + /** record the screen around the cursor via the debugger API during recordings (shows an infobar) */ + videoCapture: boolean; + /** attach console.error/warn and uncaught exceptions to recordings */ + consoleCapture: boolean; } const DEFAULTS: Settings = { @@ -23,6 +27,8 @@ defaultProjectId: null, user: null, hoverReplay: true, + videoCapture: true, + consoleCapture: true, }; export async function getSettings(): Promise { diff --git a/packages/extension/src/console-tap.ts b/packages/extension/src/console-tap.ts new file mode 100644 index 0000000..7de6b43 --- /dev/null +++ b/packages/extension/src/console-tap.ts @@ -0,0 +1,69 @@ +/** + * MAIN-world tap present on every http(s) page from document_start, before + * any page script runs: wraps console.error/warn and global error handlers + * so the recorder can attach a console dump to the report. + * + * It must live in the MAIN world — the page calls *its own* console object, + * which the isolated content-script world never sees. And since MAIN-world + * scripts have no extension messaging, everything goes out through + * window.postMessage to the relay content script. + */ +interface BugtrailWindow { + __bugtrailTap?: boolean; +} + +const w = window as unknown as BugtrailWindow; +// the page could re-inject us (SPA frameworks re-running scripts) — wrap once +if (!w.__bugtrailTap) { + w.__bugtrailTap = true; + + const MAX_TEXT = 2000; + + /** Formats a console argument; errors keep their message, objects go JSON. */ + function format(arg: unknown, depth = 0): string { + if (arg == null) return String(arg); + if (typeof arg === "string") return arg; + if (arg instanceof Error) return arg.stack ?? `${arg.name}: ${arg.message}`; + if (depth === 0 && (typeof arg === "object" || typeof arg === "function")) { + try { + return JSON.stringify(arg, (_key, value) => (typeof value === "bigint" ? String(value) : value)) ?? String(arg); + } catch { + // circular or throwing toJSON — fall back to the string form + return String(arg); + } + } + return String(arg); + } + + function post(level: "error" | "warning", text: string) { + try { + window.postMessage({ source: "bugtrail-tap", type: "console", level, text: text.slice(0, MAX_TEXT), at: Date.now() }, window.location.origin); + } catch { + // structured clone can refuse exotic values — never break the page + } + } + + const wrap = (method: "error" | "warn", level: "error" | "warning") => { + const original = console[method].bind(console); + console[method] = (...args: unknown[]) => { + try { + post(level, args.map((arg) => format(arg)).join(" ")); + } catch { + // never break the page's own logging + } + original(...args); + }; + }; + wrap("error", "error"); + wrap("warn", "warning"); + + window.addEventListener("error", (event) => { + // resources (img/script) failing also fire "error" — only real JS errors carry a message + if (event instanceof ErrorEvent && event.message) { + post("error", `Uncaught ${event.message} (${event.filename}:${event.lineno})`); + } + }); + window.addEventListener("unhandledrejection", (event) => { + post("error", `Unhandled rejection: ${format((event as PromiseRejectionEvent).reason)}`); + }); +} \ No newline at end of file diff --git a/packages/extension/src/options/Options.vue b/packages/extension/src/options/Options.vue index ecb886e..9bf115e 100644 --- a/packages/extension/src/options/Options.vue +++ b/packages/extension/src/options/Options.vue @@ -11,6 +11,8 @@ user: { id: string; nickname: string; email: string } | null; defaultProjectId: string | null; hoverReplay: boolean; + videoCapture: boolean; + consoleCapture: boolean; } interface ProjectView { @@ -18,7 +20,7 @@ name: string; } -const settings = ref({ serverUrl: "http://localhost:8001", panelUrl: null, token: null, user: null, defaultProjectId: null, hoverReplay: true }); +const settings = ref({ serverUrl: "http://localhost:8001", panelUrl: null, token: null, user: null, defaultProjectId: null, hoverReplay: true, videoCapture: true, consoleCapture: true }); const projects = ref([]); const email = ref(""); const password = ref(""); @@ -113,11 +115,11 @@ } /** Toggled in place — persists immediately, no save button. */ -async function saveHoverReplay(value: boolean) { - settings.value = { ...settings.value, hoverReplay: value }; +async function saveToggle(name: "hoverReplay" | "videoCapture" | "consoleCapture", value: boolean) { + settings.value = { ...settings.value, [name]: value }; try { - await sendMessage({ type: "settings_save", patch: { hoverReplay: value } }); - status.value = value ? "CSS :hover replay enabled" : "CSS :hover replay disabled"; + await sendMessage({ type: "settings_save", patch: { [name]: value } }); + status.value = "Setting saved"; } catch (err) { error.value = err instanceof Error ? err.message : String(err); } @@ -146,9 +148,19 @@ type="url" /> + +
diff --git a/packages/extension/src/relay.ts b/packages/extension/src/relay.ts index bcfd8c6..f87edbb 100644 --- a/packages/extension/src/relay.ts +++ b/packages/extension/src/relay.ts @@ -1,20 +1,34 @@ /** - * Tiny relay content script present on every http(s) page. Its only job is to - * let the BugTrail web panel talk to the extension through window messages — - * the panel can't use runtime.sendMessage directly (it would need the - * extension id in externally_connectable, which differs per installation). + * Tiny relay content script present on every http(s) page. Two jobs: + * - let the BugTrail web panel talk to the extension through window messages + * (the panel can't use runtime.sendMessage directly — it would need the + * extension id in externally_connectable, which differs per installation); + * - forward the MAIN-world console tap's events into the extension (the tap + * itself has no access to extension messaging). * The overlay is still injected on demand; this relay holds no UI. */ import browser from "webextension-polyfill"; const PANEL_SOURCE = "bugtrail-panel"; const EXT_SOURCE = "bugtrail-ext"; +const TAP_SOURCE = "bugtrail-tap"; window.addEventListener("message", (event) => { // only accept messages from this same window (the page itself) if (event.source !== window) return; - const data = event.data as { source?: string; type?: string; token?: string } | null; - if (!data || data.source !== PANEL_SOURCE) return; + const data = event.data as { source?: string; type?: string; token?: string; level?: string; text?: string; at?: number } | null; + if (!data) return; + + // console tap (MAIN world) → background recorder buffer; the background + // drops the event when no recording is running on this tab + if (data.source === TAP_SOURCE && data.type === "console" && typeof data.text === "string") { + void browser.runtime + .sendMessage({ type: "recorder_console", level: data.level === "warning" ? "warning" : "error", text: data.text, at: data.at }) + .catch(() => {}); + return; + } + + if (data.source !== PANEL_SOURCE) return; if (data.type === "ping") { window.postMessage({ source: EXT_SOURCE, type: "pong", ok: true }, window.location.origin); diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 049ffa9..174c37b 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -9,7 +9,7 @@ // One build pass per entry (LTT_ENTRY), driven by scripts/build.mjs: // Vite/Rollup cannot emit IIFE for a code-splitting (multi-entry) build, and // content scripts / MV3 service workers must be single self-contained files. -const entryNames = ["background", "content", "options", "popup", "relay"] as const; +const entryNames = ["background", "content", "options", "popup", "relay", "console-tap"] as const; type EntryName = (typeof entryNames)[number]; const entryPaths: Record = { @@ -18,6 +18,7 @@ options: resolve(__dirname, "src/options/options.html"), popup: resolve(__dirname, "src/popup/popup.html"), relay: resolve(__dirname, "src/relay.ts"), + "console-tap": resolve(__dirname, "src/console-tap.ts"), }; // Build targets: --mode chrome | --mode firefox diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 85785b9..dc1f282 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -57,7 +57,7 @@ export interface RecordingStep { id: string; step_index: number; - type: "click" | "input" | "url_change" | "navigation" | "note" | "screenshot"; + type: "click" | "input" | "url_change" | "navigation" | "note" | "screenshot" | "console"; offset_ms: number; data: Record; screenshot_attachment_id: string | null; diff --git a/packages/web/src/i18n/en.json b/packages/web/src/i18n/en.json index fcc4baf..652f497 100644 --- a/packages/web/src/i18n/en.json +++ b/packages/web/src/i18n/en.json @@ -103,7 +103,8 @@ "url_change": "URL change", "navigation": "Navigation", "note": "Note", - "screenshot": "Screenshot" + "screenshot": "Screenshot", + "console": "Console error" }, "replay": "Start replay", "replayNoExtension": "BugTrail extension not found in this browser — install it and reload the panel", diff --git a/packages/web/src/i18n/ru.json b/packages/web/src/i18n/ru.json index cd9139c..c251aa9 100644 --- a/packages/web/src/i18n/ru.json +++ b/packages/web/src/i18n/ru.json @@ -103,7 +103,8 @@ "url_change": "Смена URL", "navigation": "Переход", "note": "Заметка", - "screenshot": "Скриншот" + "screenshot": "Скриншот", + "console": "Ошибка консоли" }, "replay": "Начать реплей", "replayNoExtension": "Расширение BugTrail не найдено в этом браузере — установите его и обновите страницу", diff --git a/packages/web/src/pages/ReportPage.vue b/packages/web/src/pages/ReportPage.vue index d877be7..2184bec 100644 --- a/packages/web/src/pages/ReportPage.vue +++ b/packages/web/src/pages/ReportPage.vue @@ -392,6 +392,7 @@ : step.type === 'url_change' ? 'ph-arrows-left-right' : step.type === 'note' ? 'ph-note-pencil' : step.type === 'screenshot' ? 'ph-camera' + : step.type === 'console' ? 'ph-warning' : 'ph-navigation-arrow', })) " diff --git a/server/app/schemas.py b/server/app/schemas.py index b947dec..39e74fc 100644 --- a/server/app/schemas.py +++ b/server/app/schemas.py @@ -106,7 +106,7 @@ class StepIn(BaseModel): - type: str = Field(pattern="^(click|input|url_change|navigation|note|screenshot)$") + type: str = Field(pattern="^(click|input|url_change|navigation|note|screenshot|console)$") offset_ms: int = Field(ge=0) data: dict = {} attachment_id: uuid.UUID | None = None # uploaded screenshot to link to this step