From af4d712a15aedd36dfacba0192e2cd87b2ffef48 Mon Sep 17 00:00:00 2001 From: Vansh Date: Sat, 11 Jul 2026 20:28:44 +0530 Subject: [PATCH] feat: improve error handling and lifecycle management for tabCapture --- package.json | 2 +- src/audioCaptureRetry.test.ts | 178 ++++++++++++++++++++++++++++++++++ src/background.ts | 162 ++++++++++++++++++++----------- src/dashboard.css | 46 +++++++++ src/dashboard.html | 7 ++ src/dashboard.ts | 40 +++++++- src/offscreen.ts | 14 +++ src/popup.css | 46 +++++++++ src/popup.html | 7 ++ src/popup.ts | 43 +++++++- src/types.ts | 1 + 11 files changed, 486 insertions(+), 60 deletions(-) create mode 100644 src/audioCaptureRetry.test.ts diff --git a/package.json b/package.json index 397ba699..1af44cf5 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "test": "tsx --test src/activeMeetingMessages.test.ts src/audioCaptureLifecycle.test.ts src/audioProcessing.test.ts src/audioChunkQueue.test.ts src/audioChunkQueue.fuzz.test.ts src/participantDetection.test.ts src/meetingTabs.test.ts src/sessionStorage.test.ts src/dashboardCapture.test.ts src/popupCapture.test.ts src/speakerAttribution.test.ts src/passphraseStrength.test.ts src/settings.test.ts src/utils/credentials.test.ts src/utils/storageUtils.test.ts src/background.urlParsing.test.ts src/background.sessionRecovery.test.ts src/lateJoinerDelivery.test.ts src/utils/sanitize.test.ts tests/offscreenAudioGraph.test.ts", + "test": "tsx --test src/activeMeetingMessages.test.ts src/audioCaptureLifecycle.test.ts src/audioProcessing.test.ts src/audioChunkQueue.test.ts src/audioChunkQueue.fuzz.test.ts src/participantDetection.test.ts src/meetingTabs.test.ts src/sessionStorage.test.ts src/dashboardCapture.test.ts src/popupCapture.test.ts src/speakerAttribution.test.ts src/passphraseStrength.test.ts src/settings.test.ts src/utils/credentials.test.ts src/utils/storageUtils.test.ts src/background.urlParsing.test.ts src/background.sessionRecovery.test.ts src/lateJoinerDelivery.test.ts src/utils/sanitize.test.ts tests/offscreenAudioGraph.test.ts src/audioCaptureRetry.test.ts", "lint": "eslint . --ext .ts,.js", "type-check": "tsc --noEmit", "size-check": "tsx scripts/checkBundleSize.ts", diff --git a/src/audioCaptureRetry.test.ts b/src/audioCaptureRetry.test.ts new file mode 100644 index 00000000..e53c55f6 --- /dev/null +++ b/src/audioCaptureRetry.test.ts @@ -0,0 +1,178 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +type AnyRecord = Record; +type MessageListener = ( + message: AnyRecord, + sender: AnyRecord, + sendResponse: (response?: unknown) => void, +) => boolean | undefined; + +let messageListener: MessageListener | undefined; +let getMediaStreamIdCalls = 0; +let getMediaStreamIdCallbackValue: string | null = null; +let lastErrorMock: { message: string } | null = null; +let sentMessages: AnyRecord[] = []; +let tabUpdatedListener: Function | null = null; + +// Speed up setTimeout for backoff testing +const originalSetTimeout = globalThis.setTimeout; +(globalThis as any).setTimeout = (cb: Function, ms?: number) => { + return originalSetTimeout(cb, 0); +}; + +function installChromeMock() { + if (typeof (globalThis as any).addEventListener !== "function") { + (globalThis as any).addEventListener = () => {}; + } + (globalThis as any).self = globalThis; + + (globalThis as any).chrome = { + runtime: { + getURL: (path: string) => `chrome-extension://fakeextid/${path}`, + sendMessage: async (msg: AnyRecord) => { + sentMessages.push(msg); + if (msg.type === "OFFSCREEN_START_CAPTURE") { + return { success: false, error: "Mock offscreen start failure" }; + } + return { success: true }; + }, + getContexts: async () => [], + onMessage: { + addListener: (cb: MessageListener) => { + messageListener = cb; + }, + }, + onInstalled: { addListener: () => {} }, + onStartup: { addListener: () => {} }, + onSuspend: { addListener: () => {} }, + get lastError() { + return lastErrorMock; + }, + }, + offscreen: { + createDocument: async () => {}, + closeDocument: async () => {}, + hasDocument: async () => false, + }, + alarms: { + onAlarm: { addListener: () => {} }, + create: () => {}, + }, + tabs: { + onUpdated: { + addListener: (cb: Function) => { + tabUpdatedListener = cb; + }, + }, + onActivated: { addListener: () => {} }, + onRemoved: { addListener: () => {} }, + get: async () => ({}), + query: async () => [], + sendMessage: async () => {}, + }, + tabCapture: { + getMediaStreamId: (options: any, callback: (streamId: string | null) => void) => { + getMediaStreamIdCalls++; + callback(getMediaStreamIdCallbackValue); + }, + }, + commands: { onCommand: { addListener: () => {} } }, + contextMenus: { + onClicked: { addListener: () => {} }, + removeAll: (cb?: () => void) => cb?.(), + create: () => {}, + }, + sidePanel: { open: async () => {} }, + storage: { + local: { + get: async () => ({}), + set: async () => {}, + remove: async () => {}, + }, + session: { + get: async () => ({}), + set: async () => {}, + remove: async () => {}, + }, + }, + }; +} + +installChromeMock(); +await import("./background.ts"); + +function sendMessage(message: AnyRecord): Promise { + return new Promise((resolve) => { + if (!messageListener) { + throw new Error("background did not register an onMessage listener"); + } + const kept = messageListener(message, {}, (response) => resolve((response ?? {}) as AnyRecord)); + if (kept !== true) resolve({}); + }); +} + +test("audio capture retries 3 times and reports error on failure", async () => { + getMediaStreamIdCalls = 0; + getMediaStreamIdCallbackValue = null; + lastErrorMock = { message: "Mocked capture stream permission denied" }; + sentMessages = []; + + const response = await sendMessage({ + type: "MANUAL_START_AUDIO", + tabId: 42, + meetingId: "abc-defg-hij", + }); + + // Verify that getMediaStreamId was called 3 times (due to retry limit) + assert.equal(getMediaStreamIdCalls, 3); + + // Retrieve final state to check error message is populated + const finalState = await sendMessage({ type: "GET_STATE" }); + assert.equal(finalState.audioActive, false); + assert.equal(finalState.captureError, "Failed to get media stream ID for tab capture. Ensure you have given permission."); +}); + +test("audio capture stops when active tab navigates away from Google Meet", async () => { + // Reset states + const finalState1 = await sendMessage({ type: "GET_STATE" }); + assert.equal(finalState1.audioActive, false); + + // Simulate success on first try + getMediaStreamIdCalls = 0; + getMediaStreamIdCallbackValue = "mock-stream-id"; + lastErrorMock = null; + + // Temporarily mock sendMessage for runtime to return success for offscreen document start + const originalSendMessage = chrome.runtime.sendMessage; + chrome.runtime.sendMessage = async (msg: any) => { + if (msg.type === "OFFSCREEN_START_CAPTURE") { + return { success: true }; + } + return { success: true }; + }; + + await sendMessage({ + type: "MANUAL_START_AUDIO", + tabId: 42, + meetingId: "abc-defg-hij", + }); + + const activeState = await sendMessage({ type: "GET_STATE" }); + assert.equal(activeState.audioActive, true); + assert.equal(activeState.targetTabId, 42); + + // Restore original runtime sendMessage + chrome.runtime.sendMessage = originalSendMessage; + + // Now trigger tab update listener to navigate away + assert.ok(tabUpdatedListener); + await tabUpdatedListener( + 42, + { url: "https://example.com" }, + { id: 42, url: "https://example.com" } + ); + + const stoppedState = await sendMessage({ type: "GET_STATE" }); + assert.equal(stoppedState.audioActive, false); +}); diff --git a/src/background.ts b/src/background.ts index 3698d7b5..65646c62 100644 --- a/src/background.ts +++ b/src/background.ts @@ -274,6 +274,7 @@ const state: State = { participantCount: 0, tokensUsed: 0, estimatedCost: 0, + captureError: null, }; async function trackUsage(delta: UsageDelta) { @@ -533,6 +534,7 @@ function resetState() { selfParticipantName = null; state.tokensUsed = 0; state.estimatedCost = 0; + state.captureError = null; } function addTimeline(event: string) { @@ -578,6 +580,7 @@ function snapshot() { pendingJoiners: [...(state.pendingJoiners ?? [])], tokensUsed: state.tokensUsed ?? 0, estimatedCost: state.estimatedCost ?? 0, + captureError: state.captureError, }; } @@ -1657,76 +1660,101 @@ async function startAudioCapture( const createdSession = !state.isActive || !state.meetingId; - try { - await ensureOffscreenDocument(); + if (createdSession) { + resetState(); + await chrome.storage.local.remove("activeMeetingState"); + state.isActive = true; + state.startTime = Date.now(); + state.meetingId = meetingId || "unknown"; + state.meetingUrl = meetingUrl || null; + state.targetTabId = tabId; + addTimeline(`Meeting started (${state.meetingId})`); + } - if (createdSession) { - resetState(); - await chrome.storage.local.remove("activeMeetingState"); - state.isActive = true; - state.startTime = Date.now(); - state.meetingId = meetingId || "unknown"; - state.meetingUrl = meetingUrl || null; - state.targetTabId = tabId; - addTimeline(`Meeting started (${state.meetingId})`); - } + let attempts = 0; + const maxAttempts = 3; + const baseDelay = 1000; + let success = false; + let lastErr: any = null; - let streamId = providedStreamId; - - if (!streamId) { - streamId = await new Promise((resolve) => { - chrome.tabCapture.getMediaStreamId({ targetTabId: tabId }, (id) => { - if (chrome.runtime.lastError) { - console.error( - "[LateMeet] getMediaStreamId error (background):", - chrome.runtime.lastError.message || chrome.runtime.lastError, - ); - resolve(null); - } else { - resolve(id); - } + while (attempts < maxAttempts) { + try { + await ensureOffscreenDocument(); + + let streamId = providedStreamId; + + if (!streamId) { + streamId = await new Promise((resolve) => { + chrome.tabCapture.getMediaStreamId({ targetTabId: tabId }, (id) => { + if (chrome.runtime.lastError) { + console.error( + `[LateMeet] getMediaStreamId error (attempt ${attempts + 1}):`, + chrome.runtime.lastError.message || chrome.runtime.lastError, + ); + resolve(null); + } else { + resolve(id); + } + }); }); - }); - } + } - if (!streamId) { - throw new Error( - "Failed to get media stream ID for tab capture. Ensure you have given permission.", - ); - } + if (!streamId) { + throw new Error( + "Failed to get media stream ID for tab capture. Ensure you have given permission.", + ); + } - const settings = await getSettings(); - const raw = settings.vadThreshold; - const vadThreshold = - typeof raw === "number" && Number.isFinite(raw) && raw >= 0.001 && raw <= 1.0 ? raw : 0.012; - const response = await chrome.runtime.sendMessage({ - type: "OFFSCREEN_START_CAPTURE", - streamId, - tabId, - includeMicrophone, - vadThreshold, - }); + const settings = await getSettings(); + const raw = settings.vadThreshold; + const vadThreshold = + typeof raw === "number" && Number.isFinite(raw) && raw >= 0.001 && raw <= 1.0 ? raw : 0.012; + const response = await chrome.runtime.sendMessage({ + type: "OFFSCREEN_START_CAPTURE", + streamId, + tabId, + includeMicrophone, + vadThreshold, + }); - if (!response?.success) { - throw new Error(response?.error || "Failed to start offscreen capture"); - } + if (!response?.success) { + throw new Error(response?.error || "Failed to start offscreen capture"); + } - state.audioActive = true; - addTimeline("Audio capture started"); - if (response.microphoneActive === false) { - addTimeline("Microphone capture unavailable; recording tab audio only"); + state.audioActive = true; + addTimeline("Audio capture started"); + if (response.microphoneActive === false) { + addTimeline("Microphone capture unavailable; recording tab audio only"); + } + state.captureError = null; + await broadcastStateUpdate(true); + success = true; + break; + } catch (err: any) { + attempts++; + lastErr = err; + console.warn(`[LateMeet] Audio capture start attempt ${attempts} failed:`, err.message || err); + if (attempts < maxAttempts) { + const delay = baseDelay * Math.pow(2, attempts - 1) + Math.random() * 200; + await new Promise((resolve) => setTimeout(resolve, delay)); + } } - await broadcastStateUpdate(true); - } catch (err) { + } + + if (!success) { + state.captureError = lastErr?.message || "Audio capture failed"; state.audioActive = false; if (createdSession) { resetState(); - await broadcastStateUpdate(true); + // Keep captureError preserved even after resetState() + state.captureError = lastErr?.message || "Audio capture failed"; } - throw err; - } finally { + await broadcastStateUpdate(true); isStartingAudio = false; + throw lastErr || new Error("Audio capture failed after retries"); } + + isStartingAudio = false; } async function scanForMeetTabs() { @@ -1782,7 +1810,7 @@ async function pollRemainingChunks(): Promise { while (Date.now() - pollStart < POLL_TIMEOUT) { try { const pollResponse = await chrome.runtime.sendMessage({ - type: "GET_REMAINING_CHUNKS", + type: "OFFSCREEN_GET_REMAINING_CHUNKS", }); if (pollResponse && typeof pollResponse === "object") { const pending = pollResponse.pending ?? 0; @@ -1846,6 +1874,26 @@ async function stopAudioCapture(reason = "Stopped") { } chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + // If the target tab being captured updates its URL or completes loading: + if (tabId === state.targetTabId) { + const newUrl = changeInfo.url || (changeInfo.status === "complete" ? tab.url : undefined); + if (newUrl) { + await hydrateState(); + if (state.isActive) { + const newMeetingId = getMeetingIdFromUrl(newUrl); + if (!newMeetingId || newMeetingId !== state.meetingId) { + let reason = "Left meeting"; + if (newMeetingId && newMeetingId !== state.meetingId) { + reason = "Meeting code changed"; + } else if (!newUrl.includes("meet.google.com")) { + reason = "Navigated away from Google Meet"; + } + await stopAudioCapture(reason); + } + } + } + } + if (changeInfo.status !== "complete" || !tab.url) return; await hydrateState(); try { diff --git a/src/dashboard.css b/src/dashboard.css index d2d40ea9..b6f85a09 100644 --- a/src/dashboard.css +++ b/src/dashboard.css @@ -1125,6 +1125,52 @@ body::-webkit-scrollbar-thumb, color: #fca5a5; } +/* --- ERROR BANNER --- */ +.dash-error-banner { + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(239, 68, 68, 0.1); + border: 1px solid #ef4444; + border-radius: 8px; + padding: 10px 16px; + margin: 12px 16px; + font-size: 13px; + color: #ef4444; + gap: 12px; +} + +.dash-error-banner .dash-error-message { + flex-grow: 1; +} + +.dash-error-banner .dash-error-retry-btn { + background: #ef4444; + color: white; + border: none; + border-radius: 4px; + padding: 6px 12px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + transition: background-color 0.2s ease; +} + +.dash-error-banner .dash-error-retry-btn:hover { + background: #dc2626; +} + +.dash-error-banner .dash-error-close-btn { + background: transparent; + color: #ef4444; + border: none; + font-size: 18px; + cursor: pointer; + line-height: 1; + padding: 0 4px; +} + /* ===================================================== RESPONSIVE BREAKPOINTS — Issue #648 Fix: text overlap, button clipping, header overflow, diff --git a/src/dashboard.html b/src/dashboard.html index 93c8b30a..e6b379c6 100644 --- a/src/dashboard.html +++ b/src/dashboard.html @@ -150,6 +150,13 @@ + + +