diff --git a/packages/extension/e2e.mjs b/packages/extension/e2e.mjs index 7a0f162..eff44f1 100644 --- a/packages/extension/e2e.mjs +++ b/packages/extension/e2e.mjs @@ -191,14 +191,26 @@ await page.mouse.click(buttonBox.x + box.width / 2, buttonBox.y + buttonBox.height / 2); await page.waitForTimeout(600); // let the input debounce flush await worker.evaluate(() => self.__lttToggleRecorder()); - // stopping no longer submits right away: the recording goes "pending" and - // the composer overlay opens in the tab (title input focused + selected) - await page.waitForTimeout(2000); + // the report is submitted right at stop (auto-title); the composer opens + // once it exists and edits it by token — wait for the report to land first + const waitForNewReport = async (knownCount) => { + for (let i = 0; i < 40; i++) { + const listing = await api(`/api/projects/${project.id}/reports`, { + headers: { Authorization: `Bearer ${login.token}` }, + }); + const found = listing.items ?? listing; + if (found.length > knownCount) return found; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error("recording report did not appear after stop"); + }; + await waitForNewReport(1); // the note from the first flow + await page.waitForTimeout(1200); // composer mounts right after the report await page.keyboard.type("E2E recorded bug"); // replaces the selected prefill await page.keyboard.press("Tab"); // title → description textarea await page.keyboard.type("Recorded via e2e composer."); await page.keyboard.press("Control+Enter"); // composer save shortcut - await page.waitForTimeout(8000); // media upload (awaited by finalize) + submit + await page.waitForTimeout(3000); // title/description patch request const itemsAfter = (await api(`/api/projects/${project.id}/reports`, { headers: { Authorization: `Bearer ${login.token}` }, @@ -308,8 +320,10 @@ console.log("composer:", JSON.stringify({ title: recordingSummary.title, description: recordingSummary.description })); // --- discard flow: a short second recording dropped in the composer --- - // keyboard path through the closed shadow root: title input is focused on - // mount, so Tab×2 reaches the two-step Discard button, Enter×2 confirms + // the report exists from the moment of the stop; Discard deletes it, so + // the count returns to where it was. Keyboard path through the closed + // shadow root: title input is focused on mount, so Tab×2 reaches the + // two-step Discard button, Enter×2 confirms const beforeDiscard = all.length; await page.bringToFront(); await worker.evaluate(() => self.__lttToggleRecorder()); @@ -318,7 +332,8 @@ await page.mouse.click(headingBox.x + 30, headingBox.y + 10); // one event so the buffer is non-empty await page.waitForTimeout(400); await worker.evaluate(() => self.__lttToggleRecorder()); - await page.waitForTimeout(2000); // composer mounts, title focused + await waitForNewReport(beforeDiscard); // report submitted at stop + await page.waitForTimeout(1200); // composer mounts await page.keyboard.press("Tab"); // title → description await page.keyboard.press("Tab"); // description → Discard button await page.keyboard.press("Enter"); // arm the two-step discard diff --git a/packages/extension/src/background/api.ts b/packages/extension/src/background/api.ts index 229e0b8..00b512c 100644 --- a/packages/extension/src/background/api.ts +++ b/packages/extension/src/background/api.ts @@ -66,4 +66,14 @@ project_id: settings.defaultProjectId, }); return { report_token: report.share_token }; +} + +/** Composer Save: the report already exists — patch its title/description. */ +export async function updateRecordingReport(token: string, title: string, description: string): Promise { + const client = await getHttpClient(); + // empty fields are dropped so the server keeps the auto-title / no description + return client.patch(`/api/reports/${token}`, { + ...(title.trim() ? { title: title.trim() } : {}), + ...(description.trim() ? { description: description.trim() } : {}), + }); } \ No newline at end of file diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index be14248..53b6d56 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 type { ReportDetail } from "@ltt/shared"; -import { submitNoteReport, submitRecordingReport } from "./api"; +import { submitNoteReport, submitRecordingReport, updateRecordingReport } from "./api"; import { getSettings, saveSettings, login, logout, getHttpClient, panelBase } from "./settings"; import { buildGif, buildCursorGif, type VideoFrame } from "./gif"; import type { @@ -39,14 +39,6 @@ videoActive: boolean; lastVideoAt: number; consoleCount: number; - /** stopped but not yet saved/discarded — the composer is open */ - pending: boolean; - /** media file id once the parallel WebM/GIF upload finishes */ - preparedAttachmentId: string | null; - /** in-flight media upload, awaited at submit time if not settled yet */ - mediaPromise: Promise | null; - /** auto-save timer for a pending recording the user never answered */ - pendingTimer: ReturnType | null; } const recorders = new Map(); @@ -68,10 +60,6 @@ videoActive: false, lastVideoAt: 0, consoleCount: 0, - pending: false, - preparedAttachmentId: null, - mediaPromise: null, - pendingTimer: null, }; recorders.set(tabId, buffer); } @@ -122,65 +110,48 @@ recording: buffer.recording, stepCount: buffer.events.length, startedAt: buffer.startedAt, - pending: buffer.pending, + pending: false, + report_token: null, }; } function clearRecorderBuffer(buffer: RecorderBuffer): void { - if (buffer.pendingTimer) clearTimeout(buffer.pendingTimer); buffer.recording = false; - buffer.pending = false; buffer.startedAt = null; buffer.events = []; buffer.screenshots = new Map(); buffer.moveTrack = []; buffer.video = []; - buffer.preparedAttachmentId = null; - buffer.mediaPromise = null; - buffer.pendingTimer = null; } -/** pending recordings the user never answered are auto-saved (data safety) */ -const PENDING_TIMEOUT_MS = 10 * 60 * 1000; - function autoTitle(buffer: RecorderBuffer): string { return `Recording ${new Date().toLocaleString()} — ${buffer.pageTitle ?? "page"}`; } /** - * Submits the pending recording (composer Save, or the auto-save timeout). - * The media upload usually finished while the composer was open; if it is - * still in flight it is awaited, and a failed upload still submits the steps. + * Uploads the media and submits the recording report. Returns the report's + * share token, or null when there was nothing to submit (or the submit + * failed — the steps are lost then, same as before the composer existed). */ -async function finalizePendingRecording( - buffer: RecorderBuffer, - tabId: number, - title?: string, - description?: string -): Promise<{ report_token: string } | null> { - const hadEvents = buffer.events.length > 0; - if (!hadEvents) { - clearRecorderBuffer(buffer); +async function submitRecordingBuffer(buffer: RecorderBuffer): Promise { + if (!buffer.events.length) return null; + const startedAt = buffer.startedAt ?? Date.now(); + const attachmentId = await uploadTrackGif(buffer, startedAt).catch((error) => { + debugLog(`recording media failed: ${error instanceof Error ? error.message : String(error)}`); return null; - } - let attachmentId = buffer.preparedAttachmentId; - if (!attachmentId && buffer.mediaPromise) { - attachmentId = await buffer.mediaPromise.catch(() => null); - } - const settings = await getSettings(); + }); const steps = buffer.events.map((event) => ({ type: event.type, offset_ms: buffer.startedAt ? Math.max(event.at - buffer.startedAt, 0) : 0, data: event.data, attachment_id: null, })); - let result: { report_token: string } | null = null; try { - result = await submitRecordingReport({ + const result = await submitRecordingReport({ payload: { type: "recording", - title: title?.trim() || autoTitle(buffer), - description: description?.trim() || null, + title: autoTitle(buffer), + description: null, page_url: buffer.pageUrl, page_title: buffer.pageTitle, environment: buffer.environment ?? {}, @@ -188,40 +159,19 @@ attachment_ids: attachmentId ? [attachmentId] : [], mouse_track: buildMouseTrack(buffer), }, - settings, }); debugLog(`recording submitted: ${JSON.stringify(result)}`); + return result.report_token; } catch (error) { debugLog(`recording submit failed: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } finally { - clearRecorderBuffer(buffer); - void notifyPendingCleared(tabId); + return null; } - return result; -} - -function discardPendingRecording(buffer: RecorderBuffer, tabId: number): void { - debugLog("recording discarded"); - clearRecorderBuffer(buffer); - void notifyPendingCleared(tabId); -} - -/** tells the page's overlay the pending composer can go away */ -async function notifyPendingCleared(tabId: number): Promise { - await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: false, pending: false }).catch(() => {}); } async function recorderStart(tabId: number): Promise { const buffer = recorder(tabId); - // a pending recording that was never answered is auto-saved first — - // one buffer per tab, and its data must not be lost - if (buffer.pending) void finalizePendingRecording(buffer, tabId).catch(() => {}); const tab = await browser.tabs.get(tabId); buffer.recording = true; - buffer.pending = false; - if (buffer.pendingTimer) clearTimeout(buffer.pendingTimer); - buffer.pendingTimer = null; buffer.startedAt = Date.now(); buffer.events = []; buffer.screenshots = new Map(); @@ -230,8 +180,6 @@ buffer.videoActive = false; buffer.lastVideoAt = 0; buffer.consoleCount = 0; - buffer.preparedAttachmentId = null; - buffer.mediaPromise = null; buffer.pageUrl = tab.url ?? null; buffer.pageTitle = tab.title ?? null; buffer.lastUrl = tab.url ?? null; @@ -257,11 +205,11 @@ } /** - * Stops the capture side of the recording and parks it as *pending*: the - * buffer stays alive while the composer (title/description + Save/Discard) - * is open, and the media upload runs in parallel so it is usually done by - * the time the user hits Save. The user's answer lands via recorder_submit / - * recorder_discard; an unanswered pending recording auto-saves on timeout. + * Stops the capture side of the recording and submits the report right away: + * an MV3 service worker is killed after ~30 s of idle time, so nothing about + * the recording may depend on background memory once the composer is open. + * The composer only *edits* the already-submitted report (title/description + * via recorder_edit, or deletes it via recorder_discard). */ async function recorderStop(tabId: number): Promise { const buffer = recorder(tabId); @@ -278,32 +226,14 @@ | null; if (flushed?.points?.length) buffer.moveTrack.push(...flushed.points); - // media encoding + upload runs while the composer is open (not awaited) - const startedAt = buffer.startedAt ?? Date.now(); - const mediaPromise = uploadTrackGif(buffer, startedAt) - .then((id) => { - buffer.preparedAttachmentId = id; - return id; - }) - .catch((error) => { - debugLog(`recording media failed: ${error instanceof Error ? error.message : String(error)}`); - return null; - }); - buffer.mediaPromise = mediaPromise; - - if (buffer.events.length > 0) { - buffer.pending = true; - if (buffer.pendingTimer) clearTimeout(buffer.pendingTimer); - buffer.pendingTimer = setTimeout(() => { - // the composer was never answered — auto-save so nothing is lost - void finalizePendingRecording(buffer, tabId).catch(() => {}); - }, PENDING_TIMEOUT_MS); - debugLog(`recording pending: ${buffer.events.length} steps`); - } else { - // nothing recorded — clear silently - clearRecorderBuffer(buffer); - } - return recorderStateOf(buffer); + const reportToken = await submitRecordingBuffer(buffer); + clearRecorderBuffer(buffer); + // the composer opens only when the report exists; it edits by token, so a + // service-worker restart while it is open loses nothing + void browser.tabs + .sendMessage(tabId, { type: "recorder_state", recording: false, pending: reportToken != null, report_token: reportToken }) + .catch(() => {}); + return { recording: false, stepCount: 0, startedAt: null, pending: reportToken != null, report_token: reportToken }; } /** @@ -497,9 +427,8 @@ 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, pending: state.pending }).catch(() => {}); - return state; + // recorderStop notifies the tab itself (with the report token) + return recorderStop(tabId); } return startRecorder(tabId); } @@ -854,8 +783,8 @@ if (tab?.id == null) return; const buffer = recorder(tab.id); if (buffer.recording) { - const state = await recorderStop(tab.id).catch(console.error); - await browser.tabs.sendMessage(tab.id, { type: "recorder_state", recording: false, pending: state?.pending }).catch(() => {}); + // recorderStop notifies the tab itself (with the report token) + await recorderStop(tab.id).catch(console.error); } else { await startRecorder(tab.id).catch(console.error); } @@ -878,14 +807,10 @@ 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; a pending composer - // must survive navigation too, so the new document re-opens it + // (and the recorder bar) once the new document is ready if (changeInfo.status === "complete") { await ensureContentScript(tabId); await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: true, pending: false }).catch(() => {}); - if (buffer.pending) { - await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: false, pending: true }).catch(() => {}); - } } }); @@ -1067,28 +992,32 @@ } } - case "recorder_submit": { - // the recording composer's Save — finalize the pending buffer - const buffer = tabId != null ? recorders.get(tabId) : undefined; - if (!buffer?.pending) return { ok: false, error: "No pending recording" }; + case "recorder_edit": { + // composer Save: the report was already submitted at stop — patch it + if (typeof msg.token !== "string" || !msg.token) return { ok: false, error: "No report to edit" }; try { - const result = await finalizePendingRecording( - buffer, - tabId!, - typeof msg.title === "string" ? msg.title : undefined, - typeof msg.description === "string" ? msg.description : undefined + const detail = await updateRecordingReport( + msg.token, + typeof msg.title === "string" ? msg.title : "", + typeof msg.description === "string" ? msg.description : "" ); - return { ok: true, data: result ?? { report_token: null } }; + return { ok: true, data: { report_token: detail.share_token } }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } } case "recorder_discard": { - const buffer = tabId != null ? recorders.get(tabId) : undefined; - if (!buffer?.pending) return { ok: false, error: "No pending recording" }; - discardPendingRecording(buffer, tabId!); - return { ok: true }; + // composer Discard: delete the report submitted at stop + if (typeof msg.token !== "string" || !msg.token) return { ok: false, error: "No report to discard" }; + try { + const client = await getHttpClient(); + await client.del(`/api/reports/${msg.token}`); + debugLog("recording discarded"); + return { ok: true }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } } case "recorder_state_request": { diff --git a/packages/extension/src/content/index.ts b/packages/extension/src/content/index.ts index b31564d..3962c21 100644 --- a/packages/extension/src/content/index.ts +++ b/packages/extension/src/content/index.ts @@ -315,7 +315,8 @@ case "recorder_state": { const recording = Boolean((message as { recording?: unknown }).recording); const pending = Boolean((message as { pending?: unknown }).pending); - getOverlay().setRecorderState(recording, pending); + const reportToken = (message as { report_token?: string | null }).report_token ?? null; + getOverlay().setRecorderState(recording, pending, reportToken); if (recording) { moveSamples = []; lastMove = null; diff --git a/packages/extension/src/content/overlay/App.vue b/packages/extension/src/content/overlay/App.vue index e4fb6e4..b312041 100644 --- a/packages/extension/src/content/overlay/App.vue +++ b/packages/extension/src/content/overlay/App.vue @@ -33,14 +33,16 @@ await copyReportLink(reportToken); } -/** recording composer: Save → share link like a note, Discard → just close */ +/** recording composer: Save → patch the report, copy the share link; Discard → delete it */ async function onRecordingSaved(reportToken: string) { props.state.pending = false; + props.state.pendingToken = null; if (reportToken) await copyReportLink(reportToken); } function onRecordingDiscarded() { props.state.pending = false; + props.state.pendingToken = null; } /** @@ -136,6 +138,7 @@ diff --git a/packages/extension/src/content/overlay/RecordingComposer.vue b/packages/extension/src/content/overlay/RecordingComposer.vue index 4a7d8b1..9b4e56c 100644 --- a/packages/extension/src/content/overlay/RecordingComposer.vue +++ b/packages/extension/src/content/overlay/RecordingComposer.vue @@ -2,6 +2,10 @@ import { onMounted, ref } from "vue"; import browser from "webextension-polyfill"; +// the report was already submitted when the recording stopped — the composer +// only edits (or deletes) it, so a service-worker restart changes nothing +const props = defineProps<{ reportToken?: string | null }>(); + const emit = defineEmits<{ (e: "saved", reportToken: string): void; (e: "discarded"): void; @@ -23,16 +27,21 @@ async function save() { if (saving.value) return; + if (!props.reportToken) { + error.value = "The report was not submitted — it cannot be renamed"; + return; + } saving.value = true; error.value = null; try { const response = (await browser.runtime.sendMessage({ - type: "recorder_submit", + type: "recorder_edit", + token: props.reportToken, title: title.value, description: description.value, })) as { ok: boolean; data?: { report_token: string | null }; error?: string }; if (!response?.ok) throw new Error(response?.error ?? "Submit failed"); - emit("saved", response.data?.report_token ?? ""); + emit("saved", response.data?.report_token ?? props.reportToken); } catch (err) { error.value = err instanceof Error ? err.message : String(err); } finally { @@ -45,11 +54,18 @@ confirmingDiscard.value = true; return; } - const response = (await browser.runtime.sendMessage({ type: "recorder_discard" })) as { - ok: boolean; - error?: string; - }; - if (response?.ok) emit("discarded"); + if (props.reportToken) { + const response = (await browser.runtime.sendMessage({ + type: "recorder_discard", + token: props.reportToken, + })) as { ok: boolean; error?: string }; + if (!response?.ok) { + error.value = response?.error ?? "Discard failed"; + confirmingDiscard.value = false; + return; + } + } + emit("discarded"); } function onKeyDown(event: KeyboardEvent) { diff --git a/packages/extension/src/content/overlay/mount.ts b/packages/extension/src/content/overlay/mount.ts index e866e06..5af4662 100644 --- a/packages/extension/src/content/overlay/mount.ts +++ b/packages/extension/src/content/overlay/mount.ts @@ -18,15 +18,17 @@ export interface OverlayState { mode: "idle" | "picker"; recording: boolean; - /** a stopped recording awaits Save/Discard in the composer */ + /** the composer is open for a recording already submitted to the server */ pending: boolean; + /** share token of the submitted report the composer edits */ + pendingToken: string | null; /** transient message shown in the flash toast (replay results etc.) */ flash: string | null; } export interface Overlay { startPicker: () => void; - setRecorderState: (recording: boolean, pending?: boolean) => void; + setRecorderState: (recording: boolean, pending?: boolean, reportToken?: string | null) => void; setFlash: (text: string | null) => void; destroy: () => void; } @@ -67,7 +69,7 @@ mountPoint.style.cssText = `all: initial; pointer-events: none; width: 100%; height: 100%; ${vars}`; shadowRoot.appendChild(mountPoint); - const state = reactive({ mode: "idle", recording: false, pending: false, flash: null }); + const state = reactive({ mode: "idle", recording: false, pending: false, pendingToken: null, flash: null }); const app = createApp(App, { state }); app.mount(mountPoint); @@ -107,10 +109,14 @@ state.mode = "picker"; state.recording = false; }, - setRecorderState: (recording: boolean, pending?: boolean) => { + setRecorderState: (recording: boolean, pending?: boolean, reportToken?: string | null) => { // only toggles the bar; picking stays opt-in via the bar's Note button state.recording = recording; - state.pending = Boolean(pending); + state.pending = Boolean(pending) && !recording; + state.pendingToken = recording ? null : reportToken ?? null; + // a new recording supersedes an open composer (the report is already + // saved server-side with its auto-title — nothing is lost) + if (recording) state.pendingToken = null; if (!recording && state.mode === "picker") state.mode = "idle"; }, setFlash: (text: string | null) => { diff --git a/packages/extension/src/lib/messages.ts b/packages/extension/src/lib/messages.ts index 2a87b1b..dba839f 100644 --- a/packages/extension/src/lib/messages.ts +++ b/packages/extension/src/lib/messages.ts @@ -19,9 +19,9 @@ | { type: "recorder_event"; event: RecordEvent } | { type: "recorder_note"; text: string } | { type: "recorder_screenshot" } - | { type: "recorder_state"; recording: boolean; pending?: boolean } - | { type: "recorder_submit"; title: string; description: string } - | { type: "recorder_discard" } + | { type: "recorder_state"; recording: boolean; pending?: boolean; report_token?: string | null } + | { type: "recorder_edit"; token: string; title: string; description: string } + | { type: "recorder_discard"; token: string } | { type: "settings_changed" }; export interface SubmitNotePayload { @@ -57,6 +57,8 @@ recording: boolean; stepCount: number; startedAt: number | null; - /** stopped but awaiting the user's Save/Discard in the recording composer */ + /** the composer is open for a recording already submitted to the server */ pending?: boolean; + /** share token of the submitted report the composer edits */ + report_token?: string | null; } \ No newline at end of file