diff --git a/packages/extension/e2e.mjs b/packages/extension/e2e.mjs index 077024e..6f7d373 100644 --- a/packages/extension/e2e.mjs +++ b/packages/extension/e2e.mjs @@ -86,6 +86,7 @@ // --- target page: pick an element and submit a note --- const page = await context.newPage(); + await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: `http://localhost:${PAGE_PORT}` }); page.on("console", (msg) => { if (msg.type() === "error") console.log("[page error]", msg.text()); }); @@ -130,6 +131,15 @@ report.element?.selector?.includes("buggy-button") && report.attachments?.length > 0; + // the share link must land in the clipboard automatically after submit + let clipboardOk = false; + try { + const clipboard = await page.evaluate(() => navigator.clipboard.readText()); + clipboardOk = clipboard === `${SERVER}/r/${items[0].share_token}`; + } catch (error) { + console.log("[clipboard read failed]", String(error).slice(0, 120)); + } + // --- recorder: start, click the button, type, stop → recording report --- await worker.evaluate(() => self.__lttToggleRecorder()); await page.waitForTimeout(800); @@ -185,10 +195,31 @@ steps.some((s) => s.type === "input" && s.data?.value === "Hello recorder") && steps.some((s) => s.screenshot_attachment_id != null); - const ok = noteOk && recorderOk; + // --- popup: mode buttons, latest report, delete from the popup --- + const popup = await context.newPage(); + await popup.goto(`chrome-extension://${extensionId}/src/popup/popup.html`); + await popup.waitForSelector("text=Element note", { timeout: 10000 }); + const hasRecord = await popup.locator("text=Record steps").count(); + const latestTitle = await popup.locator(".popup-report-title").textContent(); + const latestOk = Boolean(latestTitle && latestTitle.length > 0); + // two-step delete: arm then confirm + await popup.click(".popup-mini-danger"); + await popup.click(".popup-mini-danger"); + await popup.waitForTimeout(1500); + await popup.close(); + const itemsAfterDelete = (await api(`/api/projects/${project.id}/reports`, { + headers: { Authorization: `Bearer ${login.token}` }, + })); + const deleteOk = (itemsAfterDelete.items ?? itemsAfterDelete).length === all.length - 1; + const popupOk = hasRecord > 0 && latestOk && deleteOk; + console.log("popup:", JSON.stringify({ hasRecord: hasRecord > 0, latestOk, deleteOk })); + + const ok = noteOk && recorderOk && clipboardOk && popupOk; 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(clipboardOk ? "clipboard link OK" : "clipboard link MISMATCH"); + console.log(popupOk ? "popup OK" : "popup MISMATCH"); 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 38eba83..ac24029 100644 --- a/packages/extension/manifest.template.json +++ b/packages/extension/manifest.template.json @@ -6,7 +6,8 @@ "permissions": ["activeTab", "scripting", "storage", "tabs"], "host_permissions": [""], "action": { - "default_title": "Capture a bug report" + "default_title": "Live Testing Tool", + "default_popup": "src/popup/popup.html" }, "web_accessible_resources": [ { diff --git a/packages/extension/scripts/build.mjs b/packages/extension/scripts/build.mjs index a15275b..1973b70 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", "content"]; +const entries = ["background", "options", "popup", "content"]; function run(entry) { return new Promise((resolve, reject) => { diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index f6f12c0..d9dd059 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -1,7 +1,7 @@ import browser from "webextension-polyfill"; import type { Runtime } from "webextension-polyfill"; import { submitNoteReport, submitRecordingReport } from "./api"; -import { getSettings, saveSettings, login, logout, getHttpClient } from "./settings"; +import { getSettings, saveSettings, login, logout, getHttpClient, panelBase } from "./settings"; import type { BackgroundResponse, RecordEvent, @@ -209,6 +209,32 @@ return state; } +async function toggleRecorder(tabId: number): Promise { + const buffer = recorder(tabId); + if (buffer.recording) { + const state = await recorderStop(tabId); + await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: false }).catch(() => {}); + return state; + } + return startRecorder(tabId); +} + +/** Latest report of the default project + its project share token (for the panel link). */ +async function latestReport(): Promise<{ + report: { title: string; type: string; status: string; created_at: string; share_token: string } | null; + project_token: string | null; +} | null> { + const settings = await getSettings(); + if (!settings.token || !settings.defaultProjectId) return null; + const client = await getHttpClient(); + const projects = await client.get<{ id: string; share_token: string }[]>("/api/projects"); + const project = projects.find((p) => p.id === settings.defaultProjectId); + const list = await client.get<{ items: { title: string; type: string; status: string; created_at: string; share_token: string }[] }>( + `/api/projects/${settings.defaultProjectId}/reports?limit=1` + ); + return { report: list.items[0] ?? null, project_token: project?.share_token ?? null }; +} + // ---------- wiring ---------- browser.action.onClicked.addListener(async (tab) => { @@ -293,6 +319,49 @@ case "ping": return { ok: true, data: "pong" }; + // popup actions: the popup has no tab context, resolve the active tab here + case "start_picker": { + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + if (tab?.id == null) return { ok: false, error: "No active tab" }; + await startPicker(tab.id).catch((error) => console.error("[ltt] failed to start picker:", error)); + return { ok: true }; + } + + case "toggle_recorder": { + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + if (tab?.id == null) return { ok: false, error: "No active tab" }; + try { + const state = await toggleRecorder(tab.id); + return { ok: true, data: state }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + case "latest_report": { + try { + return { ok: true, data: await latestReport() }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + case "delete_report": { + try { + const client = await getHttpClient(); + await client.del(`/api/reports/${msg.token as string}`); + return { ok: true }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + case "open_panel": { + const settings = await getSettings(); + await browser.tabs.create({ url: panelBase(settings) }); + 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 3390ed1..96facd9 100644 --- a/packages/extension/src/background/settings.ts +++ b/packages/extension/src/background/settings.ts @@ -4,6 +4,8 @@ /** Server settings persisted in storage.local. */ export interface Settings { serverUrl: string; + /** web panel base URL; falls back to serverUrl when unset (same origin in prod) */ + panelUrl: string | null; token: string | null; tokenExpiresAt: string | null; defaultProjectId: string | null; @@ -12,6 +14,7 @@ const DEFAULTS: Settings = { serverUrl: "http://localhost:8001", + panelUrl: null, token: null, tokenExpiresAt: null, defaultProjectId: null, @@ -70,4 +73,9 @@ export async function getHttpClient(): Promise { const settings = await getSettings(); return createHttpClient({ baseUrl: settings.serverUrl, token: settings.token }); +} + +/** Base URL of the web panel for share links and the "open panel" shortcut. */ +export function panelBase(settings: Settings): string { + return (settings.panelUrl ?? settings.serverUrl).replace(/\/+$/, ""); } \ No newline at end of file diff --git a/packages/extension/src/content/overlay/App.vue b/packages/extension/src/content/overlay/App.vue index 5332acc..2be4f68 100644 --- a/packages/extension/src/content/overlay/App.vue +++ b/packages/extension/src/content/overlay/App.vue @@ -14,6 +14,9 @@ /** screenshot chosen in the region selector, passed to the composer */ const screenshotDataUrl = ref(null); const composerOpen = ref(false); +/** brief confirmation after the report link was copied */ +const linkCopied = ref(false); +let linkCopiedTimer: number | null = null; function onPick(element: ElementContext) { pickedElement.value = element; @@ -24,6 +27,44 @@ composerOpen.value = true; } +async function onSubmitted(reportToken: string) { + closeComposer(); + await copyReportLink(reportToken); +} + +/** + * Share links are copied automatically so they can go straight into the + * bug tracker. The page is usually focused (the user just clicked around), + * but a hidden textarea fallback covers the unfocused case. + */ +async function copyReportLink(reportToken: string) { + try { + const settings = (await browser.runtime.sendMessage({ type: "settings_get" })) as { + ok: boolean; + data?: { serverUrl: string; panelUrl: string | null }; + }; + const base = (settings?.data?.panelUrl ?? settings?.data?.serverUrl ?? "").replace(/\/+$/, ""); + const url = `${base}/r/${reportToken}`; + try { + await navigator.clipboard.writeText(url); + } catch { + const textarea = document.createElement("textarea"); + textarea.value = url; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); + } + linkCopied.value = true; + if (linkCopiedTimer) window.clearTimeout(linkCopiedTimer); + linkCopiedTimer = window.setTimeout(() => (linkCopied.value = false), 2500); + } catch (error) { + console.error("[ltt] failed to copy report link:", error); + } +} + function onRegionCancelled() { pickedElement.value = null; // keep the picker alive while recording so another element can be picked @@ -73,7 +114,7 @@ :element="pickedElement" :screenshot-data-url="screenshotDataUrl" @close="closeComposer" - @submitted="closeComposer" + @submitted="onSubmitted" /> + +
+ Link copied to clipboard +
- \ No newline at end of file + + + \ No newline at end of file diff --git a/packages/extension/src/options/Options.vue b/packages/extension/src/options/Options.vue index 6fade5f..de7cbd9 100644 --- a/packages/extension/src/options/Options.vue +++ b/packages/extension/src/options/Options.vue @@ -5,6 +5,7 @@ interface SettingsView { serverUrl: string; + panelUrl: string | null; token: string | null; user: { id: string; nickname: string; email: string } | null; defaultProjectId: string | null; @@ -15,7 +16,7 @@ name: string; } -const settings = ref({ serverUrl: "http://localhost:8001", token: null, user: null, defaultProjectId: null }); +const settings = ref({ serverUrl: "http://localhost:8001", panelUrl: null, token: null, user: null, defaultProjectId: null }); const projects = ref([]); const email = ref(""); const password = ref(""); @@ -24,6 +25,12 @@ const busy = ref(false); /** GnSelect is a native