From 1fe2043742c81be8dbc07ba4daabd6d6899dccc6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 18:17:56 +0100 Subject: [PATCH] fix(browser): harden extension relay worker recovery Co-authored-by: codexGW <9350182+codexGW@users.noreply.github.com> --- CHANGELOG.md | 1 + assets/chrome-extension/background-utils.js | 30 ++ assets/chrome-extension/background.js | 477 +++++++++++++++--- assets/chrome-extension/manifest.json | 2 +- .../chrome-extension-background-utils.test.ts | 77 +++ src/browser/chrome-extension-manifest.test.ts | 29 ++ 6 files changed, 550 insertions(+), 66 deletions(-) create mode 100644 assets/chrome-extension/background-utils.js create mode 100644 src/browser/chrome-extension-background-utils.test.ts create mode 100644 src/browser/chrome-extension-manifest.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d44c50b9f4..999ba959c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai - Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) - Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via `conversations.open` before calling `files.uploadV2`, which rejects non-channel IDs. `chat.postMessage` tolerates user IDs directly, but `files.uploadV2` → `completeUploadExternal` validates `channel_id` against `^[CGDZ][A-Z0-9]{8,}$`, causing `invalid_arguments` when agents reply with media to DM conversations. - Browser/Relay: treat extension websocket as connected only when `OPEN`, allow reconnect when a stale `CLOSING/CLOSED` extension socket lingers, and guard stale socket message/close handlers so late events cannot clear active relay state; includes regression coverage for live-duplicate `409` rejection and immediate reconnect-after-close races. (#15099, #18698, #20688) +- Browser/Extension Relay: refactor the MV3 worker to preserve debugger attachments across relay drops, auto-reconnect with bounded backoff+jitter, persist and rehydrate attached tab state via `chrome.storage.session`, recover from `target_closed` navigation detaches, guard stale socket handlers, enforce per-tab operation locks and per-request timeouts, and add lifecycle keepalive/badge refresh hooks (`alarms`, `webNavigation`). (#15099, #6175, #8468, #9807) - Signal/RPC: guard malformed Signal RPC JSON responses with a clear status-scoped error and add regression coverage for invalid JSON responses. (#22995) Thanks @adhitShet. - Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early `Cannot read properties of undefined (reading 'trim')` crashes during subagent spawn and wait flows. - Agents/Workspace: guard `resolveUserPath` against undefined/null input to prevent `Cannot read properties of undefined (reading 'trim')` crashes when workspace paths are missing in embedded runner flows. diff --git a/assets/chrome-extension/background-utils.js b/assets/chrome-extension/background-utils.js new file mode 100644 index 00000000000..183e35f9c4a --- /dev/null +++ b/assets/chrome-extension/background-utils.js @@ -0,0 +1,30 @@ +export function reconnectDelayMs( + attempt, + opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random }, +) { + const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000; + const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000; + const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000; + const random = typeof opts.random === "function" ? opts.random : Math.random; + const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0); + const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs); + return backoff + Math.max(0, jitterMs) * random(); +} + +export function buildRelayWsUrl(port, gatewayToken) { + const token = String(gatewayToken || "").trim(); + if (!token) { + throw new Error( + "Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)", + ); + } + return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(token)}`; +} + +export function isRetryableReconnectError(err) { + const message = err instanceof Error ? err.message : String(err || ""); + if (message.includes("Missing gatewayToken")) { + return false; + } + return true; +} diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 7a1754e06c9..5de9027bfcd 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -1,3 +1,5 @@ +import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' + const DEFAULT_PORT = 18792 const BADGE = { @@ -12,8 +14,6 @@ let relayWs = null /** @type {Promise|null} */ let relayConnectPromise = null -let debuggerListenersInstalled = false - let nextSession = 1 /** @type {Map} */ @@ -26,6 +26,14 @@ const childSessionToTab = new Map() /** @type {Mapvoid, reject:(e:Error)=>void}>} */ const pending = new Map() +// Per-tab operation locks prevent double-attach races. +/** @type {Set} */ +const tabOperationLocks = new Set() + +// Reconnect state for exponential backoff. +let reconnectAttempt = 0 +let reconnectTimer = null + function nowStack() { try { return new Error().stack || '' @@ -55,6 +63,63 @@ function setBadge(tabId, kind) { void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {}) } +// Persist attached tab state to survive MV3 service worker restarts. +async function persistState() { + try { + const tabEntries = [] + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected' && tab.sessionId && tab.targetId) { + tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder }) + } + } + await chrome.storage.session.set({ + persistedTabs: tabEntries, + nextSession, + }) + } catch { + // chrome.storage.session may not be available in all contexts. + } +} + +// Rehydrate tab state on service worker startup. Fast path — just restores +// maps and badges. Relay reconnect happens separately in background. +async function rehydrateState() { + try { + const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession']) + if (stored.nextSession) { + nextSession = Math.max(nextSession, stored.nextSession) + } + const entries = stored.persistedTabs || [] + // Phase 1: optimistically restore state and badges. + for (const entry of entries) { + tabs.set(entry.tabId, { + state: 'connected', + sessionId: entry.sessionId, + targetId: entry.targetId, + attachOrder: entry.attachOrder, + }) + tabBySession.set(entry.sessionId, entry.tabId) + setBadge(entry.tabId, 'on') + } + // Phase 2: validate asynchronously, remove dead tabs. + for (const entry of entries) { + try { + await chrome.tabs.get(entry.tabId) + await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', { + expression: '1', + returnByValue: true, + }) + } catch { + tabs.delete(entry.tabId) + tabBySession.delete(entry.sessionId) + setBadge(entry.tabId, 'off') + } + } + } catch { + // Ignore rehydration errors. + } +} + async function ensureRelayConnection() { if (relayWs && relayWs.readyState === WebSocket.OPEN) return if (relayConnectPromise) return await relayConnectPromise @@ -63,9 +128,7 @@ async function ensureRelayConnection() { const port = await getRelayPort() const gatewayToken = await getGatewayToken() const httpBase = `http://127.0.0.1:${port}` - const wsUrl = gatewayToken - ? `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(gatewayToken)}` - : `ws://127.0.0.1:${port}/extension` + const wsUrl = buildRelayWsUrl(port, gatewayToken) // Fast preflight: is the relay server up? try { @@ -74,12 +137,6 @@ async function ensureRelayConnection() { throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) } - if (!gatewayToken) { - throw new Error( - 'Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)', - ) - } - const ws = new WebSocket(wsUrl) relayWs = ws @@ -99,42 +156,142 @@ async function ensureRelayConnection() { } }) - ws.onmessage = (event) => void onRelayMessage(String(event.data || '')) - ws.onclose = () => onRelayClosed('closed') - ws.onerror = () => onRelayClosed('error') - - if (!debuggerListenersInstalled) { - debuggerListenersInstalled = true - chrome.debugger.onEvent.addListener(onDebuggerEvent) - chrome.debugger.onDetach.addListener(onDebuggerDetach) + // Bind permanent handlers. Guard against stale socket: if this WS was + // replaced before its close fires, the handler is a no-op. + ws.onmessage = (event) => { + if (ws !== relayWs) return + void whenReady(() => onRelayMessage(String(event.data || ''))) + } + ws.onclose = () => { + if (ws !== relayWs) return + onRelayClosed('closed') + } + ws.onerror = () => { + if (ws !== relayWs) return + onRelayClosed('error') } })() try { await relayConnectPromise + reconnectAttempt = 0 } finally { relayConnectPromise = null } } +// Relay closed — update badges, reject pending requests, auto-reconnect. +// Debugger sessions are kept alive so they survive transient WS drops. function onRelayClosed(reason) { relayWs = null + for (const [id, p] of pending.entries()) { pending.delete(id) p.reject(new Error(`Relay disconnected (${reason})`)) } - for (const tabId of tabs.keys()) { - void chrome.debugger.detach({ tabId }).catch(() => {}) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: disconnected (click to re-attach)', - }) + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected') { + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay reconnecting…', + }) + } } - tabs.clear() - tabBySession.clear() - childSessionToTab.clear() + + scheduleReconnect() +} + +function scheduleReconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + + const delay = reconnectDelayMs(reconnectAttempt) + reconnectAttempt++ + + console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`) + + reconnectTimer = setTimeout(async () => { + reconnectTimer = null + try { + await ensureRelayConnection() + reconnectAttempt = 0 + console.log('Reconnected successfully') + await reannounceAttachedTabs() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`) + if (!isRetryableReconnectError(err)) { + return + } + scheduleReconnect() + } + }, delay) +} + +function cancelReconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + reconnectAttempt = 0 +} + +// Re-announce all attached tabs to the relay after reconnect. +async function reannounceAttachedTabs() { + for (const [tabId, tab] of tabs.entries()) { + if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue + + // Verify debugger is still attached. + try { + await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + expression: '1', + returnByValue: true, + }) + } catch { + tabs.delete(tabId) + if (tab.sessionId) tabBySession.delete(tab.sessionId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay (click to attach/detach)', + }) + continue + } + + // Send fresh attach event to relay. + try { + const info = /** @type {any} */ ( + await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo') + ) + const targetInfo = info?.targetInfo + + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.attachedToTarget', + params: { + sessionId: tab.sessionId, + targetInfo: { ...targetInfo, attached: true }, + waitingForDebugger: false, + }, + }, + }) + + setBadge(tabId, 'on') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: attached (click to detach)', + }) + } catch { + setBadge(tabId, 'on') + } + } + + await persistState() } function sendToRelay(payload) { @@ -159,10 +316,18 @@ async function maybeOpenHelpOnce() { function requestFromRelay(command) { const id = command.id return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }) + const timer = setTimeout(() => { + pending.delete(id) + reject(new Error('Relay request timeout (30s)')) + }, 30000) + pending.set(id, { + resolve: (v) => { clearTimeout(timer); resolve(v) }, + reject: (e) => { clearTimeout(timer); reject(e) }, + }) try { sendToRelay(command) } catch (err) { + clearTimeout(timer) pending.delete(id) reject(err instanceof Error ? err : new Error(String(err))) } @@ -233,8 +398,9 @@ async function attachTab(tabId, opts = {}) { throw new Error('Target.getTargetInfo returned no targetId') } - const sessionId = `cb-tab-${nextSession++}` - const attachOrder = nextSession + const sid = nextSession++ + const sessionId = `cb-tab-${sid}` + const attachOrder = sid tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder }) tabBySession.set(sessionId, tabId) @@ -258,11 +424,33 @@ async function attachTab(tabId, opts = {}) { } setBadge(tabId, 'on') + await persistState() + return { sessionId, targetId } } async function detachTab(tabId, reason) { const tab = tabs.get(tabId) + + // Send detach events for child sessions first. + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: childSessionId, reason: 'parent_detached' }, + }, + }) + } catch { + // Relay may be down. + } + childSessionToTab.delete(childSessionId) + } + } + + // Send detach event for main session. if (tab?.sessionId && tab?.targetId) { try { sendToRelay({ @@ -273,21 +461,17 @@ async function detachTab(tabId, reason) { }, }) } catch { - // ignore + // Relay may be down. } } if (tab?.sessionId) tabBySession.delete(tab.sessionId) tabs.delete(tabId) - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === tabId) childSessionToTab.delete(childSessionId) - } - try { await chrome.debugger.detach({ tabId }) } catch { - // ignore + // May already be detached. } setBadge(tabId, 'off') @@ -295,6 +479,8 @@ async function detachTab(tabId, reason) { tabId, title: 'OpenClaw Browser Relay (click to attach/detach)', }) + + await persistState() } async function connectOrToggleForActiveTab() { @@ -302,33 +488,43 @@ async function connectOrToggleForActiveTab() { const tabId = active?.id if (!tabId) return - const existing = tabs.get(tabId) - if (existing?.state === 'connected') { - await detachTab(tabId, 'toggle') - return - } - - tabs.set(tabId, { state: 'connecting' }) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: connecting to local relay…', - }) + // Prevent concurrent operations on the same tab. + if (tabOperationLocks.has(tabId)) return + tabOperationLocks.add(tabId) try { - await ensureRelayConnection() - await attachTab(tabId) - } catch (err) { - tabs.delete(tabId) - setBadge(tabId, 'error') + const existing = tabs.get(tabId) + if (existing?.state === 'connected') { + await detachTab(tabId, 'toggle') + return + } + + // User is manually connecting — cancel any pending reconnect. + cancelReconnect() + + tabs.set(tabId, { state: 'connecting' }) + setBadge(tabId, 'connecting') void chrome.action.setTitle({ tabId, - title: 'OpenClaw Browser Relay: relay not running (open options for setup)', + title: 'OpenClaw Browser Relay: connecting to local relay…', }) - void maybeOpenHelpOnce() - // Extra breadcrumbs in chrome://extensions service worker logs. - const message = err instanceof Error ? err.message : String(err) - console.warn('attach failed', message, nowStack()) + + try { + await ensureRelayConnection() + await attachTab(tabId) + } catch (err) { + tabs.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay not running (open options for setup)', + }) + void maybeOpenHelpOnce() + const message = err instanceof Error ? err.message : String(err) + console.warn('attach failed', message, nowStack()) + } + } finally { + tabOperationLocks.delete(tabId) } } @@ -337,14 +533,12 @@ async function handleForwardCdpCommand(msg) { const params = msg?.params?.params || undefined const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined - // Map command to tab const bySession = sessionId ? getTabBySessionId(sessionId) : null const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined const tabId = bySession?.tabId || (targetId ? getTabByTargetId(targetId) : null) || (() => { - // No sessionId: pick the first connected tab (stable-ish). for (const [id, tab] of tabs.entries()) { if (tab.state === 'connected') return id } @@ -434,20 +628,173 @@ function onDebuggerEvent(source, method, params) { }, }) } catch { - // ignore + // Relay may be down. } } +// Navigation/reload fires target_closed but the tab is still alive — Chrome +// just swaps the renderer process. Suppress the detach event to the relay and +// seamlessly re-attach after a short grace period. function onDebuggerDetach(source, reason) { const tabId = source.tabId if (!tabId) return if (!tabs.has(tabId)) return + + if (reason === 'target_closed') { + const oldState = tabs.get(tabId) + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attaching after navigation…', + }) + + setTimeout(async () => { + try { + // If user manually detached during the grace period, bail out. + if (!tabs.has(tabId)) return + const tab = await chrome.tabs.get(tabId) + if (tab && relayWs?.readyState === WebSocket.OPEN) { + console.log(`Re-attaching tab ${tabId} after navigation`) + if (oldState?.sessionId) tabBySession.delete(oldState.sessionId) + tabs.delete(tabId) + await attachTab(tabId, { skipAttachedEvent: false }) + } else { + // Tab gone or relay down — full cleanup. + void detachTab(tabId, reason) + } + } catch (err) { + console.warn(`Failed to re-attach tab ${tabId} after navigation:`, err.message) + void detachTab(tabId, reason) + } + }, 500) + return + } + + // Non-navigation detach (user action, crash, etc.) — full cleanup. void detachTab(tabId, reason) } -chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab()) +// Tab lifecycle listeners — clean up stale entries. +chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { + if (!tabs.has(tabId)) return + const tab = tabs.get(tabId) + if (tab?.sessionId) tabBySession.delete(tab.sessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + if (tab?.sessionId && tab?.targetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' }, + }, + }) + } catch { + // Relay may be down. + } + } + void persistState() +})) + +chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => { + const tab = tabs.get(removedTabId) + if (!tab) return + tabs.delete(removedTabId) + tabs.set(addedTabId, tab) + if (tab.sessionId) { + tabBySession.set(tab.sessionId, addedTabId) + } + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === removedTabId) { + childSessionToTab.set(childSessionId, addedTabId) + } + } + setBadge(addedTabId, 'on') + void persistState() +})) + +// Register debugger listeners at module scope so detach/event handling works +// even when the relay WebSocket is down. +chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args))) +chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args))) + +chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab())) + +// Refresh badge after navigation completes — service worker may have restarted +// during navigation, losing ephemeral badge state. +chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => { + if (frameId !== 0) return + const tab = tabs.get(tabId) + if (tab?.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } +})) + +// Refresh badge when user switches to an attached tab. +chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => { + const tab = tabs.get(tabId) + if (tab?.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } +})) chrome.runtime.onInstalled.addListener(() => { - // Useful: first-time instructions. void chrome.runtime.openOptionsPage() }) + +// MV3 keepalive via chrome.alarms — more reliable than setInterval across +// service worker restarts. Checks relay health and refreshes badges. +chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 }) + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name !== 'relay-keepalive') return + await initPromise + + if (tabs.size === 0) return + + // Refresh badges (ephemeral in MV3). + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } + } + + // If relay is down and no reconnect is in progress, trigger one. + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + if (!relayConnectPromise && !reconnectTimer) { + console.log('Keepalive: WebSocket unhealthy, triggering reconnect') + await ensureRelayConnection().catch(() => { + // ensureRelayConnection may throw without triggering onRelayClosed + // (e.g. preflight fetch fails before WS is created), so ensure + // reconnect is always scheduled on failure. + if (!reconnectTimer) { + scheduleReconnect() + } + }) + } + } +}) + +// Rehydrate state on service worker startup. Split: rehydration is the gate +// (fast), relay reconnect runs in background (slow, non-blocking). +const initPromise = rehydrateState() + +initPromise.then(() => { + if (tabs.size > 0) { + ensureRelayConnection().then(() => { + reconnectAttempt = 0 + return reannounceAttachedTabs() + }).catch(() => { + scheduleReconnect() + }) + } +}) + +// Shared gate: all state-dependent handlers await this before accessing maps. +async function whenReady(fn) { + await initPromise + return fn() +} diff --git a/assets/chrome-extension/manifest.json b/assets/chrome-extension/manifest.json index d6b593990de..62038276cd7 100644 --- a/assets/chrome-extension/manifest.json +++ b/assets/chrome-extension/manifest.json @@ -9,7 +9,7 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" }, - "permissions": ["debugger", "tabs", "activeTab", "storage"], + "permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"], "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"], "background": { "service_worker": "background.js", "type": "module" }, "action": { diff --git a/src/browser/chrome-extension-background-utils.test.ts b/src/browser/chrome-extension-background-utils.test.ts new file mode 100644 index 00000000000..7f383398d8e --- /dev/null +++ b/src/browser/chrome-extension-background-utils.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + buildRelayWsUrl, + isRetryableReconnectError, + reconnectDelayMs, +} from "../../assets/chrome-extension/background-utils.js"; + +describe("chrome extension background utils", () => { + it("builds websocket url with encoded gateway token", () => { + const url = buildRelayWsUrl(18792, "abc/+= token"); + expect(url).toBe("ws://127.0.0.1:18792/extension?token=abc%2F%2B%3D%20token"); + }); + + it("throws when gateway token is missing", () => { + expect(() => buildRelayWsUrl(18792, "")).toThrow(/Missing gatewayToken/); + expect(() => buildRelayWsUrl(18792, " ")).toThrow(/Missing gatewayToken/); + }); + + it("uses exponential backoff from attempt index", () => { + expect(reconnectDelayMs(0, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe( + 1000, + ); + expect(reconnectDelayMs(1, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe( + 2000, + ); + expect(reconnectDelayMs(4, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe( + 16000, + ); + }); + + it("caps reconnect delay at max", () => { + const delay = reconnectDelayMs(20, { + baseMs: 1000, + maxMs: 30000, + jitterMs: 0, + random: () => 0, + }); + expect(delay).toBe(30000); + }); + + it("adds jitter using injected random source", () => { + const delay = reconnectDelayMs(3, { + baseMs: 1000, + maxMs: 30000, + jitterMs: 1000, + random: () => 0.25, + }); + expect(delay).toBe(8250); + }); + + it("sanitizes invalid attempts and options", () => { + expect(reconnectDelayMs(-2, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe( + 1000, + ); + expect( + reconnectDelayMs(Number.NaN, { + baseMs: Number.NaN, + maxMs: Number.NaN, + jitterMs: Number.NaN, + random: () => 0, + }), + ).toBe(1000); + }); + + it("marks missing token errors as non-retryable", () => { + expect( + isRetryableReconnectError( + new Error("Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)"), + ), + ).toBe(false); + }); + + it("keeps transient network errors retryable", () => { + expect(isRetryableReconnectError(new Error("WebSocket connect timeout"))).toBe(true); + expect(isRetryableReconnectError(new Error("Relay server not reachable"))).toBe(true); + }); +}); diff --git a/src/browser/chrome-extension-manifest.test.ts b/src/browser/chrome-extension-manifest.test.ts new file mode 100644 index 00000000000..4d4a0321724 --- /dev/null +++ b/src/browser/chrome-extension-manifest.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +type ExtensionManifest = { + background?: { service_worker?: string; type?: string }; + permissions?: string[]; +}; + +function readManifest(): ExtensionManifest { + const path = resolve(process.cwd(), "assets/chrome-extension/manifest.json"); + return JSON.parse(readFileSync(path, "utf8")) as ExtensionManifest; +} + +describe("chrome extension manifest", () => { + it("keeps background worker configured as module", () => { + const manifest = readManifest(); + expect(manifest.background?.service_worker).toBe("background.js"); + expect(manifest.background?.type).toBe("module"); + }); + + it("includes resilience permissions", () => { + const permissions = readManifest().permissions ?? []; + expect(permissions).toContain("alarms"); + expect(permissions).toContain("webNavigation"); + expect(permissions).toContain("storage"); + expect(permissions).toContain("debugger"); + }); +});