diff --git a/.changeset/bright-clients-route.md b/.changeset/bright-clients-route.md new file mode 100644 index 0000000..b9416a2 --- /dev/null +++ b/.changeset/bright-clients-route.md @@ -0,0 +1,9 @@ +--- +"@opencode-ai/browser-control": patch +--- + +Isolate CDP client state so concurrent clients retain their own auto-attach +settings, invalidate target aliases when ownership hides a tab, reject hidden +session routing, avoid arbitrary target fallback, and detach child targets when +their root disappears. Centralize target and alias routing so stale root and +child sessions fail closed. diff --git a/.changeset/calm-sessions-select.md b/.changeset/calm-sessions-select.md new file mode 100644 index 0000000..f85105e --- /dev/null +++ b/.changeset/calm-sessions-select.md @@ -0,0 +1,6 @@ +--- +"@opencode-ai/browser-control": patch +--- + +Accept `--session`, `-s`, and `BROWSER_CONTROL_SESSION` for session reset and +delete while retaining positional and current-session selection. diff --git a/.changeset/calm-spiders-connect.md b/.changeset/calm-spiders-connect.md new file mode 100644 index 0000000..8dbe893 --- /dev/null +++ b/.changeset/calm-spiders-connect.md @@ -0,0 +1,8 @@ +--- +"@opencode-ai/browser-control": patch +--- + +Prepare an unlisted Chrome Web Store extension with protocol-based relay +compatibility, deterministic packaging, and more reliable cold-start target +creation. Session reset and delete now recover relay-owned targets whose +debugger attachment was permanently lost during an extension update. diff --git a/.changeset/clean-recordings-stream.md b/.changeset/clean-recordings-stream.md new file mode 100644 index 0000000..7f6d5b4 --- /dev/null +++ b/.changeset/clean-recordings-stream.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/browser-control": minor +--- + +Stream tab-capture recordings to disk with intrinsically framed, sequenced binary messages instead of buffering complete recordings in relay memory. diff --git a/.changeset/shiny-forms-open.md b/.changeset/shiny-forms-open.md new file mode 100644 index 0000000..614f0c9 --- /dev/null +++ b/.changeset/shiny-forms-open.md @@ -0,0 +1,6 @@ +--- +"@opencode-ai/browser-control": patch +--- + +Search recursively through open shadow roots in `fillInput` and `fillInputs`, +and report the closed-root boundary when a selector has no match. diff --git a/.changeset/tidy-inputs-focus.md b/.changeset/tidy-inputs-focus.md new file mode 100644 index 0000000..cc20147 --- /dev/null +++ b/.changeset/tidy-inputs-focus.md @@ -0,0 +1,6 @@ +--- +"@opencode-ai/browser-control": patch +--- + +Preserve page focus while `fillInput` and `fillInputs` update controlled fields, +preventing focus-sensitive extensions from making the target unresponsive. diff --git a/.changeset/warm-spoons-reconnect.md b/.changeset/warm-spoons-reconnect.md new file mode 100644 index 0000000..c7f6edd --- /dev/null +++ b/.changeset/warm-spoons-reconnect.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/browser-control": patch +--- + +Keep the Chrome extension connected across idle service-worker suspension, repair missing reconnect alarms whenever the worker starts, start the managed relay correctly when MCP runs through a package-manager bin symlink, and make Doctor compare the runtime extension with the manifest shipped in the npm package. diff --git a/AGENTS.md b/AGENTS.md index 06121a8..e07ce39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,7 +209,7 @@ local Node relay. - Extension shim changes require reloading the unpacked extension once in Brave. - Relay-only changes should not require reloading the extension. - Use `termctrl` for long-running relay sessions during testing. -- Run `SMOKE_CASE=local-forms,local-cart,local-checkout,reconnect-evaluate,redirect-reconnect-evaluate,execute-target-url,execute-page-recovery,execute-page-detach-recovery,execute-fill-helpers,execute-snapshot-refs,handoff-navigation,handoff-cross-tab,handoff-target-detach,oopif-reconnect,dedicated-worker,network-capture,session-download-capability,execute-ghost-cursor,session-isolation,multi-client,stale-client-checkout,raw-first-checkout pnpm smoke` +- Run `SMOKE_CASE=local-forms,local-cart,local-checkout,reconnect-evaluate,redirect-reconnect-evaluate,session-missing-selector,execute-target-url,execute-page-recovery,execute-page-detach-recovery,execute-fill-helpers,execute-snapshot-refs,handoff-navigation,handoff-cross-tab,handoff-target-detach,oopif-reconnect,dedicated-worker,network-capture,session-download-capability,execute-ghost-cursor,session-isolation,multi-client,stale-client-checkout,raw-first-checkout pnpm smoke` before claiming the current smoke set is green. - CDP target visibility is scoped per client (`src/cdp-visibility.ts`): session-owned tabs are announced and their events delivered only to that @@ -242,11 +242,14 @@ browser-control skill - Load `extension/dist` as the unpacked extension. - The relay listens on `127.0.0.1:19989` by default. -- Current shim version is `0.0.23` and extension protocol version is `1`. -- Store and npm versions may differ when protocol `1` remains compatible. +- Current shim version is `0.0.23` and extension protocol version is `2`. +- Store and npm versions may differ while their extension protocol versions remain compatible. - On socket open the shim sends `hello` and then re-announces every tab it still has `chrome.debugger` attached to (`debugger.attached` events), so a restarted relay rebuilds its target registry without the user re-clicking the toolbar. +- Repair the reconnect alarm whenever the MV3 worker starts and send heartbeat + traffic every 20 seconds while its relay socket is open. Chrome may clear + persisted alarms and retires idle extension workers even with an open socket. - The relay dedupes target announcements per CDP client by targetId: a re-announce under a new sessionId emits `Target.detachedFromTarget` for the old session first. Never announce the same targetId twice to one client diff --git a/PLAN.md b/PLAN.md index 20548bd..5e110b0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -17,8 +17,8 @@ Agent / MCP client / CLI -> user's Chromium-family browser tabs ``` -The end-to-end path is working. Current work should finish CDP routing -correctness, simplify the relay, and make recording robust. New features +The end-to-end path is working. Current work should simplify the relay and make +recording robust. New features should not weaken the code-first interface or move behavior into the extension without a concrete browser-API reason. @@ -27,60 +27,39 @@ without a concrete browser-API reason. Work these in order unless field evidence changes the priority. Every item should land with unit or smoke evidence appropriate to the behavior. -### 1. Finish CDP routing correctness - -- Emit child `Target.detachedFromTarget` events when a root target detaches so - clients cannot retain orphaned child sessions. -- Remove arbitrary-first-target fallbacks from `Target.getTargetInfo` and other - sessionless CDP routing. -- Store `autoAttachParams` per client instead of using global - last-writer-wins state. - -Verification: - -- Extend reconnect, OOPIF, and multi-client smoke cases to cover root detach and - conflicting client auto-attach settings. - -### 2. Split the relay into testable responsibilities +### 1. Split the relay into testable responsibilities Extract cohesive modules from `makeRelay` without changing the protocol: -- `CdpRouter`: command routing, guardrails, and compatibility shims. +- Deepen `CdpRouter` with command classification, guardrails, and compatibility + shims. - `ExtensionEventHandler`: extension event decoding and registry mutation. -- `CdpClientPool`: client sockets, per-client attachment sets, and connection - generations. + +`CdpClientPool` now owns client sockets, per-client attachment sets, aliases, +auto-attach settings, and connection generations. `CdpRouter` now owns +client-relative visibility, target inventory, target and alias resolution, and +exact root-versus-child Chrome session routing. The goal is browser-free testing of routing and lifecycle behavior, not smaller files for their own sake. Keep orchestration in `makeRelay` and avoid exposing internal protocol details to the CLI or MCP server. -### 3. Stream recordings with unambiguous framing +Verification: + +- Extend reconnect, OOPIF, and multi-client smoke cases to cover root detach and + conflicting client auto-attach settings. + +### 2. Extend recording surfaces -- Include the tab id and sequence number in each binary websocket frame instead - of pairing a JSON metadata frame with the next binary frame. -- Stream chunks to disk instead of buffering complete recordings in relay - memory. - Add MCP recording start, stop, status, and cancel tools after the relay path is robust. - Build the flight-recorder ring buffer only after chunk streaming lands. Verification: -- Exercise interleaved recordings and a recording larger than the intended - in-memory bound. - Confirm CLI and MCP recording behavior match. -### 4. Resolve smaller agent-experience gaps - -- Make `fillInput` and `fillInputs` search open shadow roots recursively. Closed - shadow roots remain unsupported. When no DOM match exists, the error should - explain the helper's boundary and suggest `locator.fill()` if Playwright can - resolve the field. The current zero-match error was observed on - api.data.gov's signup component. -- Accept `--session` and `-s` on `session reset` and `session delete`, matching - `execute` and `journal`. -- Add smoke coverage proving explicit missing session ids fail for both - `--session x` and `BROWSER_CONTROL_SESSION=x`. +### 3. Resolve smaller agent-experience gaps Verification: @@ -92,6 +71,46 @@ Verification: ## Recently Shipped +### Tab-capture recordings stream with intrinsic framing + +Extension protocol `2` sends each recording chunk as a sequenced `BCRD` binary +frame containing its tab id. The relay validates framing and sequence, bounds +pending writes, streams each tab to an adjacent temporary file, and atomically +renames complete recordings. Interleaving, oversized queues, malformed frames, +and output larger than a single frame have direct coverage. + +### Fill helpers traverse open shadow roots + +String selectors passed to `fillInput` and `fillInputs` now search recursively +through open shadow roots. A zero-match error explains that closed roots remain +unavailable and suggests `locator.fill()` when Playwright can resolve the field. + +### Session lifecycle selectors are consistent + +`session reset` and `session delete` accept positional ids, `--session`/`-s`, +and `BROWSER_CONTROL_SESSION` before falling back to the saved current session. +Smoke coverage verifies explicit missing flag and environment ids fail instead +of falling back to the saved current session. + +### CDP routing fails closed + +Identity-free `Target.getTargetInfo` no longer returns an arbitrary tab, and +otherwise-unhandled sessionless CDP commands require an explicit session. All +explicit target and session routing now rechecks client visibility, including +session-scoped auto-attach. Root teardown emits each announced child detach +before detaching the root so clients cannot retain orphaned sessions. The +browser-free `CdpRouter` module keeps these visibility, alias, and generation +rules out of relay transport orchestration. + +### CDP client state is isolated per connection + +`CdpClientPool` now owns each CDP client's session identity, target +announcements, aliases, auto-attach settings, and idle-reset generation. New +targets use the originating client's auto-attach settings instead of global +last-writer-wins state. Ownership visibility changes also invalidate target +aliases, so a client cannot continue routing commands to a tab after it becomes +hidden. + ### Wedged session pages recover or fail fast A 2026-07-09 field failure left a relay-owned page open but unusable after its @@ -437,8 +456,7 @@ restarts can be distinguished from session eviction. - Native `locator.fill()` can hang on login-style fields when installed browser extensions inject focus handlers or overlays. `fillInput` is the explicit fallback for ordinary `input` and `textarea` elements. -- `fillInput` currently uses `querySelector` semantics and cannot reach fields - in shadow roots. +- `fillInput` cannot reach fields inside closed shadow roots. - OOPIF behavior is guaranteed only by the current reconnect smoke scenarios. - Clipboard automation on insecure origins is not guaranteed. - Playwright download events and `download.saveAs()` are unavailable in diff --git a/extension/src/background.ts b/extension/src/background.ts index bf9e7f9..52c5244 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1,4 +1,5 @@ import { extensionProtocolVersion, parseExtensionCommand, type ExtensionCommand as ShimCommand, type JsonObject } from "../../src/protocol.ts" +import { encodeRecordingFrame } from "../../src/recording-protocol.ts" import type { OffscreenCancelRecordingResult, OffscreenOutgoingMessage, @@ -9,32 +10,29 @@ import type { import { isBrowserControlGroupTitle, isCurrentBrowserControlGroupTitle, isLegacyBrowserControlGroupTitle, shouldUngroupBrowserControlTab, tabGroupColor, tabGroupTitle } from "./tab-groups.ts" import { pageStatusFromJson } from "./page-status.ts" import { debuggerDetachedEvent } from "./debugger-detach.ts" +import { ensureReconnectAlarm, reconnectAlarmName, startSocketKeepAlive } from "./connection-lifecycle.ts" const relayHost = "127.0.0.1" const relayPort = 19989 const offscreenDocumentPath = "offscreen.html" +const maxRecordingSocketBufferedBytes = 16 * 1024 * 1024 let socket: WebSocket | undefined let connectionPromise: Promise | undefined let reconnectTimer: ReturnType | undefined let offscreenDocumentCreating: Promise | undefined -const activeRecordings = new Map() - -chrome.runtime.onInstalled.addListener(() => { - chrome.alarms.create("browser-control-reconnect", { periodInMinutes: 0.5 }) -}) - -chrome.runtime.onStartup.addListener(() => { - chrome.alarms.create("browser-control-reconnect", { periodInMinutes: 0.5 }) -}) chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name !== "browser-control-reconnect") { + if (alarm.name !== reconnectAlarmName) { return } void ensureConnection().catch(() => {}) }) +// Chrome can clear persisted alarms, so repair the reconnect wake-up whenever +// the MV3 service worker starts rather than only on install or browser startup. +void ensureReconnectAlarm(chrome.alarms).catch(() => {}) + chrome.action.onClicked.addListener((tab) => { if (tab.id) sendMessage({ method: "toolbar.clicked", params: { tabId: tab.id } }) }) @@ -72,9 +70,12 @@ chrome.tabs.onRemoved.addListener((tabId) => { sendMessage({ method: "tabs.removed", params: { tabId } }) }) -chrome.runtime.onMessage.addListener((message: unknown, sender) => { - handleRuntimeMessage(message, sender) - return false +chrome.runtime.onMessage.addListener((message: unknown, sender, sendResponse) => { + void handleRuntimeMessage(message, sender).then( + () => sendResponse({ success: true }), + (error: unknown) => sendResponse({ success: false, error: error instanceof Error ? error.message : String(error) }), + ) + return true }) connect() @@ -89,14 +90,23 @@ function startConnection(): WebSocket { reconnectTimer = undefined } const currentSocket = new WebSocket(`ws://${relayHost}:${relayPort}/extension`) + let stopKeepAlive: (() => void) | undefined socket = currentSocket currentSocket.onopen = () => { - void announceHelloAndAttachedTabs(currentSocket).catch((error) => reportAnnouncementFailure(currentSocket, error)) + void announceHelloAndAttachedTabs(currentSocket).then( + () => { + if (socket === currentSocket && currentSocket.readyState === WebSocket.OPEN) { + stopKeepAlive = startSocketKeepAlive(() => sendOnCurrentSocket(currentSocket, { method: "pong" })) + } + }, + (error) => reportAnnouncementFailure(currentSocket, error), + ) } currentSocket.onmessage = (event) => { void handleSocketMessage(currentSocket, event.data) } currentSocket.onclose = () => { + stopKeepAlive?.() if (socket !== currentSocket) { return } @@ -346,9 +356,6 @@ async function handleCommand(command: ShimCommand): Promise { async function startRecording(params: JsonObject | undefined): Promise { const tabId = numberParam(params, "tabId") - if (activeRecordings.has(tabId)) { - return { success: false, error: "Recording already in progress for this tab" } - } await ensureOffscreenDocument() const streamId = await getTabCaptureStreamId(tabId) const result = await chrome.runtime.sendMessage({ @@ -363,39 +370,25 @@ async function startRecording(params: JsonObject | undefined): Promise { const tabId = numberParam(params, "tabId") - if (!activeRecordings.has(tabId)) { - return { success: false, error: "No active recording for this tab" } - } const result = await chrome.runtime.sendMessage({ action: "recording.stop", tabId }) as OffscreenStopRecordingResult if (!result.success) { return { success: false, error: result.error } } - activeRecordings.delete(tabId) return { success: true, tabId: result.tabId, duration: result.duration } } async function statusRecording(params: JsonObject | undefined): Promise { const tabId = numberParam(params, "tabId") - const recording = activeRecordings.get(tabId) - if (!recording) { - return { isRecording: false, tabId } - } - try { - const result = await chrome.runtime.sendMessage({ action: "recording.status", tabId }) as OffscreenStatusRecordingResult - return { - isRecording: result.isRecording, - tabId, - ...(result.startedAt === undefined ? { startedAt: recording.startedAt } : { startedAt: result.startedAt }), - } - } catch { - activeRecordings.delete(tabId) - return { isRecording: false, tabId } + const result = await chrome.runtime.sendMessage({ action: "recording.status", tabId }) as OffscreenStatusRecordingResult + return { + isRecording: result.isRecording, + tabId, + ...(result.startedAt === undefined ? {} : { startedAt: result.startedAt }), } } @@ -405,11 +398,7 @@ async function cancelRecording(params: JsonObject | undefined): Promise { - if (!activeRecordings.has(tabId)) { - return { success: true } - } const result = await chrome.runtime.sendMessage({ action: "recording.cancel", tabId }) as OffscreenCancelRecordingResult - activeRecordings.delete(tabId) if (!result.success) { return { success: false, error: result.error } } @@ -419,15 +408,11 @@ async function cancelRecordingForTab(tabId: number): Promise { async function cleanupRecordingForTab(tabId: number): Promise { try { await cancelRecordingForTab(tabId) - } catch { - activeRecordings.delete(tabId) - } + } catch {} } async function cancelAllRecordings(): Promise { - await Promise.all(Array.from(activeRecordings.keys()).map(async (tabId) => { - await cleanupRecordingForTab(tabId) - })) + await chrome.runtime.sendMessage({ action: "recording.cancelAll" }).catch(() => {}) } async function ensureOffscreenDocument(): Promise { @@ -522,7 +507,7 @@ async function guardedUngroupBrowserControlTab(tabId: number): Promise { } } -function handleRuntimeMessage(message: unknown, sender: chrome.runtime.MessageSender): void { +async function handleRuntimeMessage(message: unknown, sender: chrome.runtime.MessageSender): Promise { if (!message || typeof message !== "object" || Array.isArray(message)) { return } @@ -541,18 +526,15 @@ function handleRuntimeMessage(message: unknown, sender: chrome.runtime.MessageSe } const offscreenMessage = message as OffscreenOutgoingMessage if (offscreenMessage.action === "recording.chunk") { - if (offscreenMessage.data) { - sendMessage({ method: "recording.data", params: { tabId: offscreenMessage.tabId } }) - sendBinary(Uint8Array.from(offscreenMessage.data)) - return - } - if (offscreenMessage.final) { - sendMessage({ method: "recording.data", params: { tabId: offscreenMessage.tabId, final: true } }) - } + await sendBinaryAfterConnection(encodeRecordingFrame({ + tabId: offscreenMessage.tabId, + sequence: offscreenMessage.sequence, + final: offscreenMessage.final, + payload: offscreenMessage.final ? new Uint8Array() : decodeBase64(offscreenMessage.dataBase64), + })) return } if (offscreenMessage.action === "recording.cancelled") { - activeRecordings.delete(offscreenMessage.tabId) sendMessage({ method: "recording.cancelled", params: { tabId: offscreenMessage.tabId } }) } } @@ -580,10 +562,26 @@ async function sendMessageAfterConnection(message: JsonObject): Promise { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)) } -function sendBinary(data: Uint8Array): void { - if (socket?.readyState === WebSocket.OPEN) { - socket.send(data.buffer) +async function sendBinaryAfterConnection(data: Uint8Array): Promise { + if (socket?.readyState !== WebSocket.OPEN) await ensureConnection() + const currentSocket = socket + if (currentSocket?.readyState !== WebSocket.OPEN) throw new Error("Browser Control relay is not connected") + const deadline = Date.now() + 30_000 + while (currentSocket.bufferedAmount + data.byteLength > maxRecordingSocketBufferedBytes) { + await new Promise((resolve) => setTimeout(resolve, 10)) + if (socket !== currentSocket || currentSocket.readyState !== WebSocket.OPEN) { + throw new Error("Browser Control relay disconnected while sending recording data") + } + if (Date.now() >= deadline) throw new Error("Timed out sending recording data to the Browser Control relay") } + currentSocket.send(data.buffer) +} + +function decodeBase64(value: string): Uint8Array { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return bytes } function isAlreadyAttachedError(error: unknown): boolean { diff --git a/extension/src/connection-lifecycle.ts b/extension/src/connection-lifecycle.ts new file mode 100644 index 0000000..078b878 --- /dev/null +++ b/extension/src/connection-lifecycle.ts @@ -0,0 +1,15 @@ +export const reconnectAlarmName = "browser-control-reconnect" +const reconnectAlarmPeriodMinutes = 0.5 +const socketKeepAliveIntervalMs = 20_000 + +type AlarmApi = Pick + +export async function ensureReconnectAlarm(alarms: AlarmApi): Promise { + if (await alarms.get(reconnectAlarmName)) return + await alarms.create(reconnectAlarmName, { periodInMinutes: reconnectAlarmPeriodMinutes }) +} + +export function startSocketKeepAlive(heartbeat: () => void): () => void { + const timer = setInterval(heartbeat, socketKeepAliveIntervalMs) + return () => clearInterval(timer) +} diff --git a/extension/src/offscreen.ts b/extension/src/offscreen.ts index af15f2f..eb235de 100644 --- a/extension/src/offscreen.ts +++ b/extension/src/offscreen.ts @@ -2,6 +2,7 @@ import type { ChromeTabCaptureAudioConstraints, ChromeTabCaptureVideoConstraints, OffscreenCancelRecordingMessage, + OffscreenCancelAllRecordingsResult, OffscreenCancelRecordingResult, OffscreenMessage, OffscreenResult, @@ -12,18 +13,23 @@ import type { OffscreenStopRecordingMessage, OffscreenStopRecordingResult, } from "./recording-types.ts" +import { maxRecordingFramePayloadBytes } from "../../src/recording-protocol.ts" type RecordingState = { readonly recorder: MediaRecorder readonly stream: MediaStream - readonly pendingChunks: Set> readonly startedAt: number readonly tabId: number + nextSequence: number + sendTail: Promise + sendError?: Error + cancelled: boolean } const recordings = new Map() -chrome.runtime.onMessage.addListener((message: OffscreenMessage, _sender, sendResponse) => { +chrome.runtime.onMessage.addListener((message: unknown, _sender, sendResponse) => { + if (!isOffscreenMessage(message)) return false void handleMessage(message).then(sendResponse) return true }) @@ -38,7 +44,19 @@ async function handleMessage(message: OffscreenMessage): Promise { @@ -75,22 +93,41 @@ async function handleStartRecording(message: OffscreenStartRecordingMessage): Pr audioBitsPerSecond: message.audioBitsPerSecond, }) const startedAt = Date.now() - const pendingChunks = new Set>() + const recording: RecordingState = { + recorder, + stream, + startedAt, + tabId: message.tabId, + nextSequence: 0, + sendTail: Promise.resolve(), + cancelled: false, + } recorder.ondataavailable = (event) => { - if (event.data.size === 0) { + if (event.data.size === 0 || recording.sendError || recording.cancelled) { return } - const pendingChunk = event.data.arrayBuffer().then((arrayBuffer) => { - chrome.runtime.sendMessage({ - action: "recording.chunk", - tabId: message.tabId, - data: Array.from(new Uint8Array(arrayBuffer)), - }) - }).finally(() => { - pendingChunks.delete(pendingChunk) + if (recorder.state === "recording") recorder.pause() + recording.sendTail = recording.sendTail.then(async () => { + try { + for (let offset = 0; offset < event.data.size; offset += maxRecordingFramePayloadBytes) { + const dataBase64 = await blobToBase64(event.data.slice(offset, offset + maxRecordingFramePayloadBytes)) + const result = await chrome.runtime.sendMessage({ + action: "recording.chunk", + tabId: message.tabId, + sequence: recording.nextSequence++, + final: false, + dataBase64, + }) as { readonly success: boolean; readonly error?: string } + if (!result.success) throw new Error(result.error ?? "Could not send recording chunk") + } + } catch (error) { + recording.sendError = error instanceof Error ? error : new Error(String(error)) + handleCancelRecordingForTab(message.tabId) + } finally { + if (!recording.sendError && recorder.state === "paused") recorder.resume() + } }) - pendingChunks.add(pendingChunk) } recorder.onerror = () => { handleCancelRecordingForTab(message.tabId) @@ -107,7 +144,7 @@ async function handleStartRecording(message: OffscreenStartRecordingMessage): Pr recorder.start(1_000) }) - recordings.set(message.tabId, { recorder, stream, pendingChunks, startedAt, tabId: message.tabId }) + recordings.set(message.tabId, recording) return { success: true, tabId: message.tabId, startedAt, mimeType: recorder.mimeType || mimeType || "video/webm" } } catch (error) { stream?.getTracks().map((track) => { @@ -139,15 +176,23 @@ async function handleStopRecording(message: OffscreenStopRecordingMessage): Prom } recording.recorder.stop() }) - await Promise.allSettled(recording.pendingChunks) + await recording.sendTail + if (recording.sendError) throw recording.sendError recording.stream.getTracks().map((track) => { track.stop() return undefined }) recordings.delete(message.tabId) - chrome.runtime.sendMessage({ action: "recording.chunk", tabId: message.tabId, final: true }) + const finalResult = await chrome.runtime.sendMessage({ + action: "recording.chunk", + tabId: message.tabId, + sequence: recording.nextSequence, + final: true, + }) as { readonly success: boolean; readonly error?: string } + if (!finalResult.success) throw new Error(finalResult.error ?? "Could not finish recording stream") return { success: true, tabId: message.tabId, duration: Date.now() - recording.startedAt } } catch (error) { + handleCancelRecordingForTab(message.tabId) return { success: false, error: error instanceof Error ? error.message : String(error) } } } @@ -158,7 +203,7 @@ function handleStatusRecording(message: OffscreenStatusRecordingMessage): Offscr return { isRecording: false, tabId: message.tabId } } return { - isRecording: recording.recorder.state === "recording", + isRecording: recording.recorder.state !== "inactive", tabId: message.tabId, startedAt: recording.startedAt, } @@ -168,12 +213,22 @@ function handleCancelRecording(message: OffscreenCancelRecordingMessage): Offscr return handleCancelRecordingForTab(message.tabId) } +function handleCancelAllRecordings(): OffscreenCancelAllRecordingsResult { + let failure: OffscreenCancelAllRecordingsResult | undefined + for (const tabId of Array.from(recordings.keys())) { + const result = handleCancelRecordingForTab(tabId) + if (!result.success) failure ??= result + } + return failure ?? { success: true } +} + function handleCancelRecordingForTab(tabId: number): OffscreenCancelRecordingResult { const recording = recordings.get(tabId) if (!recording) { return { success: true, tabId } } try { + recording.cancelled = true if (recording.recorder.state !== "inactive") { recording.recorder.stop() } @@ -194,3 +249,19 @@ function selectWebmMimeType(): string { return MediaRecorder.isTypeSupported(mimeType) }) ?? "" } + +function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = reader.result + if (typeof result !== "string") { + reject(new Error("Could not encode recording chunk")) + return + } + resolve(result.slice(result.indexOf(",") + 1)) + } + reader.onerror = () => reject(reader.error ?? new Error("Could not read recording chunk")) + reader.readAsDataURL(blob) + }) +} diff --git a/extension/src/recording-types.ts b/extension/src/recording-types.ts index a0d24c5..196cc2e 100644 --- a/extension/src/recording-types.ts +++ b/extension/src/recording-types.ts @@ -39,11 +39,16 @@ export type OffscreenCancelRecordingMessage = { readonly tabId: number } +export type OffscreenCancelAllRecordingsMessage = { + readonly action: "recording.cancelAll" +} + export type OffscreenMessage = | OffscreenStartRecordingMessage | OffscreenStopRecordingMessage | OffscreenStatusRecordingMessage | OffscreenCancelRecordingMessage + | OffscreenCancelAllRecordingsMessage export type OffscreenStartRecordingResult = | { @@ -84,18 +89,31 @@ export type OffscreenCancelRecordingResult = readonly error: string } +export type OffscreenCancelAllRecordingsResult = + | { readonly success: true } + | { readonly success: false; readonly error: string } + export type OffscreenResult = | OffscreenStartRecordingResult | OffscreenStopRecordingResult | OffscreenStatusRecordingResult | OffscreenCancelRecordingResult + | OffscreenCancelAllRecordingsResult -export type OffscreenRecordingChunkMessage = { - readonly action: "recording.chunk" - readonly tabId: number - readonly data?: readonly number[] - readonly final?: boolean -} +export type OffscreenRecordingChunkMessage = + | { + readonly action: "recording.chunk" + readonly tabId: number + readonly sequence: number + readonly final: false + readonly dataBase64: string + } + | { + readonly action: "recording.chunk" + readonly tabId: number + readonly sequence: number + readonly final: true + } export type OffscreenRecordingCancelledMessage = { readonly action: "recording.cancelled" diff --git a/scripts/opencode-jr-developer-onboarding.js b/scripts/opencode-jr-developer-onboarding.js new file mode 100644 index 0000000..d5c8e54 --- /dev/null +++ b/scripts/opencode-jr-developer-onboarding.js @@ -0,0 +1,19 @@ +await fillInputs(page, [ + { + selector: page.getByRole("textbox", { name: "Account Name" }), + value: "OpenCode Jr", + }, + { + selector: page.getByRole("textbox", { name: /Describe all of your use cases/i }), + value: "OpenCode Jr is an automated responder for the OpenCode project. It monitors public posts that structurally mention the main @opencode account, admits requests only when the author is a stable allowlisted Anomaly team member, reads the relevant public conversation history for context, and posts one concise public reply from @OpenCodeJr. We use the official X API only. We do not access Direct Messages, sell or redistribute X data, follow users, like posts, delete posts, or perform unsolicited autonomous posting. Stored provider evidence is limited to the durable identifiers and public text required for request processing, thread continuity, reconnect backfill, and duplicate-reply prevention.", + }, +]) +await page.bringToFront() +await handoff( + "Review X's Developer Agreement and policies. If you accept them, check the three agreement boxes and click Submit. Complete any CAPTCHA or verification yourself. When the Developer Console dashboard is visible, use the in-page I'm done, continue control.", + { timeoutMs: 1_800_000 }, +) +if (!page.url().startsWith("https://console.x.com/")) { + throw new Error(`Developer onboarding left the X console: ${page.url()}`) +} +return { url: page.url(), title: await page.title() } diff --git a/scripts/opencode-jr-signup-handoff.js b/scripts/opencode-jr-signup-handoff.js new file mode 100644 index 0000000..304f8a3 --- /dev/null +++ b/scripts/opencode-jr-signup-handoff.js @@ -0,0 +1,24 @@ +await page.goto("https://x.com/home", { waitUntil: "domcontentloaded", timeout: 60_000 }) +await page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').waitFor({ timeout: 30_000 }) +await page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').click() +await page.getByText(/Add an existing account/i).first().click() +await page.waitForURL(/x\.com\/i\/(flow\/login|jf\/onboarding)/, { timeout: 30_000 }) +await page.evaluate(() => { + const markInputs = () => { + for (const input of document.querySelectorAll("input")) { + input.setAttribute("data-1p-ignore", "true") + input.setAttribute("data-lpignore", "true") + } + } + markInputs() + new MutationObserver(markInputs).observe(document.documentElement, { childList: true, subtree: true }) +}) +await page.bringToFront() +await handoff( + "Create a new X account through account switching. Use display name OpenCode Jr and desired handle @OpenCodeJr. Enter private email/phone, birthday, CAPTCHA, verification code, password, and 2FA yourself. Do not submit the final Create account or Sign up action. Stop on the final review screen, then use the in-page I'm done, continue control.", + { timeoutMs: 1_800_000 }, +) +return { + url: page.url(), + finalCreateVisible: await page.getByRole("button", { name: /^(Sign up|Create account)$/i }).count() > 0, +} diff --git a/scripts/smoke.ts b/scripts/smoke.ts index b4ea827..9c48661 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -453,6 +453,29 @@ const cases: SmokeCase[] = [ ) }), }, + { + name: "session-missing-selector", + run: Effect.fnUntraced(function* () { + const missing = `missing-${Date.now()}` + const flagOutcome = yield* Effect.result(runBrowserControl(["session", "reset", "--session", missing])) + if (flagOutcome._tag === "Success") { + return yield* Effect.fail(new Error(`explicit missing --session unexpectedly succeeded: ${flagOutcome.success}`)) + } + const flagError = flagOutcome.failure + if (!flagError.message.includes(`Session not found: ${missing}`)) { + return yield* Effect.fail(new Error(`explicit missing --session returned the wrong error: ${flagError.message}`)) + } + const environmentOutcome = yield* Effect.result(runBrowserControl(["session", "delete"], { sessionId: missing })) + if (environmentOutcome._tag === "Success") { + return yield* Effect.fail(new Error(`explicit missing BROWSER_CONTROL_SESSION unexpectedly succeeded: ${environmentOutcome.success}`)) + } + const environmentError = environmentOutcome.failure + if (!environmentError.message.includes(`Session not found: ${missing}`)) { + return yield* Effect.fail(new Error(`explicit missing BROWSER_CONTROL_SESSION returned the wrong error: ${environmentError.message}`)) + } + return "explicit missing session selectors fail closed" + }), + }, { name: "execute-target-url", run: Effect.fnUntraced(function* (page) { @@ -571,22 +594,93 @@ const cases: SmokeCase[] = [ smokeSession, ` await page.setContent('') -await fillInput('#one', 'alpha') +await page.evaluate(() => { + document.documentElement.dataset.fillFocusEvents = '0' + document.addEventListener('focusin', () => { + document.documentElement.dataset.fillFocusEvents = String(Number(document.documentElement.dataset.fillFocusEvents ?? '0') + 1) + }) + const outer = document.createElement('div') + const outerRoot = outer.attachShadow({ mode: 'open' }) + const inner = document.createElement('div') + const innerRoot = inner.attachShadow({ mode: 'open' }) + const shadowInput = document.createElement('input') + shadowInput.id = 'shadow-input' + innerRoot.append(shadowInput) + outerRoot.append(inner) + document.body.append(outer) + const closed = document.createElement('div') + closed.attachShadow({ mode: 'closed' }).append(document.createElement('input')) + document.body.append(closed) + + const controlled = document.createElement('input') + controlled.id = 'controlled' + const nativeValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value') + if (!nativeValue?.get || !nativeValue.set) throw new Error('missing native input value descriptor') + let trackedValue = '' + Object.defineProperty(controlled, 'value', { + configurable: true, + get: () => nativeValue.get?.call(controlled), + set: (value) => { + trackedValue = String(value) + nativeValue.set?.call(controlled, value) + }, + }) + controlled.addEventListener('input', () => { + trackedValue = String(nativeValue.get?.call(controlled) ?? '') + controlled.dataset.state = trackedValue + queueMicrotask(() => { + controlled.value = trackedValue + }) + }) + setTimeout(() => document.body.append(controlled), 100) + + const frame = document.createElement('iframe') + frame.id = 'fixture-frame' + frame.srcdoc = '' + document.body.append(frame) +}) +await fillInput(page.locator('#one'), 'alpha') +await fillInput('#shadow-input', 'delta') +await fillInput(page.locator('#controlled'), 'epsilon') await fillInputs(page, [ { selector: page.getByRole('textbox').nth(1), value: 'beta' }, { selector: '#three', value: 'gamma' }, ]) +const fixtureFrame = page.frames().find((frame) => frame !== page.mainFrame()) +if (!fixtureFrame) throw new Error('fixture iframe did not attach') +await fixtureFrame.locator('html').evaluate((element) => { + element.dataset.fillFocusEvents = '0' + element.addEventListener('focusin', () => { + element.dataset.fillFocusEvents = String(Number(element.dataset.fillFocusEvents ?? '0') + 1) + }) +}) +await fillInput(fixtureFrame.locator('#frame-input'), 'zeta') +let closedBoundary +try { + await fillInput('#closed-input', 'should-not-appear') +} catch (error) { + closedBoundary = error instanceof Error ? error.message : String(error) +} const values = await page.evaluate(() => ({ one: document.querySelector('#one')?.value, two: document.querySelector('#two')?.value, three: document.querySelector('#three')?.value, + shadow: document.querySelector('div')?.shadowRoot?.querySelector('div')?.shadowRoot?.querySelector('input')?.value, + controlled: document.querySelector('#controlled')?.value, + controlledState: document.querySelector('#controlled')?.dataset.state, + focusEvents: document.documentElement.dataset.fillFocusEvents, })) -return values +return { + ...values, + frame: await fixtureFrame.locator('#frame-input').inputValue(), + frameFocusEvents: await fixtureFrame.locator('html').getAttribute('data-fill-focus-events'), + closedBoundary, +} `, ], { retryOnTimeout: true }, ) - if (!output.includes("alpha") || !output.includes("beta") || !output.includes("gamma")) { + if (!output.includes("alpha") || !output.includes("beta") || !output.includes("gamma") || !output.includes("delta") || !output.includes("controlled: 'epsilon'") || !output.includes("controlledState: 'epsilon'") || !output.includes("frame: 'zeta'") || !output.includes("focusEvents: '0'") || !output.includes("frameFocusEvents: '0'") || !output.includes("closed shadow roots")) { return yield* Effect.fail(new Error(`execute fill helpers did not fill fields: ${output}`)) } return output.trim() @@ -1852,7 +1946,6 @@ const fillInput = Effect.fnUntraced(function* (locator: ReturnType PromiseLike, timeoutM type RunBrowserControlOptions = { readonly retryOnTimeout?: boolean + readonly sessionId?: string } function runBrowserControl(args: readonly string[], options: RunBrowserControlOptions = {}): Effect.Effect { - return runBrowserControlOnce(args).pipe( + return runBrowserControlOnce(args, options).pipe( Effect.catchIf( (error) => options.retryOnTimeout === true && isBrowserControlTimeout(error), - () => runBrowserControlOnce(args), + () => runBrowserControlOnce(args, options), ), ) } -function runBrowserControlOnce(args: readonly string[]): Effect.Effect { +function runBrowserControlOnce(args: readonly string[], options: RunBrowserControlOptions): Effect.Effect { return Effect.callback((resume) => { let completed = false const endpointPort = new URL(endpointUrl).port @@ -1980,6 +2071,7 @@ function runBrowserControlOnce(args: readonly string[]): Effect.Effect { - void terminateChild(child, closePromise, () => closed) + void terminateChildProcess({ child, exit: closePromise, graceMs: 1_000, isExited: () => closed }) }, options.timeoutMs) child.once("error", (cause) => { clearTimeout(timeout) @@ -373,7 +374,7 @@ function runChild(options: { stderrTruncated, })) }) - return Effect.promise(() => terminateChild(child, closePromise, () => closed)) + return Effect.promise(() => terminateChildProcess({ child, exit: closePromise, graceMs: 1_000, isExited: () => closed })) }) } @@ -384,28 +385,6 @@ function childEnvironment(profile: Readonly>): NodeJS.Pro return { ...inherited, ...profile } } -async function terminateChild(child: ReturnType, closePromise: Promise, isClosed: () => boolean): Promise { - if (isClosed()) return - signalChild(child, "SIGTERM") - await Promise.race([closePromise, new Promise((resolve) => setTimeout(resolve, 1_000))]) - if (!isClosed()) { - signalChild(child, "SIGKILL") - await closePromise - } -} - -function signalChild(child: ReturnType, signal: NodeJS.Signals): void { - if (process.platform !== "win32" && child.pid !== undefined) { - try { - process.kill(-child.pid, signal) - return - } catch { - // The child may have exited between the state check and the signal. - } - } - child.kill(signal) -} - function isNodeError(value: unknown): value is NodeJS.ErrnoException { return value instanceof Error && "code" in value } diff --git a/src/cdp-client-pool.ts b/src/cdp-client-pool.ts new file mode 100644 index 0000000..8da2ea9 --- /dev/null +++ b/src/cdp-client-pool.ts @@ -0,0 +1,115 @@ +import { createClientTargetAnnouncements, type ClientCdpSessionAlias, type ClientTargetAnnouncements } from "./cdp-shims.ts" +import type { JsonObject } from "./protocol.ts" +import type { ChildTarget, ConnectedTarget } from "./relay-types.ts" + +type CdpClientState = { + readonly aliases: Map + readonly announcements: ClientTargetAnnouncements + readonly browserControlSessionId?: string + autoAttachParams?: JsonObject +} + +export class CdpClientPool implements Iterable { + private readonly states = new Map() + private nextAliasId = 1 + private connectionGeneration = 0 + + register(client: Client, browserControlSessionId?: string): void { + if (this.states.has(client)) throw new Error("CDP client is already registered") + this.states.set(client, { + aliases: new Map(), + announcements: createClientTargetAnnouncements(), + ...(browserControlSessionId ? { browserControlSessionId } : {}), + }) + this.connectionGeneration += 1 + } + + unregister(client: Client): number | undefined { + if (!this.states.delete(client) || this.states.size !== 0) return undefined + return ++this.connectionGeneration + } + + get size(): number { + return this.states.size + } + + [Symbol.iterator](): IterableIterator { + return this.states.keys() + } + + sessionId(client: Client): string | undefined { + return this.states.get(client)?.browserControlSessionId + } + + announcements(client: Client): ClientTargetAnnouncements { + return this.requireState(client).announcements + } + + setAutoAttachParams(client: Client, params: JsonObject | undefined): void { + const state = this.requireState(client) + if (params === undefined) delete state.autoAttachParams + else state.autoAttachParams = params + } + + autoAttachParams(client: Client): JsonObject | undefined { + return this.states.get(client)?.autoAttachParams + } + + isCurrentIdleGeneration(generation: number): boolean { + return this.states.size === 0 && this.connectionGeneration === generation + } + + createBrowserAlias(client: Client): string { + const aliasId = this.nextAlias("browser") + this.requireState(client).aliases.set(aliasId, { kind: "browser" }) + return aliasId + } + + createTargetAlias(client: Client, target: ConnectedTarget | ChildTarget, rootSessionId: string | undefined): string { + const aliasId = this.nextAlias("session") + this.requireState(client).aliases.set(aliasId, { + kind: "target", + tabId: target.tabId, + targetId: target.targetInfo.targetId, + ...(target.sessionId === rootSessionId ? {} : { chromeSessionId: target.sessionId }), + }) + return aliasId + } + + alias(client: Client, aliasId: string): ClientCdpSessionAlias | undefined { + return this.states.get(client)?.aliases.get(aliasId) + } + + deleteAlias(client: Client, aliasId: string): boolean { + return this.states.get(client)?.aliases.delete(aliasId) ?? false + } + + removeTargetAliases(matches: (alias: Extract) => boolean): void { + for (const state of this.states.values()) { + removeTargetAliases(state, matches) + } + } + + removeClientTargetAliases(client: Client, matches: (alias: Extract) => boolean): void { + removeTargetAliases(this.requireState(client), matches) + } + + private nextAlias(kind: "browser" | "session"): string { + return `bc-client-${kind}-${this.nextAliasId++}` + } + + private requireState(client: Client): CdpClientState { + const state = this.states.get(client) + if (!state) throw new Error("CDP client is not registered") + return state + } +} + +function removeTargetAliases( + state: CdpClientState, + matches: (alias: Extract) => boolean, +): void { + for (const [aliasId, alias] of state.aliases) { + if (alias.kind === "target" && matches(alias)) state.aliases.delete(aliasId) + } +} diff --git a/src/cdp-router.ts b/src/cdp-router.ts new file mode 100644 index 0000000..4bd7356 --- /dev/null +++ b/src/cdp-router.ts @@ -0,0 +1,115 @@ +import type { CdpClientPool } from "./cdp-client-pool.ts" +import { canClientSeeTarget } from "./cdp-visibility.ts" +import type { TargetInfo } from "./protocol.ts" +import { isRestrictedTarget } from "./relay-helpers.ts" +import type { ChildTarget, ConnectedTarget } from "./relay-types.ts" +import { shouldExposeChildTarget, type TargetRegistry } from "./target-registry.ts" + +export type CdpRoutedSession = { + readonly tabId: number + readonly rootSessionId?: string + readonly chromeSessionId?: string +} + +export class CdpRouter { + constructor( + private readonly clients: CdpClientPool, + private readonly registry: TargetRegistry, + ) {} + + canSeeTarget(client: Client, target: ConnectedTarget): boolean { + return this.canSessionSeeTarget(this.clients.sessionId(client), target) + } + + canSessionSeeTarget(clientSessionId: string | undefined, target: ConnectedTarget): boolean { + return canClientSeeTarget({ + clientSessionId, + targetOwnerSessionId: target.browserControlSessionId, + targetOwner: target.owner, + clientHasOwnedTarget: clientSessionId !== undefined && this.registry + .listRootTargets() + .some((candidate) => candidate.browserControlSessionId === clientSessionId), + }) + } + + canSeeTab(client: Client, tabId: number): boolean { + const rootTarget = this.registry.tabTargets.get(tabId) + return rootTarget ? this.canSeeTarget(client, rootTarget) : false + } + + singleVisibleRoot(client: Client): ConnectedTarget | undefined { + const visible = this.visibleRoots(client) + return visible.length === 1 ? visible[0] : undefined + } + + visibleRoots(client: Client): ConnectedTarget[] { + return this.registry.listRootTargets().filter((target) => this.canSeeTarget(client, target)) + } + + rootForSession(client: Client, sessionId: string): ConnectedTarget | undefined { + const target = this.registry.targets.get(sessionId) + return target && this.canSeeTarget(client, target) ? target : undefined + } + + targetForAttach(client: Client, targetId: string): ConnectedTarget | ChildTarget | undefined { + const root = this.registry.targetsByTargetId.get(targetId) + if (root) return this.canSeeTarget(client, root) ? root : undefined + const child = this.registry.childTargetsByTargetId.get(targetId) + return child && this.canSeeTab(client, child.tabId) ? child : undefined + } + + targetInfo(client: Client, options: { + readonly targetId?: string + readonly sessionId?: string + }): ConnectedTarget | ChildTarget | undefined { + const alias = options.sessionId ? this.clients.alias(client, options.sessionId) : undefined + const aliasedTargetId = alias?.kind === "target" ? alias.targetId : undefined + const target = (options.targetId + ? this.registry.targetsByTargetId.get(options.targetId) ?? this.registry.childTargetsByTargetId.get(options.targetId) + : undefined) ?? + (aliasedTargetId + ? this.registry.targetsByTargetId.get(aliasedTargetId) ?? this.registry.childTargetsByTargetId.get(aliasedTargetId) + : undefined) ?? + (options.sessionId + ? this.registry.targets.get(options.sessionId) ?? this.registry.childTargets.get(options.sessionId) + : undefined) + if (!target) return undefined + return "owner" in target + ? this.canSeeTarget(client, target) ? target : undefined + : this.canSeeTab(client, target.tabId) ? target : undefined + } + + session(client: Client, requestedSessionId: string): CdpRoutedSession | undefined { + const sessionAlias = this.clients.alias(client, requestedSessionId) + if (sessionAlias?.kind === "browser") return undefined + const alias = sessionAlias?.kind === "target" ? sessionAlias : undefined + const target = alias + ? this.registry.targetsByTargetId.get(alias.targetId) ?? this.registry.childTargetsByTargetId.get(alias.targetId) + : this.registry.targets.get(requestedSessionId) ?? this.registry.childTargets.get(requestedSessionId) + if (!target || !this.canSeeTab(client, target.tabId)) return undefined + const rootSessionId = this.registry.tabTargets.get(target.tabId)?.sessionId + if (!rootSessionId) return undefined + const expectedChromeSessionId = "owner" in target ? undefined : target.sessionId + if (alias && (alias.tabId !== target.tabId || alias.chromeSessionId !== expectedChromeSessionId)) return undefined + return { + tabId: target.tabId, + rootSessionId, + ...(expectedChromeSessionId ? { chromeSessionId: expectedChromeSessionId } : {}), + } + } + + pruneInvisibleAliases(client: Client, tabIds: Iterable): void { + const invisibleTabIds = new Set(Array.from(tabIds).filter((tabId) => !this.canSeeTab(client, tabId))) + if (invisibleTabIds.size === 0) return + this.clients.removeClientTargetAliases(client, (alias) => invisibleTabIds.has(alias.tabId)) + } + + visibleTargetInfos(client: Client): TargetInfo[] { + return this.registry.allTargetInfos({ + isRestrictedTarget, + isVisibleTarget: (target) => { + return this.canSeeTab(client, target.tabId) && ("owner" in target || shouldExposeChildTarget(target)) + }, + }) + } +} diff --git a/src/cdp-shims.ts b/src/cdp-shims.ts index b28c02c..e870792 100644 --- a/src/cdp-shims.ts +++ b/src/cdp-shims.ts @@ -23,32 +23,6 @@ export function createClientTargetAnnouncements(): ClientTargetAnnouncements { return { sessions: new Set(), targets: new Map(), sessionTargets: new Map() } } -export function chromeSessionIdForClientRequest(options: { - readonly alias: { readonly chromeSessionId?: string } | undefined - readonly requestedSessionId: string | undefined - readonly rootSessionId: string | undefined -}): string | undefined { - if (options.alias) { - return options.alias.chromeSessionId - } - return options.requestedSessionId && options.requestedSessionId !== options.rootSessionId - ? options.requestedSessionId - : undefined -} - -export function removeClientTargetAliases( - clients: Iterable>, - matches: (alias: Extract) => boolean, -): void { - for (const aliases of clients) { - for (const [aliasId, alias] of aliases) { - if (alias.kind === "target" && matches(alias)) { - aliases.delete(aliasId) - } - } - } -} - export function hasAnnouncedSession(state: ClientTargetAnnouncements | undefined, sessionId: string): boolean { return state?.sessions.has(sessionId) ?? false } @@ -67,13 +41,13 @@ export function removeAnnouncedSession(state: ClientTargetAnnouncements | undefi export function sendAttachedToTarget(options: { readonly socket: WebSocket - readonly clientAnnouncements: ReadonlyMap + readonly announcements: ClientTargetAnnouncements readonly target: ConnectedTarget readonly onDuplicateTarget?: (duplicate: { readonly targetId: string; readonly oldSessionId: string; readonly newSessionId: string }) => void }): void { - const announcements = options.clientAnnouncements.get(options.socket) + const announcements = options.announcements const targetId = options.target.targetInfo.targetId - const existing = announcements?.targets.get(targetId) + const existing = announcements.targets.get(targetId) if (existing?.sessionId === options.target.sessionId) { return } @@ -87,9 +61,9 @@ export function sendAttachedToTarget(options: { }) } removeAnnouncedSession(announcements, options.target.sessionId) - announcements?.sessions.add(options.target.sessionId) - announcements?.targets.set(targetId, { sessionId: options.target.sessionId }) - announcements?.sessionTargets.set(options.target.sessionId, targetId) + announcements.sessions.add(options.target.sessionId) + announcements.targets.set(targetId, { sessionId: options.target.sessionId }) + announcements.sessionTargets.set(options.target.sessionId, targetId) sendCdpEvent(options.socket, { method: "Target.attachedToTarget", params: { @@ -102,13 +76,13 @@ export function sendAttachedToTarget(options: { export function sendAttachedToChildTarget(options: { readonly socket: WebSocket - readonly clientAnnouncements: ReadonlyMap + readonly announcements: ClientTargetAnnouncements readonly target: ChildTarget readonly onDuplicateTarget?: (duplicate: { readonly targetId: string; readonly oldSessionId: string; readonly newSessionId: string }) => void }): void { - const announcements = options.clientAnnouncements.get(options.socket) + const announcements = options.announcements const targetId = options.target.targetInfo.targetId - const existing = announcements?.targets.get(targetId) + const existing = announcements.targets.get(targetId) if (existing?.sessionId === options.target.sessionId) { return } @@ -122,9 +96,9 @@ export function sendAttachedToChildTarget(options: { }) } removeAnnouncedSession(announcements, options.target.sessionId) - announcements?.sessions.add(options.target.sessionId) - announcements?.targets.set(targetId, { sessionId: options.target.sessionId, parentSessionId: options.target.parentSessionId }) - announcements?.sessionTargets.set(options.target.sessionId, targetId) + announcements.sessions.add(options.target.sessionId) + announcements.targets.set(targetId, { sessionId: options.target.sessionId, parentSessionId: options.target.parentSessionId }) + announcements.sessionTargets.set(options.target.sessionId, targetId) sendCdpEvent(options.socket, { sessionId: options.target.parentSessionId, method: "Target.attachedToTarget", @@ -140,7 +114,7 @@ export function replayChildTargetsForParent(options: { readonly socket: WebSocket readonly parentSessionId: string readonly registry: TargetRegistry - readonly clientAnnouncements: ReadonlyMap + readonly announcements: ClientTargetAnnouncements readonly onDuplicateTarget?: (duplicate: { readonly targetId: string; readonly oldSessionId: string; readonly newSessionId: string }) => void }): void { for (const target of options.registry.childTargets.values()) { @@ -148,7 +122,7 @@ export function replayChildTargetsForParent(options: { replayFrameEventsForChild({ socket: options.socket, registry: options.registry, target }) sendAttachedToChildTarget({ socket: options.socket, - clientAnnouncements: options.clientAnnouncements, + announcements: options.announcements, target, ...(options.onDuplicateTarget ? { onDuplicateTarget: options.onDuplicateTarget } : {}), }) diff --git a/src/child-process.ts b/src/child-process.ts new file mode 100644 index 0000000..a7e416b --- /dev/null +++ b/src/child-process.ts @@ -0,0 +1,35 @@ +import type { ChildProcess } from "node:child_process" + +export async function terminateChildProcess(options: { + readonly child: ChildProcess + readonly exit: Promise + readonly graceMs: number + readonly isExited?: () => boolean +}): Promise { + const isExited = options.isExited ?? (() => options.child.exitCode !== null || options.child.signalCode !== null) + if (isExited()) return + signalChildProcess(options.child, "SIGTERM") + let timeout: ReturnType | undefined + await Promise.race([ + options.exit, + new Promise((resolve) => { + timeout = setTimeout(resolve, options.graceMs) + }), + ]) + if (timeout) clearTimeout(timeout) + if (isExited()) return + signalChildProcess(options.child, "SIGKILL") + await options.exit +} + +function signalChildProcess(child: ChildProcess, signal: NodeJS.Signals): void { + if (process.platform !== "win32" && child.pid !== undefined) { + try { + process.kill(-child.pid, signal) + return + } catch { + // The child may have exited between the state check and the signal. + } + } + child.kill(signal) +} diff --git a/src/cli-session-selector.ts b/src/cli-session-selector.ts new file mode 100644 index 0000000..782408b --- /dev/null +++ b/src/cli-session-selector.ts @@ -0,0 +1,10 @@ +export function resolveExplicitSessionSelector(options: { + readonly positional: string | undefined + readonly flag: string | undefined + readonly environment: string | undefined +}): string | undefined { + if (options.positional && options.flag) { + throw new Error("Use either a positional session id or --session, not both") + } + return options.flag ?? options.positional ?? options.environment +} diff --git a/src/cli.ts b/src/cli.ts index 4a256db..f34915c 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { startRelay } from "./relay.ts" import { defaultJournalBaseDir, formatJournalEntry, readJournalEntries } from "./session-journal.ts" import * as SessionStore from "./session-store.ts" import { browserControlVersion } from "./version.ts" +import { resolveExplicitSessionSelector } from "./cli-session-selector.ts" const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))) const sessionIdConfig = Config.option(Config.string("BROWSER_CONTROL_SESSION")) @@ -370,12 +371,17 @@ const sessionReset = Command.make( "reset", { id: Argument.string("id").pipe(Argument.optional), + session: Flag.string("session").pipe(Flag.optional, Flag.withAlias("s"), Flag.withDescription("Reset this Browser Control session id")), }, - Effect.fn("Cli.sessionReset")(function* ({ id }) { + Effect.fn("Cli.sessionReset")(function* ({ id, session }) { const relay = yield* RelayClient.Service - const sessionId = yield* resolveExistingSessionId(optionString(id)) - const session = yield* relay.sessionReset(sessionId) - yield* Console.log(session.id) + const sessionId = yield* resolveExistingSessionId(resolveExplicitSessionSelector({ + positional: optionString(id), + flag: optionString(session), + environment: Option.getOrUndefined(yield* sessionIdConfig), + })) + const resetSession = yield* relay.sessionReset(sessionId) + yield* Console.log(resetSession.id) }), ).pipe(Command.withDescription("Reset a Browser Control session state and page")) @@ -420,11 +426,16 @@ const sessionDelete = Command.make( "delete", { id: Argument.string("id").pipe(Argument.optional), + session: Flag.string("session").pipe(Flag.optional, Flag.withAlias("s"), Flag.withDescription("Delete this Browser Control session id")), }, - Effect.fn("Cli.sessionDelete")(function* ({ id }) { + Effect.fn("Cli.sessionDelete")(function* ({ id, session }) { const relay = yield* RelayClient.Service const store = yield* SessionStore.Service - const sessionId = yield* resolveExistingSessionId(optionString(id)) + const sessionId = yield* resolveExistingSessionId(resolveExplicitSessionSelector({ + positional: optionString(id), + flag: optionString(session), + environment: Option.getOrUndefined(yield* sessionIdConfig), + })) yield* relay.sessionDelete(sessionId) const current = yield* store.read if (current === sessionId) { diff --git a/src/doctor.ts b/src/doctor.ts index 6797728..cf91c05 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -25,6 +25,7 @@ const PackageMetadata = Schema.Struct({ const ManifestVersion = Schema.Struct({ version: Schema.String, }) +const bundledExtensionManifestPath = "extension/dist/manifest.json" type PackageInfo = { readonly name: string @@ -163,13 +164,12 @@ export const createDoctorReport = Effect.fn("Doctor.createReport")(function* (op Effect.catch(() => Effect.succeed(false)), ) - const [packageResult, sourceManifestVersion, distManifestVersion, distCliExists, distMcpExists, extensionDistManifestExists, currentResult] = yield* Effect.all([ + const [packageResult, bundledManifestVersion, distCliExists, distMcpExists, extensionDistManifestExists, currentResult] = yield* Effect.all([ probe(readPackageInfo), - probe(readManifestVersion("extension/manifest.json")), - probe(readManifestVersion("extension/dist/manifest.json")), + probe(readManifestVersion(bundledExtensionManifestPath)), fileExists("dist/cli.js"), fileExists("dist/mcp.js"), - fileExists("extension/dist/manifest.json"), + fileExists(bundledExtensionManifestPath), probe(store.read), ]) const relayResult = yield* probe(relay.version) @@ -189,7 +189,7 @@ export const createDoctorReport = Effect.fn("Doctor.createReport")(function* (op { ok: false, error: relayResult.error } satisfies ProbeResult, { ok: false, error: relayResult.error } satisfies ProbeResult, ] - const expectedVersion = sourceManifestVersion.ok ? sourceManifestVersion.value : null + const expectedVersion = bundledManifestVersion.ok ? bundledManifestVersion.value : null const extensionVersion = extensionResult.ok ? extensionResult.value.version : null const extensionVersionMatches = extensionResult.ok && extensionVersion && expectedVersion ? extensionVersion === expectedVersion : null const current = currentResult.ok ? currentResult.value ?? null : null @@ -211,17 +211,16 @@ export const createDoctorReport = Effect.fn("Doctor.createReport")(function* (op { path: "dist/cli.js", exists: distCliExists }, { path: "dist/mcp.js", exists: distMcpExists }, { - path: "extension/dist/manifest.json", + path: bundledExtensionManifestPath, exists: extensionDistManifestExists, - ...(distManifestVersion.ok ? { version: distManifestVersion.value } : {}), + ...(bundledManifestVersion.ok ? { version: bundledManifestVersion.value } : {}), }, ] const checks = buildDoctorChecks({ packageResult, relayResult, extensionResult, - sourceManifestVersion, - extensionVersionMatches, + bundledManifestVersion, artifacts, currentResult, staleCurrent, @@ -299,8 +298,7 @@ function buildDoctorChecks(options: { readonly packageResult: ProbeResult readonly relayResult: ProbeResult readonly extensionResult: ProbeResult - readonly sourceManifestVersion: ProbeResult - readonly extensionVersionMatches: boolean | null + readonly bundledManifestVersion: ProbeResult readonly artifacts: readonly DoctorArtifact[] readonly currentResult: ProbeResult readonly staleCurrent: boolean @@ -355,12 +353,10 @@ function buildDoctorChecks(options: { status: options.extensionResult.ok && options.extensionResult.value.connected ? "ok" : "fail", message: options.extensionResult.ok ? options.extensionResult.value.connected ? `connected${options.extensionResult.value.version ? ` (${options.extensionResult.value.version})` : ""}` : "disconnected" : options.extensionResult.error, }, - { - id: "extension-version", - label: "extension version", - status: extensionVersionCheckStatus({ extensionResult: options.extensionResult, sourceManifestVersion: options.sourceManifestVersion, versionMatches: options.extensionVersionMatches }), - message: extensionVersionCheckMessage({ extensionResult: options.extensionResult, sourceManifestVersion: options.sourceManifestVersion, versionMatches: options.extensionVersionMatches }), - }, + extensionVersionCheck({ + extensionResult: options.extensionResult, + bundledManifestVersion: options.bundledManifestVersion, + }), extensionProtocolCheck(options.extensionResult), { id: "targets-readable", @@ -458,40 +454,35 @@ export function relayBuildCheck(options: { } } -function extensionVersionCheckStatus(options: { - readonly extensionResult: ProbeResult - readonly sourceManifestVersion: ProbeResult - readonly versionMatches: boolean | null -}): DoctorCheckStatus { - if (!options.extensionResult.ok || !options.extensionResult.value.connected) { - return "warn" - } - if (!options.sourceManifestVersion.ok || !options.extensionResult.value.version) { - return "warn" - } - return "ok" -} - -function extensionVersionCheckMessage(options: { +function extensionVersionCheck(options: { readonly extensionResult: ProbeResult - readonly sourceManifestVersion: ProbeResult - readonly versionMatches: boolean | null -}): string { + readonly bundledManifestVersion: ProbeResult +}): DoctorCheck { + const check = (status: DoctorCheckStatus, message: string): DoctorCheck => ({ + id: "extension-version", + label: "extension version", + status, + message, + }) if (!options.extensionResult.ok) { - return options.extensionResult.error + return check("warn", options.extensionResult.error) } if (!options.extensionResult.value.connected) { - return "extension disconnected; cannot compare runtime version" + return check("warn", "extension disconnected; cannot compare runtime version") } - if (!options.sourceManifestVersion.ok) { - return `could not read extension/manifest.json: ${options.sourceManifestVersion.error}` + if (!options.bundledManifestVersion.ok) { + return check("warn", `could not read ${bundledExtensionManifestPath}: ${options.bundledManifestVersion.error}`) } - if (!options.extensionResult.value.version) { - return "extension did not report a version" + const runtimeVersion = options.extensionResult.value.version + if (!runtimeVersion) { + return check("warn", "extension did not report a version") } - return options.versionMatches - ? `matches bundled extension (${options.sourceManifestVersion.value})` - : `runtime ${options.extensionResult.value.version} differs from bundled ${options.sourceManifestVersion.value}; protocol compatibility determines support` + return check( + "ok", + runtimeVersion === options.bundledManifestVersion.value + ? `matches bundled extension (${options.bundledManifestVersion.value})` + : `runtime ${runtimeVersion} differs from bundled ${options.bundledManifestVersion.value}; protocol compatibility determines support`, + ) } export function extensionProtocolCheck(extensionResult: ProbeResult): DoctorCheck { diff --git a/src/execute.ts b/src/execute.ts index 52cafed..491cb2b 100644 --- a/src/execute.ts +++ b/src/execute.ts @@ -1866,7 +1866,6 @@ async function fillInput(options: { readonly page: Page; readonly target: InputT const prototype = Object.getPrototypeOf(element) as HTMLInputElement | HTMLTextAreaElement const valueSetter = Object.getOwnPropertyDescriptor(element, "value")?.set const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set - element.focus() if (prototypeValueSetter && valueSetter !== prototypeValueSetter) { prototypeValueSetter.call(element, nextValue) } else { @@ -1874,7 +1873,6 @@ async function fillInput(options: { readonly page: Page; readonly target: InputT } element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: nextValue })) element.dispatchEvent(new Event("change", { bubbles: true })) - element.blur() }, options.value, { timeout: 30_000 }) } @@ -1899,8 +1897,20 @@ export async function fillInputs(page: Page, fields: ReadonlyArray): return inputFields.map((field) => { let element: Node | undefined if (typeof field.target === "string") { - const matches = document.querySelectorAll(field.target) + const matches: Element[] = [] + const roots: Array = [document] + for (let index = 0; index < roots.length; index += 1) { + const root = roots[index] + if (!root) continue + matches.push(...root.querySelectorAll(field.target)) + for (const candidate of root.querySelectorAll("*")) { + if (candidate.shadowRoot) roots.push(candidate.shadowRoot) + } + } if (matches.length !== 1) { + if (matches.length === 0) { + throw new Error(`fillInputs found no match for ${field.label} in the document or open shadow roots; closed shadow roots are unavailable. Try locator.fill() if Playwright can resolve the field.`) + } throw new Error(`fillInputs expects exactly one match for ${field.label}; got ${matches.length}`) } element = matches[0] @@ -1913,7 +1923,6 @@ export async function fillInputs(page: Page, fields: ReadonlyArray): const prototype = Object.getPrototypeOf(element) as HTMLInputElement | HTMLTextAreaElement const valueSetter = Object.getOwnPropertyDescriptor(element, "value")?.set const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set - element.focus() if (prototypeValueSetter && valueSetter !== prototypeValueSetter) { prototypeValueSetter.call(element, field.value) } else { @@ -1921,7 +1930,6 @@ export async function fillInputs(page: Page, fields: ReadonlyArray): } element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: field.value })) element.dispatchEvent(new Event("change", { bubbles: true })) - element.blur() return field.label }) }, resolvedFields) diff --git a/src/protocol.ts b/src/protocol.ts index 67f9eaf..f385ab1 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -4,7 +4,7 @@ export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: export type JsonObject = { readonly [key: string]: JsonValue } -export const extensionProtocolVersion = 1 +export const extensionProtocolVersion = 2 export const legacyExtensionProtocolVersion = 1 export type ExtensionProtocolCompatibility = { @@ -104,7 +104,6 @@ export type ExtensionEvent = { | "tabs.removed" | "pong" | "log" - | "recording.data" | "recording.cancelled" | "pageStatus.requested" readonly params?: JsonObject @@ -149,7 +148,6 @@ const extensionEventMethods = new Set([ "tabs.removed", "pong", "log", - "recording.data", "recording.cancelled", "pageStatus.requested", ]) diff --git a/src/recording-protocol.ts b/src/recording-protocol.ts new file mode 100644 index 0000000..e46c13c --- /dev/null +++ b/src/recording-protocol.ts @@ -0,0 +1,62 @@ +const magic = [0x42, 0x43, 0x52, 0x44] as const +const version = 1 +const finalFlag = 1 +const headerLength = 20 + +export const maxRecordingFramePayloadBytes = 4 * 1024 * 1024 + +export type RecordingFrame = { + readonly tabId: number + readonly sequence: number + readonly final: boolean + readonly payload: Uint8Array +} + +export function encodeRecordingFrame(frame: RecordingFrame): Uint8Array { + validateUint32(frame.tabId, "tabId", true) + validateUint32(frame.sequence, "sequence", false) + if (frame.payload.byteLength > maxRecordingFramePayloadBytes) { + throw new Error(`Recording frame payload exceeds ${maxRecordingFramePayloadBytes} bytes`) + } + if (frame.final && frame.payload.byteLength !== 0) throw new Error("Final recording frame must have an empty payload") + if (!frame.final && frame.payload.byteLength === 0) throw new Error("Recording data frame must have a payload") + const encoded = new Uint8Array(headerLength + frame.payload.byteLength) + encoded.set(magic, 0) + const view = new DataView(encoded.buffer) + view.setUint8(4, version) + view.setUint8(5, frame.final ? finalFlag : 0) + view.setUint16(6, headerLength) + view.setUint32(8, frame.tabId) + view.setUint32(12, frame.sequence) + view.setUint32(16, frame.payload.byteLength) + encoded.set(frame.payload, headerLength) + return encoded +} + +export function decodeRecordingFrame(data: Uint8Array): RecordingFrame { + if (data.byteLength < headerLength) throw new Error("Recording frame is shorter than its header") + if (magic.some((byte, index) => data[index] !== byte)) throw new Error("Invalid recording frame magic") + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) + if (view.getUint8(4) !== version) throw new Error(`Unsupported recording frame version: ${view.getUint8(4)}`) + const flags = view.getUint8(5) + if ((flags & ~finalFlag) !== 0) throw new Error(`Invalid recording frame flags: ${flags}`) + if (view.getUint16(6) !== headerLength) throw new Error(`Invalid recording frame header length: ${view.getUint16(6)}`) + const tabId = view.getUint32(8) + const sequence = view.getUint32(12) + const payloadLength = view.getUint32(16) + if (tabId === 0) throw new Error("Recording frame tabId must be positive") + if (payloadLength > maxRecordingFramePayloadBytes) { + throw new Error(`Recording frame payload exceeds ${maxRecordingFramePayloadBytes} bytes`) + } + if (data.byteLength !== headerLength + payloadLength) throw new Error("Recording frame payload length does not match its header") + const final = (flags & finalFlag) !== 0 + if (final && payloadLength !== 0) throw new Error("Final recording frame must have an empty payload") + if (!final && payloadLength === 0) throw new Error("Recording data frame must have a payload") + return { tabId, sequence, final, payload: data.subarray(headerLength) } +} + +function validateUint32(value: number, field: string, positive: boolean): void { + if (!Number.isInteger(value) || value < (positive ? 1 : 0) || value > 0xffff_ffff) { + throw new Error(`Recording frame ${field} must be ${positive ? "a positive" : "an"} uint32`) + } +} diff --git a/src/recording-relay.ts b/src/recording-relay.ts index 2318153..842abd4 100644 --- a/src/recording-relay.ts +++ b/src/recording-relay.ts @@ -1,12 +1,15 @@ import { spawn } from "node:child_process" +import crypto from "node:crypto" import { once } from "node:events" import fs from "node:fs/promises" import path from "node:path" import { performance } from "node:perf_hooks" +import { terminateChildProcess } from "./child-process.ts" import { mjpegMatroskaFrame, mjpegMatroskaHeader } from "./mjpeg-matroska.ts" import type { ExtensionCommand, JsonObject } from "./protocol.ts" import { getObject } from "./relay-helpers.ts" import type { ConnectedTarget } from "./relay-types.ts" +import { decodeRecordingFrame } from "./recording-protocol.ts" const defaultMaxDurationMs = 15 * 60 * 1_000 const defaultCdpFrameRate = 25 @@ -15,6 +18,8 @@ const maxPendingCdpFrames = 30 const maxCdpWidth = 1_280 const maxCdpHeight = 720 const cdpJpegQuality = 80 +const maxPendingTabCaptureBytes = 16 * 1024 * 1024 +const maxTabCaptureOutputBytes = 1024 * 1024 * 1024 export type RecordingMode = "auto" | "tab-capture" | "cdp" export type ActiveRecordingMode = "tab-capture" | "cdp" @@ -106,7 +111,18 @@ type ActiveRecordingBase = { type TabCaptureRecording = ActiveRecordingBase & { mode: "tab-capture" artifactType: "webm" - chunks: Buffer[] + temporaryPath: string + file: Awaited> + expectedSequence: number + receivedBytes: number + pendingWriteBytes: number + writePromise: Promise + writeError?: Error + stopping: boolean + finalized: boolean + stopPromise?: Promise + finalizePromise?: Promise + cleanupPromise?: Promise } type CdpRecording = ActiveRecordingBase & { @@ -193,7 +209,6 @@ type ExtensionStatusResult = { export class RecordingRelay { private readonly activeRecordings = new Map() private readonly startingRecordings = new Map() - private lastRecordingMetadataTabId: number | undefined constructor(readonly options: { readonly sendToExtension: (command: Omit) => Promise @@ -239,13 +254,23 @@ export class RecordingRelay { if (recording.mode === "cdp") { return this.stopCdpRecording(recording) } + if (recording.stopPromise) return recording.stopPromise + const stopPromise = this.stopTabCaptureRecording(recording) + recording.stopPromise = stopPromise + return stopPromise + } + private async stopTabCaptureRecording(recording: TabCaptureRecording): Promise { let stopTimeout: ReturnType | undefined const finalResult = new Promise((resolve) => { stopTimeout = setTimeout(() => { delete recording.resolveStop if (this.activeRecordings.get(recording.tabId) === recording) { - this.cleanupRecording(recording.tabId) + recording.stopping = true + if (this.options.isExtensionConnected()) { + void this.options.sendToExtension({ method: "recording.cancel", params: { tabId: recording.tabId } }).catch(() => {}) + } + void this.cleanupRecording(recording.tabId) } resolve({ success: false, error: "Timeout waiting for recording data" }) }, 30_000) @@ -263,15 +288,16 @@ export class RecordingRelay { if (!result.success) { if (stopTimeout) clearTimeout(stopTimeout) delete recording.resolveStop - this.cleanupRecording(recording.tabId) + await this.failTabCaptureRecording(recording, result.error) return result } return await finalResult } catch (error) { if (stopTimeout) clearTimeout(stopTimeout) delete recording.resolveStop - this.cleanupRecording(recording.tabId) - return { success: false, error: error instanceof Error ? error.message : String(error) } + const message = error instanceof Error ? error.message : String(error) + await this.failTabCaptureRecording(recording, message) + return { success: false, error: message } } } @@ -298,15 +324,12 @@ export class RecordingRelay { method: "recording.status", params: { tabId: recording.tabId }, })) - const size = recording.chunks.reduce((total, chunk) => { - return total + chunk.byteLength - }, 0) return { isRecording: result.isRecording, tabId: recording.tabId, startedAt: result.startedAt ?? recording.startedAt, path: recording.outputPath, - size, + size: recording.receivedBytes, mode: "tab-capture", artifactType: "webm", } @@ -318,9 +341,7 @@ export class RecordingRelay { tabId: recording.tabId, startedAt: recording.startedAt, path: recording.outputPath, - size: recording.chunks.reduce((total, chunk) => { - return total + chunk.byteLength - }, 0), + size: recording.receivedBytes, mode: "tab-capture", artifactType: "webm", } @@ -342,27 +363,42 @@ export class RecordingRelay { await this.cancelCdpRecording(recording) return { success: true } } + if (recording.finalizePromise) { + await recording.finalizePromise + return { success: true } + } if (!this.options.isExtensionConnected()) { - this.cleanupRecording(recording.tabId) + recording.resolveStop?.({ success: false, error: "Recording was cancelled" }) + await this.cleanupRecording(recording.tabId) return { success: false, error: "Browser Control extension is not connected" } } try { const result = await this.options.sendToExtension({ method: "recording.cancel", params: { tabId: recording.tabId } }) - this.cleanupRecording(recording.tabId) + recording.resolveStop?.({ success: false, error: "Recording was cancelled" }) + await this.cleanupRecording(recording.tabId) return parseCancelResult(result) } catch (error) { - this.cleanupRecording(recording.tabId) + recording.resolveStop?.({ success: false, error: "Recording was cancelled" }) + await this.cleanupRecording(recording.tabId) return { success: false, error: error instanceof Error ? error.message : String(error) } } } async cleanupAll(reason: string): Promise { const starting = Array.from(this.startingRecordings.values()) + const startingTabIds = new Set(starting.map((recording) => recording.tabId)) + const active = Array.from(this.activeRecordings.values()).filter((recording) => { + return !startingTabIds.has(recording.tabId) + }) for (const recording of starting) recording.cancelled = true await Promise.all(starting.map(async (recording) => { await recording.promise?.catch(() => {}) })) - await Promise.all(Array.from(this.activeRecordings.values()).map(async (recording) => { + await Promise.all(active.map(async (recording) => { + if (recording.mode === "tab-capture" && recording.finalizePromise) { + await recording.finalizePromise + return + } if (recording.resolveStop) { recording.resolveStop({ success: false, error: reason }) } @@ -374,7 +410,7 @@ export class RecordingRelay { await this.cancelCdpRecording(recording) return } - this.cleanupRecording(recording.tabId) + await this.cleanupRecording(recording.tabId) })) } @@ -389,6 +425,10 @@ export class RecordingRelay { if (!recording) { return } + if (recording.mode === "tab-capture" && recording.finalizePromise) { + await recording.finalizePromise + return + } recording.resolveStop?.({ success: false, error: options.reason }) if (recording.mode === "cdp") { if (recording.stopPromise) { @@ -398,24 +438,7 @@ export class RecordingRelay { await this.cancelCdpRecording(recording) return } - this.cleanupRecording(recording.tabId) - } - - handleRecordingData(message: JsonObject): void { - const params = getObject(message.params) - const tabId = typeof params?.tabId === "number" ? params.tabId : undefined - if (tabId === undefined) { - return - } - if (params?.final !== true) { - this.lastRecordingMetadataTabId = tabId - return - } - const recording = this.activeRecordings.get(tabId) - if (!recording || recording.mode !== "tab-capture") { - return - } - void this.finishRecording(recording) + await this.cleanupRecording(recording.tabId) } handleRecordingCancelled(message: JsonObject): void { @@ -425,10 +448,11 @@ export class RecordingRelay { return } const recording = this.activeRecordings.get(tabId) + if (recording?.mode === "tab-capture" && recording.finalizePromise) return if (recording?.resolveStop) { recording.resolveStop({ success: false, error: "Recording was cancelled" }) } - this.cleanupRecording(tabId) + void this.cleanupRecording(tabId) } handleDebuggerEvent(options: { readonly tabId: number; readonly method: string; readonly params: JsonObject | undefined }): boolean { @@ -482,37 +506,75 @@ export class RecordingRelay { } handleBinaryData(data: Buffer): void { - const tabId = this.lastRecordingMetadataTabId - this.lastRecordingMetadataTabId = undefined - if (tabId === undefined) { + const frame = decodeRecordingFrame(data) + const recording = this.activeRecordings.get(frame.tabId) + if (!recording || recording.mode !== "tab-capture") { return } - const recording = this.activeRecordings.get(tabId) - if (!recording || recording.mode !== "tab-capture") { + if (recording.stopping || recording.finalized) return + if (recording.writeError) { + void this.failTabCaptureRecording(recording, recording.writeError.message) + return + } + if (frame.sequence !== recording.expectedSequence) { + void this.failTabCaptureRecording(recording, `Recording frame sequence ${frame.sequence} did not match expected ${recording.expectedSequence}`) + return + } + recording.expectedSequence += 1 + if (frame.final) { + recording.stopping = true + if (recording.maxDurationTimer) clearTimeout(recording.maxDurationTimer) + recording.finalizePromise = this.finishTabCaptureRecording(recording) + void recording.finalizePromise + return + } + if (recording.receivedBytes + frame.payload.byteLength > maxTabCaptureOutputBytes) { + void this.failTabCaptureRecording(recording, `Tab capture exceeds ${maxTabCaptureOutputBytes} bytes`) return } - recording.chunks.push(Buffer.from(data)) + if (recording.pendingWriteBytes + frame.payload.byteLength > maxPendingTabCaptureBytes) { + void this.failTabCaptureRecording(recording, `Tab capture pending writes exceed ${maxPendingTabCaptureBytes} bytes`) + return + } + const payload = frame.payload + recording.receivedBytes += payload.byteLength + recording.pendingWriteBytes += payload.byteLength + recording.writePromise = recording.writePromise.then(async () => { + const result = await recording.file.write(payload) + if (result.bytesWritten !== payload.byteLength) throw new Error(`Short recording write: ${result.bytesWritten}/${payload.byteLength}`) + }).catch((error: unknown) => { + recording.writeError ??= error instanceof Error ? error : new Error(String(error)) + void this.failTabCaptureRecording(recording, recording.writeError.message) + }).finally(() => { + recording.pendingWriteBytes -= payload.byteLength + }) } - private async finishRecording(recording: TabCaptureRecording): Promise { + private async finishTabCaptureRecording(recording: TabCaptureRecording): Promise { try { - const size = recording.chunks.reduce((total, chunk) => { - return total + chunk.byteLength - }, 0) - await fs.writeFile(recording.outputPath, Buffer.concat(recording.chunks)) + await recording.writePromise + if (recording.writeError) throw recording.writeError + if (recording.receivedBytes === 0) throw new Error("No tab capture data was received") + await recording.file.sync() + await recording.file.close() + await fs.rename(recording.temporaryPath, recording.outputPath) + recording.finalized = true recording.resolveStop?.({ success: true, tabId: recording.tabId, duration: Date.now() - recording.startedAt, path: recording.outputPath, - size, + size: recording.receivedBytes, mode: "tab-capture", artifactType: "webm", }) } catch (error) { recording.resolveStop?.({ success: false, error: error instanceof Error ? error.message : String(error) }) } finally { - this.cleanupRecording(recording.tabId) + await this.cleanupTabCaptureRecording(recording) + if (this.activeRecordings.get(recording.tabId) === recording) { + this.activeRecordings.delete(recording.tabId) + } } } @@ -542,16 +604,41 @@ export class RecordingRelay { return recordings[0] } - private cleanupRecording(tabId: number): void { + private async cleanupRecording(tabId: number): Promise { const recording = this.activeRecordings.get(tabId) + if (!recording) return if (recording?.maxDurationTimer) { clearTimeout(recording.maxDurationTimer) } - if (recording?.mode === "cdp") { - void this.cleanupCdpRecording(recording) + if (recording.mode === "cdp") { + await this.cleanupCdpRecording(recording) return } + if (recording.finalizePromise) return recording.finalizePromise + if (recording.cleanupPromise) return recording.cleanupPromise this.activeRecordings.delete(tabId) + return this.cleanupTabCaptureRecording(recording) + } + + private cleanupTabCaptureRecording(recording: TabCaptureRecording): Promise { + if (recording.cleanupPromise) return recording.cleanupPromise + recording.cleanupPromise = (async () => { + await recording.writePromise.catch(() => {}) + await recording.file.close().catch(() => {}) + if (!recording.finalized) await fs.rm(recording.temporaryPath, { force: true }).catch(() => {}) + })() + return recording.cleanupPromise + } + + private async failTabCaptureRecording(recording: TabCaptureRecording, message: string): Promise { + if (recording.stopping || recording.finalized) return + recording.stopping = true + recording.resolveStop?.({ success: false, error: message }) + const cleanup = this.cleanupRecording(recording.tabId) + if (this.options.isExtensionConnected()) { + await this.options.sendToExtension({ method: "recording.cancel", params: { tabId: recording.tabId } }).catch(() => {}) + } + await cleanup } private async startReservedRecording(options: RecordingStartOptions, starting: StartingRecording): Promise { @@ -565,27 +652,50 @@ export class RecordingRelay { await fs.mkdir(path.dirname(options.outputPath), { recursive: true }) if (starting.cancelled) return { success: false, error: "Recording was cancelled while starting" } - const result = parseExtensionStartResult(await this.options.sendToExtension({ - method: "recording.start", - params: recordingStartParams(options), - })) - if (!result.success) return result - if (starting.cancelled) { - await this.options.sendToExtension({ method: "recording.cancel", params: { tabId: result.tabId } }).catch(() => {}) - return { success: false, error: "Recording was cancelled while starting" } - } - - const recording: ActiveRecording = { - tabId: result.tabId, + const temporaryPath = `${options.outputPath}.partial-${process.pid}-${crypto.randomUUID()}` + const file = await fs.open(temporaryPath, "wx", 0o600) + const recording: TabCaptureRecording = { + tabId: options.tabId, ...(options.sessionId ? { sessionId: options.sessionId } : {}), outputPath: options.outputPath, + temporaryPath, + file, mode: "tab-capture", artifactType: "webm", - chunks: [], - startedAt: result.startedAt, + expectedSequence: 0, + receivedBytes: 0, + pendingWriteBytes: 0, + writePromise: Promise.resolve(), + stopping: false, + finalized: false, + startedAt: this.now(), } + this.activeRecordings.set(options.tabId, recording) + let result: ExtensionStartResult + try { + result = parseExtensionStartResult(await this.options.sendToExtension({ + method: "recording.start", + params: recordingStartParams(options), + })) + } catch (error) { + await this.cleanupRecording(options.tabId) + throw error + } + if (!result.success || starting.cancelled || result.tabId !== options.tabId || this.activeRecordings.get(options.tabId) !== recording) { + if (result.success) await this.options.sendToExtension({ method: "recording.cancel", params: { tabId: result.tabId } }).catch(() => {}) + await this.cleanupRecording(options.tabId) + if (!result.success) return result + return { + success: false, + error: starting.cancelled + ? "Recording was cancelled while starting" + : result.tabId !== options.tabId + ? `Extension started recording tab ${result.tabId} instead of ${options.tabId}` + : recording.writeError?.message ?? "Recording ended while starting", + } + } + recording.startedAt = result.startedAt this.armMaxDuration(recording, options.maxDurationMs) - this.activeRecordings.set(result.tabId, recording) return { success: true, tabId: result.tabId, @@ -910,7 +1020,7 @@ async function startFfmpegVideoEncoder(options: { readonly width: number readonly height: number }): Promise { - const temporaryOutputPath = `${options.outputPath}.partial-${process.pid}-${Date.now()}` + const temporaryOutputPath = `${options.outputPath}.partial-${process.pid}-${crypto.randomUUID()}` const outputArgs = options.artifactType === "webm" ? ["-c:v", "libvpx", "-crf", "8", "-deadline", "realtime", "-cpu-used", "8", "-b:v", "2M", "-threads", "1"] : ["-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-pix_fmt", "yuv420p", "-movflags", "+faststart"] @@ -940,7 +1050,7 @@ async function startFfmpegVideoEncoder(options: { "-f", options.artifactType, temporaryOutputPath, - ], { stdio: "pipe" }) + ], { detached: process.platform !== "win32", stdio: "pipe" }) let stderr = "" child.stderr.setEncoding("utf8") child.stderr.on("data", (chunk: string) => { @@ -966,12 +1076,7 @@ async function startFfmpegVideoEncoder(options: { let cancelPromise: Promise | undefined const terminate = async () => { child.stdin.destroy() - if (child.exitCode === null) child.kill("SIGTERM") - const exited = await waitForProcessExit(exit, 2_000) - if (!exited && child.exitCode === null) { - child.kill("SIGKILL") - await exit - } + await terminateChildProcess({ child, exit, graceMs: 2_000 }) } return { write: async (frame, timestampMs, durationMs) => { diff --git a/src/relay-lifecycle.ts b/src/relay-lifecycle.ts index 599ff28..dea7781 100644 --- a/src/relay-lifecycle.ts +++ b/src/relay-lifecycle.ts @@ -170,6 +170,9 @@ export function managedRelayLaunch( export function managedRelayEntrypoint(entrypoint: string): string { const name = path.basename(entrypoint) + if (name === "browser-control-mcp") { + return path.join(path.dirname(entrypoint), "browser-control") + } if (name === "mcp.js" || name === "index.js" || name === "browser-control-client.js") { return path.join(path.dirname(entrypoint), "cli.js") } diff --git a/src/relay.ts b/src/relay.ts index 245b8cf..a126ce5 100644 --- a/src/relay.ts +++ b/src/relay.ts @@ -4,19 +4,16 @@ import crypto from "node:crypto" import { Clock, Config, Effect, Fiber, Semaphore } from "effect" import { WebSocket, WebSocketServer, type RawData } from "ws" import { - chromeSessionIdForClientRequest, - createClientTargetAnnouncements, hasAnnouncedSession, removeAnnouncedSession, - removeClientTargetAliases, replayChildFrameNavigation, replayChildTargetsForParent, replayTargetCreated, sendAttachedToChildTarget, sendAttachedToTarget, - type ClientCdpSessionAlias, } from "./cdp-shims.ts" -import { canClientSeeTarget } from "./cdp-visibility.ts" +import { CdpClientPool } from "./cdp-client-pool.ts" +import { CdpRouter } from "./cdp-router.ts" import { ExtensionRpc } from "./extension-rpc.ts" import { createHttpRequestHandler } from "./http-api.ts" import type { CdpEvent, CdpRequest, JsonObject, PageStatus } from "./protocol.ts" @@ -57,7 +54,7 @@ import { BrowserControlSessions } from "./session-manager.ts" import { RecordingRelay } from "./recording-relay.ts" import { appendManagedRelayProcessLog } from "./relay-log.ts" import { boundedToken, runtimeFailureKind, summarizeDiagnosticUrl, summarizeRuntimeEvaluate } from "./runtime-diagnostics.ts" -import { resolveTargetInfoTarget, shouldExposeChildTarget, TargetRegistry, type RootTargetChange, type TargetOwnershipChange } from "./target-registry.ts" +import { shouldExposeChildTarget, TargetRegistry, type RootTargetChange, type TargetOwnershipChange } from "./target-registry.ts" import { browserControlVersion } from "./version.ts" export type { RelayServer } from "./relay-types.ts" @@ -401,16 +398,10 @@ const makeRelay = Effect.fnUntraced(function* (options: { } function resolveHandoffTarget(sessionId: string, selectedPage: HandoffPageTarget): ConnectedTarget { - const clientHasOwnedTarget = registry.listRootTargets().some((target) => target.browserControlSessionId === sessionId) return resolveExactHandoffTarget({ targetId: selectedPage.targetId, targets: registry.listRootTargets(), - isVisible: (target) => canClientSeeTarget({ - clientSessionId: sessionId, - targetOwnerSessionId: target.browserControlSessionId, - targetOwner: target.owner, - clientHasOwnedTarget, - }), + isVisible: (target) => cdpRouter.canSessionSeeTarget(sessionId, target), }) } @@ -504,15 +495,10 @@ const makeRelay = Effect.fnUntraced(function* (options: { const debugLog = debugEnabled ? (line: string) => console.error(`[bc ${new Date().toISOString().slice(11, 23)}] ${line}`) : undefined const contextDebugLog = debugLog ? (line: string) => debugLog(`[bc:ctx] ${line}`) : undefined const websocketServer = new WebSocketServer({ noServer: true }) - const cdpClients = new Set() - const cdpClientAnnouncements = new Map>() - const cdpClientBrowserControlSessionIds = new Map() - const cdpClientSessionAliases = new Map>() + const cdpClients = new CdpClientPool() + const cdpRouter = new CdpRouter(cdpClients, registry) const runtimeContextWaiters = new Set<(event: CdpEvent) => void>() let nextTargetSessionId = 1 - let nextClientSessionAliasId = 1 - let autoAttachParams: JsonObject | undefined - let idleRuntimeResetGeneration = 0 const mainFrameIdsByTab = new Map() const ghostCursorPositionsByTab = new Map() const suppressedChildSessions = new Map() @@ -540,6 +526,12 @@ const makeRelay = Effect.fnUntraced(function* (options: { return registry.tabTargets.get(tabId) } + function diagnosticTargetForClient(socket: WebSocket, sessionId: string | undefined): ConnectedTarget | ChildTarget | undefined { + return sessionId + ? cdpRouter.targetInfo(socket, { sessionId }) + : cdpRouter.singleVisibleRoot(socket) + } + function isRuntimeEvaluationMethod(method: string): boolean { return method === "Runtime.evaluate" || method === "Runtime.callFunctionOn" } @@ -650,7 +642,11 @@ const makeRelay = Effect.fnUntraced(function* (options: { return } if (isBinary) { - recordingRelay.handleBinaryData(rawDataToBuffer(data)) + try { + recordingRelay.handleBinaryData(rawDataToBuffer(data)) + } catch { + socket.close(1002, "Invalid recording frame") + } return } handleExtensionMessage(socket, data.toString(), socketGeneration, announcedRootTabIds) @@ -666,14 +662,8 @@ const makeRelay = Effect.fnUntraced(function* (options: { return } - cdpClients.add(socket) - idleRuntimeResetGeneration++ - cdpClientAnnouncements.set(socket, createClientTargetAnnouncements()) - cdpClientSessionAliases.set(socket, new Map()) const browserControlSessionId = requestUrl.searchParams.get("browserControlSessionId") ?? headerValue(request.headers["browser-control-session-id"]) - if (browserControlSessionId) { - cdpClientBrowserControlSessionIds.set(socket, browserControlSessionId) - } + cdpClients.register(socket, browserControlSessionId) debugLog?.(`client+ ${browserControlSessionId ?? "raw"} total=${cdpClients.size}`) socket.on("message", (data) => { Effect.runPromise(handleCdpMessage(socket, data.toString())).catch((error: unknown) => { @@ -684,14 +674,10 @@ const makeRelay = Effect.fnUntraced(function* (options: { }) }) socket.on("close", () => { - debugLog?.(`client- ${cdpClientBrowserControlSessionIds.get(socket) ?? "raw"} total=${cdpClients.size - 1}`) - cdpClients.delete(socket) - cdpClientAnnouncements.delete(socket) - cdpClientBrowserControlSessionIds.delete(socket) - cdpClientSessionAliases.delete(socket) - if (cdpClients.size === 0) { - const generation = ++idleRuntimeResetGeneration - Effect.runPromise(disableRuntimeForIdleTargets(generation).pipe(Effect.ignore)).catch((error: unknown) => { + debugLog?.(`client- ${cdpClients.sessionId(socket) ?? "raw"} total=${cdpClients.size - 1}`) + const idleGeneration = cdpClients.unregister(socket) + if (idleGeneration !== undefined) { + Effect.runPromise(disableRuntimeForIdleTargets(idleGeneration).pipe(Effect.ignore)).catch((error: unknown) => { console.error("Failed to reset idle runtime domains", error) }) } @@ -715,7 +701,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { return undefined } const protocol = extensionProtocolCompatibility(message.params?.protocolVersion) - if (!protocol.compatible && extensionRpc.connected) { + if (!protocol.compatible && extensionRpc.protocolCompatible === true) { socket.close(4003, "Extension protocol incompatible") return extensionGeneration } @@ -830,10 +816,6 @@ const makeRelay = Effect.fnUntraced(function* (options: { } return } - if (extensionMethod === "recording.data") { - recordingRelay.handleRecordingData(message) - return - } if (extensionMethod === "recording.cancelled") { recordingRelay.handleRecordingCancelled(message) return @@ -1073,16 +1055,15 @@ const makeRelay = Effect.fnUntraced(function* (options: { return yield* Effect.fail(new Error("Invalid CDP request")) } - debugLog?.(`cdp<- ${cdpClientBrowserControlSessionIds.get(socket) ?? "raw"} #${message.id} ${message.method} ${message.sessionId ?? ""}`) + debugLog?.(`cdp<- ${cdpClients.sessionId(socket) ?? "raw"} #${message.id} ${message.method} ${message.sessionId ?? ""}`) yield* Effect.matchEffect(routeCdpCommand(socket, message), { onFailure: (error) => { return Effect.sync(() => { const runtimeEvaluation = isRuntimeEvaluationMethod(message.method) const errorDetail = runtimeEvaluation ? runtimeFailureKind(error) : error.message - debugLog?.(`cdp-> ${cdpClientBrowserControlSessionIds.get(socket) ?? "raw"} #${message.id} ${message.method} ERROR ${errorDetail}`) + debugLog?.(`cdp-> ${cdpClients.sessionId(socket) ?? "raw"} #${message.id} ${message.method} ERROR ${errorDetail}`) if (runtimeEvaluation) { - const tabId = message.sessionId ? registry.tabIdForSession(message.sessionId) : firstVisibleRootTarget(socket)?.tabId - contextDebugLog?.(`evaluation-failed method=${message.method} failure=${runtimeFailureKind(error)} client=${boundedToken(cdpClientBrowserControlSessionIds.get(socket) ?? "raw")} ${targetDiagnosticIdentity(tabId ? targetForCdpSession(tabId, message.sessionId) : undefined)} ${summarizeRuntimeEvaluate(message.params)}`) + contextDebugLog?.(`evaluation-failed method=${message.method} failure=${runtimeFailureKind(error)} client=${boundedToken(cdpClients.sessionId(socket) ?? "raw")} ${targetDiagnosticIdentity(diagnosticTargetForClient(socket, message.sessionId))} ${summarizeRuntimeEvaluate(message.params)}`) } sendCdpResponse(socket, { id: message.id, @@ -1093,12 +1074,11 @@ const makeRelay = Effect.fnUntraced(function* (options: { }, onSuccess: (result) => { return Effect.sync(() => { - debugLog?.(`cdp-> ${cdpClientBrowserControlSessionIds.get(socket) ?? "raw"} #${message.id} ${message.method} ok`) + debugLog?.(`cdp-> ${cdpClients.sessionId(socket) ?? "raw"} #${message.id} ${message.method} ok`) const resultObject = getObject(result) const exceptionDetails = isRuntimeEvaluationMethod(message.method) ? getObject(resultObject?.exceptionDetails) : undefined if (exceptionDetails) { - const tabId = message.sessionId ? registry.tabIdForSession(message.sessionId) : firstVisibleRootTarget(socket)?.tabId - contextDebugLog?.(`evaluation-exception method=${message.method} exceptionId=${boundedToken(typeof exceptionDetails.exceptionId === "number" || typeof exceptionDetails.exceptionId === "string" ? String(exceptionDetails.exceptionId) : undefined)} line=${typeof exceptionDetails.lineNumber === "number" ? exceptionDetails.lineNumber : "none"} column=${typeof exceptionDetails.columnNumber === "number" ? exceptionDetails.columnNumber : "none"} client=${boundedToken(cdpClientBrowserControlSessionIds.get(socket) ?? "raw")} ${targetDiagnosticIdentity(tabId ? targetForCdpSession(tabId, message.sessionId) : undefined)} ${summarizeRuntimeEvaluate(message.params)}`) + contextDebugLog?.(`evaluation-exception method=${message.method} exceptionId=${boundedToken(typeof exceptionDetails.exceptionId === "number" || typeof exceptionDetails.exceptionId === "string" ? String(exceptionDetails.exceptionId) : undefined)} line=${typeof exceptionDetails.lineNumber === "number" ? exceptionDetails.lineNumber : "none"} column=${typeof exceptionDetails.columnNumber === "number" ? exceptionDetails.columnNumber : "none"} client=${boundedToken(cdpClients.sessionId(socket) ?? "raw")} ${targetDiagnosticIdentity(diagnosticTargetForClient(socket, message.sessionId))} ${summarizeRuntimeEvaluate(message.params)}`) } sendCdpResponse(socket, { id: message.id, @@ -1111,7 +1091,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { }) const routeCdpCommand = Effect.fn("Relay.routeCdpCommand")(function* (socket: WebSocket, message: CdpRequest) { - const clientBrowserControlSessionId = cdpClientBrowserControlSessionIds.get(socket) + const clientBrowserControlSessionId = cdpClients.sessionId(socket) const guardMessage = guardCdpMethod({ method: message.method, readOnly: clientBrowserControlSessionId ? sessions.isReadOnly(clientBrowserControlSessionId) : false, @@ -1134,69 +1114,60 @@ const makeRelay = Effect.fnUntraced(function* (options: { } if (message.method === "Target.setDiscoverTargets") { if (message.params?.discover === true) { - replayTargetCreated({ socket, targetInfos: visibleTargetInfos(socket) }) + replayTargetCreated({ socket, targetInfos: cdpRouter.visibleTargetInfos(socket) }) } return {} } if (message.method === "Target.setAutoAttach" && !message.sessionId) { - autoAttachParams = message.params - for (const target of registry.targets.values()) { - if (!canSeeTarget(socket, target)) continue + cdpClients.setAutoAttachParams(socket, message.params) + for (const target of cdpRouter.visibleRoots(socket)) { yield* Effect.ignore(sendDebuggerCommand({ tabId: target.tabId, method: "Target.setAutoAttach", params: message.params ?? {} })) - sendAttachedToTarget({ socket, clientAnnouncements: cdpClientAnnouncements, target, onDuplicateTarget: logDuplicateTargetAnnouncement }) + sendAttachedToTarget({ socket, announcements: cdpClients.announcements(socket), target, onDuplicateTarget: logDuplicateTargetAnnouncement }) } return {} } if (message.method === "Target.setAutoAttach" && message.sessionId && registry.targets.has(message.sessionId)) { - const target = registry.targets.get(message.sessionId) + const target = cdpRouter.rootForSession(socket, message.sessionId) if (!target) { return yield* Effect.fail(new Error(`Target not found: ${message.sessionId}`)) } const result = yield* sendDebuggerCommand({ tabId: target.tabId, method: "Target.setAutoAttach", params: message.params ?? {} }) - replayChildTargetsForParent({ socket, parentSessionId: target.sessionId, registry, clientAnnouncements: cdpClientAnnouncements, onDuplicateTarget: logDuplicateTargetAnnouncement }) + replayChildTargetsForParent({ socket, parentSessionId: target.sessionId, registry, announcements: cdpClients.announcements(socket), onDuplicateTarget: logDuplicateTargetAnnouncement }) return result } if (message.method === "Target.getTargets") { return { - targetInfos: visibleTargetInfos(socket), + targetInfos: cdpRouter.visibleTargetInfos(socket), } } if (message.method === "Target.attachToBrowserTarget") { - const aliasId = `bc-client-browser-${nextClientSessionAliasId++}` - cdpClientSessionAliases.get(socket)?.set(aliasId, { kind: "browser" }) - return { sessionId: aliasId } + return { sessionId: cdpClients.createBrowserAlias(socket) } } if (message.method === "Target.attachToTarget") { const targetId = typeof message.params?.targetId === "string" ? message.params.targetId : "" - const target = registry.targetsByTargetId.get(targetId) - if (target && canSeeTarget(socket, target)) { - if (hasAnnouncedSession(cdpClientAnnouncements.get(socket), target.sessionId)) { - return { sessionId: createClientSessionAlias(socket, target) } + const target = cdpRouter.targetForAttach(socket, targetId) + if (target && "owner" in target) { + if (hasAnnouncedSession(cdpClients.announcements(socket), target.sessionId)) { + return { sessionId: cdpClients.createTargetAlias(socket, target, target.sessionId) } } - sendAttachedToTarget({ socket, clientAnnouncements: cdpClientAnnouncements, target, onDuplicateTarget: logDuplicateTargetAnnouncement }) + sendAttachedToTarget({ socket, announcements: cdpClients.announcements(socket), target, onDuplicateTarget: logDuplicateTargetAnnouncement }) return { sessionId: target.sessionId } } - const childTarget = registry.childTargetsByTargetId.get(targetId) - if (childTarget && canSeeTabId(socket, childTarget.tabId)) { - if (hasAnnouncedSession(cdpClientAnnouncements.get(socket), childTarget.sessionId)) { - return { sessionId: createClientSessionAlias(socket, childTarget) } + if (target) { + if (hasAnnouncedSession(cdpClients.announcements(socket), target.sessionId)) { + return { sessionId: cdpClients.createTargetAlias(socket, target, target.parentSessionId) } } - sendAttachedToChildTarget({ socket, clientAnnouncements: cdpClientAnnouncements, target: childTarget, onDuplicateTarget: logDuplicateTargetAnnouncement }) - replayChildFrameNavigation({ socket, registry, target: childTarget }) - return { sessionId: childTarget.sessionId } + sendAttachedToChildTarget({ socket, announcements: cdpClients.announcements(socket), target, onDuplicateTarget: logDuplicateTargetAnnouncement }) + replayChildFrameNavigation({ socket, registry, target }) + return { sessionId: target.sessionId } } return yield* Effect.fail(new Error(`Target not found: ${targetId}`)) } if (message.method === "Target.getTargetInfo") { const targetId = typeof message.params?.targetId === "string" ? message.params.targetId : "" - const sessionAlias = message.sessionId ? cdpClientSessionAliases.get(socket)?.get(message.sessionId) : undefined - const aliasedTargetId = sessionAlias?.kind === "target" ? sessionAlias.targetId : undefined - const target = resolveTargetInfoTarget({ - registry, + const target = cdpRouter.targetInfo(socket, { ...(targetId ? { targetId } : {}), ...(message.sessionId ? { sessionId: message.sessionId } : {}), - ...(aliasedTargetId ? { aliasedTargetId } : {}), - fallback: () => firstVisibleRootTarget(socket), }) if (!target) { if (!targetId && !message.sessionId) { @@ -1209,13 +1180,19 @@ const makeRelay = Effect.fnUntraced(function* (options: { if (message.method === "Target.createTarget" || message.method === "Target.closeTarget") { if (message.method === "Target.createTarget") { const url = typeof message.params?.url === "string" ? message.params.url : "about:blank" - const browserControlSessionId = cdpClientBrowserControlSessionIds.get(socket) - const target = yield* createAndAttachTab({ url, active: false, ...(browserControlSessionId ? { browserControlSessionId } : {}) }) + const browserControlSessionId = cdpClients.sessionId(socket) + const autoAttachParams = cdpClients.autoAttachParams(socket) + const target = yield* createAndAttachTab({ + url, + active: false, + ...(browserControlSessionId ? { browserControlSessionId } : {}), + ...(autoAttachParams ? { autoAttachParams } : {}), + }) return { targetId: target.targetInfo.targetId } } const targetId = typeof message.params?.targetId === "string" ? message.params.targetId : "" - const target = registry.targetsByTargetId.get(targetId) - if (!target) { + const target = cdpRouter.targetForAttach(socket, targetId) + if (!target || !("owner" in target)) { return { success: false } } yield* closeTargetByTargetId(targetId) @@ -1224,30 +1201,23 @@ const makeRelay = Effect.fnUntraced(function* (options: { if (message.method === "Target.detachFromTarget") { const childSessionId = typeof message.params?.sessionId === "string" ? message.params.sessionId : undefined if (childSessionId) { - if (cdpClientSessionAliases.get(socket)?.delete(childSessionId)) { + if (cdpClients.deleteAlias(socket, childSessionId)) { return {} } - removeAnnouncedSession(cdpClientAnnouncements.get(socket), childSessionId) + removeAnnouncedSession(cdpClients.announcements(socket), childSessionId) } return {} } const normalizedMessage = removeDefaultLightColorSchemeEmulation(message) if (message.method === "Runtime.enable" && message.sessionId) { const sessionId = message.sessionId - const sessionAlias = cdpClientSessionAliases.get(socket)?.get(sessionId) - const alias = sessionAlias?.kind === "target" ? sessionAlias : undefined - const tabId = alias?.tabId ?? registry.tabIdForSession(sessionId) - if (!tabId) { + const route = cdpRouter.session(socket, sessionId) + if (!route) { return yield* Effect.fail(new Error(`Unknown CDP session ${sessionId} for ${message.method}`)) } - const rootSessionId = registry.tabTargets.get(tabId)?.sessionId - const routedSessionId = chromeSessionIdForClientRequest({ - alias, - requestedSessionId: sessionId, - rootSessionId, - }) - const chromeSessionId = routedSessionId ? { sessionId: routedSessionId } : {} - const contextSessionId = routedSessionId ?? rootSessionId ?? sessionId + const { tabId } = route + const chromeSessionId = route.chromeSessionId ? { sessionId: route.chromeSessionId } : {} + const contextSessionId = route.chromeSessionId ?? route.rootSessionId ?? sessionId contextDebugLog?.(`runtime-enable phase=client-request ${targetDiagnosticIdentity(targetForCdpSession(tabId, sessionId))}`) // Register the waiter before sending the enable so context events that // arrive during the command round trip are not missed. @@ -1275,23 +1245,18 @@ const makeRelay = Effect.fnUntraced(function* (options: { } return result } - const sessionAlias = message.sessionId ? cdpClientSessionAliases.get(socket)?.get(message.sessionId) : undefined - const alias = sessionAlias?.kind === "target" ? sessionAlias : undefined - const tabId = alias?.tabId ?? (message.sessionId ? registry.tabIdForSession(message.sessionId) : firstVisibleRootTarget(socket)?.tabId) - if (!tabId) { - return yield* Effect.fail(new Error(message.sessionId ? `Unknown CDP session ${message.sessionId} for ${message.method}` : `No attached tab for ${message.method}`)) + const route = message.sessionId ? cdpRouter.session(socket, message.sessionId) : undefined + if (!route) { + return yield* Effect.fail(new Error(message.sessionId + ? `Unknown CDP session ${message.sessionId} for ${message.method}` + : `CDP sessionId is required for ${message.method}`)) } - const rootSessionId = registry.tabTargets.get(tabId)?.sessionId - const chromeSessionId = chromeSessionIdForClientRequest({ - alias, - requestedSessionId: message.sessionId, - rootSessionId, - }) + const { tabId } = route const result = yield* sendDebuggerCommand({ tabId, method: normalizedMessage.method, params: normalizedMessage.params ?? {}, - ...(chromeSessionId === undefined ? {} : { sessionId: chromeSessionId }), + ...(route.chromeSessionId === undefined ? {} : { sessionId: route.chromeSessionId }), }) yield* applyGhostCursorMouseEvent({ tabId, message }).pipe(Effect.ignore) return result @@ -1321,18 +1286,6 @@ const makeRelay = Effect.fnUntraced(function* (options: { } } - function createClientSessionAlias(socket: WebSocket, target: ConnectedTarget | ChildTarget): string { - const aliasId = `bc-client-session-${nextClientSessionAliasId++}` - const rootSessionId = registry.tabTargets.get(target.tabId)?.sessionId - cdpClientSessionAliases.get(socket)?.set(aliasId, { - kind: "target", - tabId: target.tabId, - targetId: target.targetInfo.targetId, - ...(target.sessionId === rootSessionId ? {} : { chromeSessionId: target.sessionId }), - }) - return aliasId - } - const toggleTab = Effect.fnUntraced(function* (tabId: number) { if (registry.tabTargets.has(tabId)) { yield* sendToExtension({ method: "debugger.detach", params: { tabId } }) @@ -1347,6 +1300,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { readonly url: string readonly active: boolean readonly browserControlSessionId?: string + readonly autoAttachParams?: JsonObject }) { const result = yield* sendToExtension({ method: "tabs.create", params: { url: options.url, active: options.active } }) const tabId = typeof result.tabId === "number" ? result.tabId : undefined @@ -1357,6 +1311,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { tabId, owner: "relay", ...(options.browserControlSessionId ? { browserControlSessionId: options.browserControlSessionId } : {}), + ...(options.autoAttachParams ? { autoAttachParams: options.autoAttachParams } : {}), }) }) @@ -1365,6 +1320,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { readonly owner: "relay" | "user" readonly browserControlSessionId?: string readonly alreadyAttached?: boolean + readonly autoAttachParams?: JsonObject }) { const { tabId } = options if (!options.alreadyAttached) { @@ -1389,10 +1345,10 @@ const makeRelay = Effect.fnUntraced(function* (options: { owner: restoredTarget?.owner ?? options.owner, ...(browserControlSessionId ? { browserControlSessionId } : {}), } - return yield* finishAttachedTarget(registry.stageRootTarget(candidate)) + return yield* finishAttachedTarget(registry.stageRootTarget(candidate), options.autoAttachParams) }) - const finishAttachedTarget = Effect.fnUntraced(function* (target: ConnectedTarget) { + const finishAttachedTarget = Effect.fnUntraced(function* (target: ConnectedTarget, autoAttachParams?: JsonObject) { const tabId = target.tabId yield* sendDebuggerCommand({ tabId, @@ -1447,6 +1403,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { readonly browserControlSessionId?: string readonly alreadyAttached?: boolean readonly expectedExtensionGeneration?: number + readonly autoAttachParams?: JsonObject }) { const semaphore = rootLifecycleSemaphores.get(options.tabId) ?? Semaphore.makeUnsafe(1) rootLifecycleSemaphores.set(options.tabId, semaphore) @@ -1614,13 +1571,13 @@ const makeRelay = Effect.fnUntraced(function* (options: { const disableRuntimeForIdleTargets = Effect.fnUntraced(function* (generation: number) { yield* Effect.forEach(Array.from(registry.targets.values()), (target) => { - if (generation !== idleRuntimeResetGeneration || cdpClients.size !== 0) { + if (!cdpClients.isCurrentIdleGeneration(generation)) { return Effect.void } return runRuntimeResetCommand({ phase: "idle-client-disconnect", tabId: target.tabId, method: "Runtime.disable", params: {} }).pipe(Effect.asVoid) }) yield* Effect.forEach(Array.from(registry.childTargets.values()), (target) => { - if (generation !== idleRuntimeResetGeneration || cdpClients.size !== 0) { + if (!cdpClients.isCurrentIdleGeneration(generation)) { return Effect.void } return runRuntimeResetCommand({ phase: "idle-client-disconnect", tabId: target.tabId, sessionId: target.sessionId, method: "Runtime.disable", params: {} }).pipe(Effect.asVoid) @@ -1645,7 +1602,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { } cancelTargetHandoffs(detached.target, "target-detached") if (!options.preserveSessionTarget) sessions.markTargetDetached(detached.target.targetInfo.targetId) - removeClientTargetAliases(cdpClientSessionAliases.values(), (alias) => alias.tabId === tabId) + cdpClients.removeTargetAliases((alias) => alias.tabId === tabId) mainFrameIdsByTab.delete(tabId) ghostCursorPositionsByTab.delete(tabId) for (const [sessionId, childTabId] of suppressedChildSessions) { @@ -1654,6 +1611,11 @@ const makeRelay = Effect.fnUntraced(function* (options: { } } contextDebugLog?.(`target-detached kind=root ${targetDiagnosticIdentity(detached.target)}`) + for (const client of cdpClients) { + for (const childSessionId of detached.childSessionIds) { + detachAnnouncedSession(client, childSessionId) + } + } sendEventToTargetViewers(detached.target.sessionId, { method: "Target.targetDestroyed", params: { targetId: detached.target.targetInfo.targetId }, @@ -1662,11 +1624,9 @@ const makeRelay = Effect.fnUntraced(function* (options: { method: "Target.detachedFromTarget", params: { sessionId: detached.target.sessionId, targetId: detached.target.targetInfo.targetId }, }) - for (const announcements of cdpClientAnnouncements.values()) { + for (const client of cdpClients) { + const announcements = cdpClients.announcements(client) removeAnnouncedSession(announcements, detached.target.sessionId) - for (const childSessionId of detached.childSessionIds) { - removeAnnouncedSession(announcements, childSessionId) - } } } @@ -1679,7 +1639,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { targetSessionId: change.target.sessionId, }) sessions.markTargetReplaced(change.previous.targetInfo.targetId, change.target.targetInfo.targetId) - removeClientTargetAliases(cdpClientSessionAliases.values(), (alias) => alias.tabId === change.target.tabId) + cdpClients.removeTargetAliases((alias) => alias.tabId === change.target.tabId) mainFrameIdsByTab.delete(change.target.tabId) ghostCursorPositionsByTab.delete(change.target.tabId) for (const [sessionId, childTabId] of suppressedChildSessions) { @@ -1700,50 +1660,25 @@ const makeRelay = Effect.fnUntraced(function* (options: { } const detached = registry.detachChildTargetState(sessionId) if (detached) { - removeClientTargetAliases(cdpClientSessionAliases.values(), (alias) => alias.targetId === detached.targetInfo.targetId) + cdpClients.removeTargetAliases((alias) => alias.targetId === detached.targetInfo.targetId) } if (!notifyClients) { - for (const announcements of cdpClientAnnouncements.values()) { - removeAnnouncedSession(announcements, sessionId) + for (const client of cdpClients) { + removeAnnouncedSession(cdpClients.announcements(client), sessionId) } } } - function canSeeTarget(socket: WebSocket, target: ConnectedTarget): boolean { - const clientSessionId = cdpClientBrowserControlSessionIds.get(socket) - return canClientSeeTarget({ - clientSessionId, - targetOwnerSessionId: target.browserControlSessionId, - targetOwner: target.owner, - clientHasOwnedTarget: clientHasOwnedTarget(clientSessionId), - }) - } - - function clientHasOwnedTarget(clientSessionId: string | undefined): boolean { - return clientSessionId ? registry.listRootTargets().some((candidate) => candidate.browserControlSessionId === clientSessionId) : false - } - - function canSeeTabId(socket: WebSocket, tabId: number): boolean { - const rootTarget = registry.tabTargets.get(tabId) - return rootTarget ? canSeeTarget(socket, rootTarget) : true - } - - function firstVisibleRootTarget(socket: WebSocket): ConnectedTarget | undefined { - return Array.from(registry.targets.values()).find((target) => { - return canSeeTarget(socket, target) - }) - } - // Deliver a session-scoped event only to clients that have been told about // the tab's root target. Broadcasting to every client lets concurrently // connected sandboxes attach to each other's pages and interfere. function sendEventToTargetViewers(rootSessionId: string, event: CdpEvent): void { const target = registry.targets.get(rootSessionId) for (const client of cdpClients) { - if (!hasAnnouncedSession(cdpClientAnnouncements.get(client), rootSessionId)) { + if (!hasAnnouncedSession(cdpClients.announcements(client), rootSessionId)) { continue } - if (target && !canSeeTarget(client, target)) { + if (target && !cdpRouter.canSeeTarget(client, target)) { detachAnnouncedSession(client, rootSessionId) continue } @@ -1753,27 +1688,26 @@ const makeRelay = Effect.fnUntraced(function* (options: { function pruneInvisibleAnnouncementsForSession(browserControlSessionId: string): void { for (const client of cdpClients) { - if (cdpClientBrowserControlSessionIds.get(client) === browserControlSessionId) { + if (cdpClients.sessionId(client) === browserControlSessionId) { pruneInvisibleAnnouncementsForClient(client) } } } function pruneInvisibleAnnouncementsForClient(client: WebSocket): void { - const announcements = cdpClientAnnouncements.get(client) - if (!announcements) { - return - } + const announcements = cdpClients.announcements(client) for (const announced of Array.from(announcements.targets.values())) { const rootTarget = registry.targets.get(announced.sessionId) if (rootTarget) { - if (!canSeeTarget(client, rootTarget)) { + if (!cdpRouter.canSeeTarget(client, rootTarget)) { + cdpClients.removeClientTargetAliases(client, (alias) => alias.tabId === rootTarget.tabId) detachAnnouncedSession(client, announced.sessionId) } continue } const childTarget = registry.childTargets.get(announced.sessionId) - if (childTarget && !canSeeTabId(client, childTarget.tabId)) { + if (childTarget && !cdpRouter.canSeeTab(client, childTarget.tabId)) { + cdpClients.removeClientTargetAliases(client, (alias) => alias.tabId === childTarget.tabId) detachAnnouncedSession(client, announced.sessionId) } } @@ -1781,6 +1715,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { function reconcileTargetOwnership(change: TargetOwnershipChange): void { for (const client of cdpClients) { + cdpRouter.pruneInvisibleAliases(client, change.tabIds) pruneInvisibleAnnouncementsForClient(client) } for (const targetId of change.targetIds) { @@ -1795,7 +1730,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { } function detachAnnouncedSession(client: WebSocket, sessionId: string): void { - const announcements = cdpClientAnnouncements.get(client) + const announcements = cdpClients.announcements(client) const targetId = announcements?.sessionTargets.get(sessionId) const announced = targetId ? announcements?.targets.get(targetId) : undefined removeAnnouncedSession(announcements, sessionId) @@ -1814,29 +1749,20 @@ const makeRelay = Effect.fnUntraced(function* (options: { function announceAttachedTarget(target: ConnectedTarget): void { for (const client of cdpClients) { - if (canSeeTarget(client, target)) { - sendAttachedToTarget({ socket: client, clientAnnouncements: cdpClientAnnouncements, target, onDuplicateTarget: logDuplicateTargetAnnouncement }) + if (cdpRouter.canSeeTarget(client, target)) { + sendAttachedToTarget({ socket: client, announcements: cdpClients.announcements(client), target, onDuplicateTarget: logDuplicateTargetAnnouncement }) } } } function announceAttachedChildTarget(rootSessionId: string, target: ChildTarget): void { for (const client of cdpClients) { - if (hasAnnouncedSession(cdpClientAnnouncements.get(client), rootSessionId)) { - sendAttachedToChildTarget({ socket: client, clientAnnouncements: cdpClientAnnouncements, target, onDuplicateTarget: logDuplicateTargetAnnouncement }) + if (hasAnnouncedSession(cdpClients.announcements(client), rootSessionId)) { + sendAttachedToChildTarget({ socket: client, announcements: cdpClients.announcements(client), target, onDuplicateTarget: logDuplicateTargetAnnouncement }) } } } - function visibleTargetInfos(socket: WebSocket) { - return registry.allTargetInfos({ - isRestrictedTarget, - isVisibleTarget: (target) => { - return canSeeTabId(socket, target.tabId) && ("owner" in target || shouldExposeChildTarget(target)) - }, - }) - } - // Resolves true once a default Runtime.executionContextCreated event arrives // for the session, or false when none arrives within the wait window. function waitForDefaultRuntimeContext(sessionId: string): Effect.Effect { diff --git a/src/target-registry.ts b/src/target-registry.ts index 77a1395..c38a037 100644 --- a/src/target-registry.ts +++ b/src/target-registry.ts @@ -104,26 +104,6 @@ export function shouldExposeChildTarget(target: ChildTarget): boolean { return target.targetInfo.type !== "page" || target.targetInfo.url !== "" } -export function resolveTargetInfoTarget(options: { - readonly registry: TargetRegistry - readonly targetId?: string - readonly sessionId?: string - readonly aliasedTargetId?: string - readonly fallback?: () => ConnectedTarget | undefined -}): ConnectedTarget | ChildTarget | undefined { - const explicit = Boolean(options.targetId || options.sessionId || options.aliasedTargetId) - return (options.targetId - ? options.registry.targetsByTargetId.get(options.targetId) ?? options.registry.childTargetsByTargetId.get(options.targetId) - : undefined) ?? - (options.aliasedTargetId - ? options.registry.targetsByTargetId.get(options.aliasedTargetId) ?? options.registry.childTargetsByTargetId.get(options.aliasedTargetId) - : undefined) ?? - (options.sessionId - ? options.registry.targets.get(options.sessionId) ?? options.registry.childTargets.get(options.sessionId) - : undefined) ?? - (explicit ? undefined : options.fallback?.()) -} - export class TargetRegistry { readonly targets = new Map() readonly tabTargets = new Map() diff --git a/test/cdp-client-pool.test.ts b/test/cdp-client-pool.test.ts new file mode 100644 index 0000000..eae9c32 --- /dev/null +++ b/test/cdp-client-pool.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest" +import { CdpClientPool } from "../src/cdp-client-pool.ts" +import type { ChildTarget, ConnectedTarget } from "../src/relay-types.ts" + +const rootTarget: ConnectedTarget = { + tabId: 7, + sessionId: "bc-tab-7", + targetInfo: { + targetId: "root-7", + type: "page", + title: "Root", + url: "https://example.com/", + attached: true, + canAccessOpener: false, + }, + owner: "user", +} + +const childTarget: ChildTarget = { + tabId: 7, + sessionId: "chrome-child-7", + parentSessionId: "bc-tab-7", + targetInfo: { + targetId: "child-7", + type: "iframe", + title: "Child", + url: "https://child.example.com/", + attached: true, + canAccessOpener: false, + }, + waitingForDebugger: false, +} + +describe("CdpClientPool", () => { + it("owns registration and cleanup for all per-client state", () => { + const pool = new CdpClientPool() + const client = {} + pool.register(client, "session-a") + pool.setAutoAttachParams(client, { autoAttach: true, flatten: true }) + const aliasId = pool.createBrowserAlias(client) + + expect(pool.size).toBe(1) + expect([...pool]).toEqual([client]) + expect(pool.sessionId(client)).toBe("session-a") + expect(pool.autoAttachParams(client)).toEqual({ autoAttach: true, flatten: true }) + expect(pool.alias(client, aliasId)).toEqual({ kind: "browser" }) + expect(pool.announcements(client)).toBeDefined() + + expect(pool.unregister(client)).toBeTypeOf("number") + expect(pool.size).toBe(0) + expect(pool.sessionId(client)).toBeUndefined() + expect(pool.alias(client, aliasId)).toBeUndefined() + expect(() => pool.announcements(client)).toThrow("CDP client is not registered") + }) + + it("keeps conflicting auto-attach settings scoped to their clients", () => { + const pool = new CdpClientPool() + const first = {} + const second = {} + pool.register(first) + pool.register(second) + pool.setAutoAttachParams(first, { autoAttach: true, waitForDebuggerOnStart: false }) + pool.setAutoAttachParams(second, { autoAttach: false, waitForDebuggerOnStart: true }) + + expect(pool.autoAttachParams(first)).toEqual({ autoAttach: true, waitForDebuggerOnStart: false }) + expect(pool.autoAttachParams(second)).toEqual({ autoAttach: false, waitForDebuggerOnStart: true }) + }) + + it("rejects duplicate registration and aliases for unknown clients", () => { + const pool = new CdpClientPool() + const client = {} + pool.register(client) + + expect(() => pool.register(client)).toThrow("CDP client is already registered") + expect(() => pool.createBrowserAlias({})).toThrow("CDP client is not registered") + }) + + it("invalidates an idle generation when another client registers", () => { + const pool = new CdpClientPool() + const first = {} + pool.register(first) + const idleGeneration = pool.unregister(first) + expect(idleGeneration).toBeDefined() + if (idleGeneration === undefined) throw new Error("Expected idle generation") + expect(pool.isCurrentIdleGeneration(idleGeneration)).toBe(true) + + pool.register({}) + expect(pool.isCurrentIdleGeneration(idleGeneration)).toBe(false) + }) + + it("produces an idle generation only when the last client leaves", () => { + const pool = new CdpClientPool() + const first = {} + const second = {} + pool.register(first) + pool.register(second) + + expect(pool.unregister(first)).toBeUndefined() + const idleGeneration = pool.unregister(second) + expect(idleGeneration).toBeDefined() + expect(pool.unregister(second)).toBeUndefined() + }) + + it("routes root aliases without a Chrome session and child aliases with one", () => { + const pool = new CdpClientPool() + const client = {} + pool.register(client) + + const rootAlias = pool.createTargetAlias(client, rootTarget, rootTarget.sessionId) + const childAlias = pool.createTargetAlias(client, childTarget, rootTarget.sessionId) + + expect(pool.alias(client, rootAlias)).toEqual({ kind: "target", tabId: 7, targetId: "root-7" }) + expect(pool.alias(client, childAlias)).toEqual({ + kind: "target", + tabId: 7, + targetId: "child-7", + chromeSessionId: "chrome-child-7", + }) + }) + + it("removes matching target aliases across clients without touching browser aliases", () => { + const pool = new CdpClientPool() + const first = {} + const second = {} + pool.register(first) + pool.register(second) + const browserAlias = pool.createBrowserAlias(first) + const firstTargetAlias = pool.createTargetAlias(first, rootTarget, rootTarget.sessionId) + const secondTargetAlias = pool.createTargetAlias(second, rootTarget, rootTarget.sessionId) + + pool.removeTargetAliases((alias) => alias.tabId === 7) + + expect(pool.alias(first, browserAlias)).toEqual({ kind: "browser" }) + expect(pool.alias(first, firstTargetAlias)).toBeUndefined() + expect(pool.alias(second, secondTargetAlias)).toBeUndefined() + }) + + it("can prune one client's target aliases without affecting another client", () => { + const pool = new CdpClientPool() + const first = {} + const second = {} + pool.register(first) + pool.register(second) + const firstAlias = pool.createTargetAlias(first, rootTarget, rootTarget.sessionId) + const secondAlias = pool.createTargetAlias(second, rootTarget, rootTarget.sessionId) + + pool.removeClientTargetAliases(first, (alias) => alias.tabId === 7) + + expect(pool.alias(first, firstAlias)).toBeUndefined() + expect(pool.alias(second, secondAlias)).toBeDefined() + }) +}) diff --git a/test/cdp-router.test.ts b/test/cdp-router.test.ts new file mode 100644 index 0000000..3db451d --- /dev/null +++ b/test/cdp-router.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest" +import { CdpClientPool } from "../src/cdp-client-pool.ts" +import { CdpRouter } from "../src/cdp-router.ts" +import type { ConnectedTarget } from "../src/relay-types.ts" +import { TargetRegistry } from "../src/target-registry.ts" + +function root(options: { + readonly tabId: number + readonly sessionId: string + readonly targetId: string + readonly browserControlSessionId?: string + readonly owner?: "relay" | "user" +}): ConnectedTarget { + return { + tabId: options.tabId, + sessionId: options.sessionId, + targetInfo: { + targetId: options.targetId, + type: "page", + title: options.targetId, + url: `https://example.com/${options.targetId}`, + attached: true, + canAccessOpener: false, + }, + owner: options.owner ?? "relay", + ...(options.browserControlSessionId ? { browserControlSessionId: options.browserControlSessionId } : {}), + } +} + +function setup() { + const clients = new CdpClientPool() + const registry = new TargetRegistry() + const router = new CdpRouter(clients, registry) + return { clients, registry, router } +} + +describe("CdpRouter", () => { + it("resolves only targets visible to the client", () => { + const { clients, registry, router } = setup() + const owner = {} + const other = {} + clients.register(owner, "session-a") + clients.register(other, "session-b") + const target = root({ + tabId: 1, + sessionId: "bc-tab-1", + targetId: "target-1", + browserControlSessionId: "session-a", + }) + registry.addRootTarget(target) + + expect(router.targetForAttach(owner, "target-1")).toBe(target) + expect(router.targetInfo(owner, { sessionId: "bc-tab-1" })).toBe(target) + expect(router.targetForAttach(other, "target-1")).toBeUndefined() + expect(router.targetInfo(other, { targetId: "target-1" })).toBeUndefined() + }) + + it("routes root aliases without a Chrome session and child aliases with one", () => { + const { clients, registry, router } = setup() + const client = {} + clients.register(client) + const target = root({ tabId: 1, sessionId: "bc-tab-1", targetId: "target-1" }) + registry.addRootTarget(target) + const child = { + tabId: 1, + sessionId: "child-session", + parentSessionId: target.sessionId, + targetInfo: { ...target.targetInfo, targetId: "child-target", type: "iframe" as const }, + waitingForDebugger: false, + } + registry.addChildTarget(child) + const rootAlias = clients.createTargetAlias(client, target, target.sessionId) + const childAlias = clients.createTargetAlias(client, child, target.sessionId) + + expect(router.session(client, rootAlias)).toEqual({ tabId: 1, rootSessionId: "bc-tab-1" }) + expect(router.session(client, childAlias)).toEqual({ + tabId: 1, + rootSessionId: "bc-tab-1", + chromeSessionId: "child-session", + }) + }) + + it("does not resolve identity-free or hidden session routes", () => { + const { clients, registry, router } = setup() + const client = {} + clients.register(client, "session-b") + registry.addRootTarget(root({ + tabId: 1, + sessionId: "bc-tab-1", + targetId: "target-1", + browserControlSessionId: "session-a", + })) + + expect(router.targetInfo(client, {})).toBeUndefined() + expect(router.session(client, "bc-tab-1")).toBeUndefined() + expect(router.rootForSession(client, "bc-tab-1")).toBeUndefined() + }) + + it("rejects stale aliases and children without a live root", () => { + const { clients, registry, router } = setup() + const client = {} + clients.register(client) + const target = root({ tabId: 1, sessionId: "bc-tab-1", targetId: "target-1" }) + registry.addRootTarget(target) + const alias = clients.createTargetAlias(client, target, target.sessionId) + + registry.addRootTarget(root({ tabId: 1, sessionId: "bc-tab-2", targetId: "target-2" })) + expect(router.session(client, alias)).toBeUndefined() + + registry.detachRootTargetState(1) + const orphan = { + tabId: 1, + sessionId: "orphan-session", + parentSessionId: "bc-tab-missing", + targetInfo: { ...target.targetInfo, targetId: "orphan-target", type: "iframe" as const }, + waitingForDebugger: false, + } + registry.addChildTarget(orphan) + expect(router.targetForAttach(client, "orphan-target")).toBeUndefined() + expect(router.session(client, "orphan-session")).toBeUndefined() + }) + + it("does not fall through from an explicit hidden target to a visible session", () => { + const { clients, registry, router } = setup() + const client = {} + clients.register(client, "session-a") + const visible = root({ tabId: 1, sessionId: "visible-session", targetId: "visible-target", browserControlSessionId: "session-a" }) + const hidden = root({ tabId: 2, sessionId: "hidden-session", targetId: "hidden-target", browserControlSessionId: "session-b" }) + registry.addRootTarget(visible) + registry.addRootTarget(hidden) + + expect(router.targetInfo(client, { + targetId: "hidden-target", + sessionId: "visible-session", + })).toBeUndefined() + }) + + it("prunes aliases when ownership hides a tab even without an announcement", () => { + const { clients, registry, router } = setup() + const client = {} + clients.register(client, "session-a") + const rawTarget = root({ tabId: 1, sessionId: "raw-session", targetId: "raw-target" }) + registry.addRootTarget(rawTarget) + const alias = clients.createTargetAlias(client, rawTarget, rawTarget.sessionId) + registry.addRootTarget(root({ + tabId: 2, + sessionId: "owned-session", + targetId: "owned-target", + browserControlSessionId: "session-a", + })) + + router.pruneInvisibleAliases(client, [rawTarget.tabId]) + + expect(clients.alias(client, alias)).toBeUndefined() + }) + + it("lists only visible roots and exposed children", () => { + const { clients, registry, router } = setup() + const client = {} + clients.register(client, "session-a") + const visible = root({ tabId: 1, sessionId: "bc-tab-1", targetId: "target-1", browserControlSessionId: "session-a" }) + const hidden = root({ tabId: 2, sessionId: "bc-tab-2", targetId: "target-2", browserControlSessionId: "session-b" }) + registry.addRootTarget(visible) + registry.addRootTarget(hidden) + registry.addChildTarget({ + tabId: 1, + sessionId: "child-visible", + parentSessionId: visible.sessionId, + targetInfo: { ...visible.targetInfo, targetId: "child-visible-target", type: "worker" }, + waitingForDebugger: false, + }) + registry.addChildTarget({ + tabId: 1, + sessionId: "child-held", + parentSessionId: visible.sessionId, + targetInfo: { ...visible.targetInfo, targetId: "child-held-target", type: "page", url: "" }, + waitingForDebugger: false, + }) + + expect(router.visibleTargetInfos(client).map((target) => target.targetId)).toEqual(["target-1", "child-visible-target"]) + }) +}) diff --git a/test/cdp-shims.test.ts b/test/cdp-shims.test.ts index 538796f..6383551 100644 --- a/test/cdp-shims.test.ts +++ b/test/cdp-shims.test.ts @@ -1,14 +1,11 @@ import { describe, expect, it } from "vitest" import type { WebSocket } from "ws" import { - chromeSessionIdForClientRequest, createClientTargetAnnouncements, hasAnnouncedSession, - removeClientTargetAliases, replayChildTargetsForParent, sendAttachedToChildTarget, sendAttachedToTarget, - type ClientCdpSessionAlias, } from "../src/cdp-shims.ts" import type { ChildTarget, ConnectedTarget } from "../src/relay-types.ts" import type { CdpEvent, TargetInfo } from "../src/protocol.ts" @@ -34,39 +31,6 @@ function child(sessionId: string, targetId = "child-target-1", parentSessionId = return { tabId: 1, sessionId, parentSessionId, targetInfo: { ...targetInfo(targetId), type: "iframe" }, waitingForDebugger: false } } -describe("chromeSessionIdForClientRequest", () => { - it("does not forward a client alias for a root target to Chrome", () => { - expect(chromeSessionIdForClientRequest({ - alias: {}, - requestedSessionId: "bc-client-session-1", - rootSessionId: "bc-tab-1", - })).toBeUndefined() - }) - - it("forwards the real Chrome session behind a child-target alias", () => { - expect(chromeSessionIdForClientRequest({ - alias: { chromeSessionId: "chrome-child-1" }, - requestedSessionId: "bc-client-session-2", - rootSessionId: "bc-tab-1", - })).toBe("chrome-child-1") - }) -}) - -describe("removeClientTargetAliases", () => { - it("removes aliases for a detached tab without touching other tabs", () => { - const aliases = new Map([ - ["browser", { kind: "browser" }], - ["detached-root", { kind: "target", tabId: 7, targetId: "root-7" }], - ["detached-child", { kind: "target", tabId: 7, targetId: "child-7", chromeSessionId: "chrome-child-7" }], - ["other-root", { kind: "target", tabId: 8, targetId: "root-8" }], - ]) - - removeClientTargetAliases([aliases], (alias) => alias.tabId === 7) - - expect(Array.from(aliases.keys())).toEqual(["browser", "other-root"]) - }) -}) - describe("TargetRegistry crash state", () => { it("marks a root target crashed and clears the marker after navigation", () => { const registry = new TargetRegistry() @@ -84,23 +48,23 @@ describe("sendAttachedToTarget", () => { it("does not re-announce the same target id and session id", () => { const events: CdpEvent[] = [] const client = socket(events) - const announcements = new Map([[client, createClientTargetAnnouncements()]]) + const announcements = createClientTargetAnnouncements() - sendAttachedToTarget({ socket: client, clientAnnouncements: announcements, target: root("bc-tab-1") }) - sendAttachedToTarget({ socket: client, clientAnnouncements: announcements, target: root("bc-tab-1") }) + sendAttachedToTarget({ socket: client, announcements, target: root("bc-tab-1") }) + sendAttachedToTarget({ socket: client, announcements, target: root("bc-tab-1") }) expect(events).toHaveLength(1) expect(events[0]?.method).toBe("Target.attachedToTarget") - expect(hasAnnouncedSession(announcements.get(client), "bc-tab-1")).toBe(true) + expect(hasAnnouncedSession(announcements, "bc-tab-1")).toBe(true) }) it("detaches the old session before re-announcing the same target id under a new session id", () => { const events: CdpEvent[] = [] const client = socket(events) - const announcements = new Map([[client, createClientTargetAnnouncements()]]) + const announcements = createClientTargetAnnouncements() - sendAttachedToTarget({ socket: client, clientAnnouncements: announcements, target: root("bc-tab-1") }) - sendAttachedToTarget({ socket: client, clientAnnouncements: announcements, target: root("bc-tab-2") }) + sendAttachedToTarget({ socket: client, announcements, target: root("bc-tab-1") }) + sendAttachedToTarget({ socket: client, announcements, target: root("bc-tab-2") }) expect(events.map((event) => event.method)).toEqual([ "Target.attachedToTarget", @@ -108,8 +72,8 @@ describe("sendAttachedToTarget", () => { "Target.attachedToTarget", ]) expect(events[1]).toEqual({ method: "Target.detachedFromTarget", params: { sessionId: "bc-tab-1", targetId: "target-1" } }) - expect(hasAnnouncedSession(announcements.get(client), "bc-tab-1")).toBe(false) - expect(hasAnnouncedSession(announcements.get(client), "bc-tab-2")).toBe(true) + expect(hasAnnouncedSession(announcements, "bc-tab-1")).toBe(false) + expect(hasAnnouncedSession(announcements, "bc-tab-2")).toBe(true) }) }) @@ -117,10 +81,10 @@ describe("sendAttachedToChildTarget", () => { it("detaches duplicate child target ids on the parent session before re-announcing", () => { const events: CdpEvent[] = [] const client = socket(events) - const announcements = new Map([[client, createClientTargetAnnouncements()]]) + const announcements = createClientTargetAnnouncements() - sendAttachedToChildTarget({ socket: client, clientAnnouncements: announcements, target: child("child-session-1") }) - sendAttachedToChildTarget({ socket: client, clientAnnouncements: announcements, target: child("child-session-2") }) + sendAttachedToChildTarget({ socket: client, announcements, target: child("child-session-1") }) + sendAttachedToChildTarget({ socket: client, announcements, target: child("child-session-2") }) expect(events.map((event) => event.method)).toEqual([ "Target.attachedToTarget", @@ -149,7 +113,7 @@ describe("sendAttachedToChildTarget", () => { socket: client, parentSessionId: "bc-tab-1", registry, - clientAnnouncements: announcements, + announcements: createClientTargetAnnouncements(), }) expect(events).toHaveLength(1) @@ -173,7 +137,7 @@ describe("sendAttachedToChildTarget", () => { socket: client, parentSessionId: "bc-tab-1", registry, - clientAnnouncements: new Map([[client, createClientTargetAnnouncements()]]), + announcements: createClientTargetAnnouncements(), }) expect(events).toEqual([]) diff --git a/test/cli-session-selector.test.ts b/test/cli-session-selector.test.ts new file mode 100644 index 0000000..4362137 --- /dev/null +++ b/test/cli-session-selector.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest" +import { resolveExplicitSessionSelector } from "../src/cli-session-selector.ts" + +describe("resolveExplicitSessionSelector", () => { + it("accepts positional, flag, and environment selectors in precedence order", () => { + expect(resolveExplicitSessionSelector({ positional: "positional", flag: undefined, environment: "environment" })).toBe("positional") + expect(resolveExplicitSessionSelector({ positional: undefined, flag: "flag", environment: "environment" })).toBe("flag") + expect(resolveExplicitSessionSelector({ positional: undefined, flag: undefined, environment: "environment" })).toBe("environment") + expect(resolveExplicitSessionSelector({ positional: undefined, flag: undefined, environment: undefined })).toBeUndefined() + }) + + it("rejects combining positional and flag selectors", () => { + expect(() => resolveExplicitSessionSelector({ + positional: "positional", + flag: "flag", + environment: undefined, + })).toThrow("Use either a positional session id or --session, not both") + }) +}) diff --git a/test/doctor.test.ts b/test/doctor.test.ts index bcc7a41..5517745 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest" -import { extensionProtocolCheck, formatTargetSummary, relayBuildCheck, unhealthyTargetsCheck } from "../src/doctor.ts" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { createDoctorReport, extensionProtocolCheck, formatTargetSummary, relayBuildCheck, unhealthyTargetsCheck } from "../src/doctor.ts" +import * as RelayClient from "../src/relay-client.ts" +import * as SessionStore from "../src/session-store.ts" describe("formatTargetSummary", () => { it("shows crashed target state", () => { @@ -75,12 +82,12 @@ describe("extensionProtocolCheck", () => { value: { connected: true, version: "9.4.2", - protocolVersion: 1, + protocolVersion: 2, protocolCompatible: true, protocolLegacy: false, activeTargets: 0, }, - })).toMatchObject({ status: "ok", message: "runtime 1 is compatible with relay 1" }) + })).toMatchObject({ status: "ok", message: "runtime 2 is compatible with relay 2" }) }) it("fails an incompatible extension protocol", () => { @@ -89,11 +96,67 @@ describe("extensionProtocolCheck", () => { value: { connected: false, version: "10.0.0", - protocolVersion: 2, + protocolVersion: 3, protocolCompatible: false, protocolLegacy: false, activeTargets: 0, }, - })).toMatchObject({ status: "fail", message: "runtime 2 is incompatible with relay 1" }) + })).toMatchObject({ status: "fail", message: "runtime 3 is incompatible with relay 2" }) + }) +}) + +describe("createDoctorReport", () => { + it("compares the runtime extension with the manifest shipped in the package", async () => { + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-doctor-")) + try { + await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }) + await fs.mkdir(path.join(packageRoot, "extension", "dist"), { recursive: true }) + await Promise.all([ + fs.writeFile(path.join(packageRoot, "package.json"), JSON.stringify({ + name: "@opencode-ai/browser-control", + version: "1.0.0", + bin: { "browser-control": "./dist/cli.js", "browser-control-mcp": "./dist/mcp.js" }, + })), + fs.writeFile(path.join(packageRoot, "dist", "cli.js"), ""), + fs.writeFile(path.join(packageRoot, "dist", "mcp.js"), ""), + fs.writeFile(path.join(packageRoot, "extension", "manifest.json"), JSON.stringify({ version: "9.9.9" })), + fs.writeFile(path.join(packageRoot, "extension", "dist", "manifest.json"), JSON.stringify({ version: "0.0.23" })), + ]) + const relay = { + endpoint: "http://127.0.0.1:19989", + version: Effect.succeed({ version: "1.0.0", buildId: "test" }), + extensionStatus: Effect.succeed({ + connected: true, + version: "0.0.23", + protocolVersion: 2, + protocolCompatible: true, + protocolLegacy: false, + activeTargets: 0, + }), + targets: Effect.succeed([]), + sessions: Effect.succeed([]), + } as unknown as RelayClient.Interface + const store = { + endpoint: relay.endpoint, + filePath: path.join(packageRoot, "session.json"), + read: Effect.succeed(undefined), + } as unknown as SessionStore.Interface + const report = await Effect.runPromise(createDoctorReport({ packageRoot }).pipe( + Effect.provide(Layer.mergeAll( + NodeFileSystem.layer, + NodePath.layer, + Layer.succeed(RelayClient.Service, relay), + Layer.succeed(SessionStore.Service, store), + )), + )) + + expect(report.extension).toMatchObject({ expectedVersion: "0.0.23", versionMatches: true }) + expect(report.checks.find((check) => check.id === "extension-version")).toMatchObject({ + status: "ok", + message: "matches bundled extension (0.0.23)", + }) + } finally { + await fs.rm(packageRoot, { recursive: true, force: true }) + } }) }) diff --git a/test/execute-ergonomics.test.ts b/test/execute-ergonomics.test.ts index e673ecb..6ffc866 100644 --- a/test/execute-ergonomics.test.ts +++ b/test/execute-ergonomics.test.ts @@ -127,6 +127,57 @@ describe("user code execution", () => { }) describe("fillInputs", () => { + it("updates values without changing document focus", async () => { + class MockInput { + private currentValue = "" + readonly focus = vi.fn() + readonly blur = vi.fn() + readonly dispatchEvent = vi.fn() + + get value() { + return this.currentValue + } + + set value(value: string) { + this.currentValue = value + } + } + + const input = new MockInput() + const evaluate = vi.fn(async (run: (fields: Array<{ readonly target: string; readonly label: string; readonly value: string }>) => unknown, fields) => { + const previousDocument = globalThis.document + const previousInput = globalThis.HTMLInputElement + const previousTextArea = globalThis.HTMLTextAreaElement + const previousInputEvent = globalThis.InputEvent + Object.assign(globalThis, { + document: { + querySelectorAll: vi.fn((selector: string) => selector === "#field" ? [input] : []), + }, + HTMLInputElement: MockInput, + HTMLTextAreaElement: class {}, + InputEvent: class {}, + }) + try { + return run(fields as Array<{ readonly target: string; readonly label: string; readonly value: string }>) + } finally { + Object.assign(globalThis, { + document: previousDocument, + HTMLInputElement: previousInput, + HTMLTextAreaElement: previousTextArea, + InputEvent: previousInputEvent, + }) + } + }) + const page = { evaluate } as unknown as Page + + await fillInputs(page, [{ selector: "#field", value: "next" }]) + + expect(input.value).toBe("next") + expect(input.focus).not.toHaveBeenCalled() + expect(input.blur).not.toHaveBeenCalled() + expect(input.dispatchEvent).toHaveBeenCalledTimes(2) + }) + it("resolves locators before the single batched page evaluation", async () => { const dispose = vi.fn().mockResolvedValue(undefined) const handle = { dispose } as unknown as Awaited> @@ -152,9 +203,32 @@ describe("fillInputs", () => { expect(locator.elementHandles).toHaveBeenCalledOnce() expect(evaluate).toHaveBeenCalledOnce() + expect(evaluate.mock.calls[0]?.[0].toString()).toContain("candidate.shadowRoot") expect(dispose).toHaveBeenCalledOnce() }) + it("explains the open and closed shadow-root boundary without exposing the value", async () => { + const evaluate = vi.fn(async (run: (fields: Array<{ readonly target: string; readonly label: string; readonly value: string }>) => unknown, fields) => { + const previousDocument = globalThis.document + const root = { + querySelectorAll: vi.fn((selector: string) => selector === "*" ? [] : []), + } + Object.assign(globalThis, { document: root }) + try { + return run(fields as Array<{ readonly target: string; readonly label: string; readonly value: string }>) + } finally { + Object.assign(globalThis, { document: previousDocument }) + } + }) + const page = { evaluate } as unknown as Page + + const outcome = fillInputs(page, [{ selector: "secure-field", value: "private-value" }]) + await expect(outcome).rejects.toThrow( + "fillInputs found no match for selector: secure-field in the document or open shadow roots; closed shadow roots are unavailable. Try locator.fill() if Playwright can resolve the field.", + ) + await expect(outcome).rejects.not.toThrow("private-value") + }) + it("rejects an ambiguous locator without serializing it or exposing values", async () => { const handles = [ { dispose: vi.fn().mockResolvedValue(undefined) }, diff --git a/test/extension-connection-lifecycle.test.ts b/test/extension-connection-lifecycle.test.ts new file mode 100644 index 0000000..9cc35b9 --- /dev/null +++ b/test/extension-connection-lifecycle.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { + ensureReconnectAlarm, + reconnectAlarmName, + startSocketKeepAlive, +} from "../extension/src/connection-lifecycle.ts" + +describe("extension connection lifecycle", () => { + afterEach(() => { + vi.useRealTimers() + }) + + it("repairs a missing reconnect alarm whenever the service worker starts", async () => { + const create = vi.fn(async () => {}) + const get = vi.fn(async () => undefined) + + await ensureReconnectAlarm({ create, get }) + + expect(get).toHaveBeenCalledWith(reconnectAlarmName) + expect(create).toHaveBeenCalledWith(reconnectAlarmName, { + periodInMinutes: 0.5, + }) + }) + + it("preserves an existing reconnect alarm", async () => { + const create = vi.fn(async () => {}) + const get = vi.fn(async () => ({ name: reconnectAlarmName, scheduledTime: Date.now() })) + + await ensureReconnectAlarm({ create, get }) + + expect(create).not.toHaveBeenCalled() + }) + + it("runs a heartbeat every 20 seconds and stops cleanly", () => { + vi.useFakeTimers() + const heartbeat = vi.fn() + + const stop = startSocketKeepAlive(heartbeat) + vi.advanceTimersByTime(40_000) + stop() + vi.advanceTimersByTime(20_000) + + expect(heartbeat).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/extension-rpc.test.ts b/test/extension-rpc.test.ts index 9aba1ad..eb6e847 100644 --- a/test/extension-rpc.test.ts +++ b/test/extension-rpc.test.ts @@ -56,7 +56,7 @@ const makeFakeSocket = (): FakeSocket => { const connect = (rpc: ExtensionRpc): FakeSocket => { const socket = makeFakeSocket() rpc.replaceSocket(socket) - rpc.markHandshake("0.0.5", 1) + rpc.markHandshake("0.0.23", 2) rpc.markReady() return socket } @@ -83,11 +83,11 @@ describe("ExtensionRpc", () => { const rpc = new ExtensionRpc() const socket = makeFakeSocket() rpc.replaceSocket(socket) - rpc.markHandshake("1.0.0", 2) + rpc.markHandshake("1.0.0", 3) expect(rpc.connected).toBe(false) await expect(Effect.runPromise(rpc.send({ method: "tabs.create", params: {} }))).rejects.toThrow( - "extension protocol 2 is incompatible", + "extension protocol 3 is incompatible", ) expect(socket.sent).toHaveLength(0) }) diff --git a/test/http-api.test.ts b/test/http-api.test.ts index 30d2fe4..971e1e0 100644 --- a/test/http-api.test.ts +++ b/test/http-api.test.ts @@ -44,7 +44,7 @@ describe("HTTP request schemas", () => { extensionStatus: () => ({ connected: true, version: "9.4.2", - protocolVersion: 1, + protocolVersion: 2, protocolCompatible: true, protocolLegacy: false, }), @@ -68,7 +68,7 @@ describe("HTTP request schemas", () => { expect(extension).toMatchObject({ connected: true, version: "9.4.2", - protocolVersion: 1, + protocolVersion: 2, protocolCompatible: true, protocolLegacy: false, }) diff --git a/test/protocol.test.ts b/test/protocol.test.ts index e06dce8..018ebd0 100644 --- a/test/protocol.test.ts +++ b/test/protocol.test.ts @@ -33,10 +33,10 @@ describe("protocol validation", () => { expect(isExtensionEvent(parseJsonObject('{"method":"unknown"}'))).toBe(false) }) - it("treats legacy hellos as protocol v1 and rejects unknown protocol versions", () => { + it("rejects legacy hellos and unknown protocol versions", () => { expect(extensionProtocolCompatibility(undefined)).toEqual({ - version: extensionProtocolVersion, - compatible: true, + version: 1, + compatible: false, legacy: true, }) expect(extensionProtocolCompatibility(extensionProtocolVersion)).toEqual({ diff --git a/test/recording-protocol.test.ts b/test/recording-protocol.test.ts new file mode 100644 index 0000000..247f961 --- /dev/null +++ b/test/recording-protocol.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest" +import { decodeRecordingFrame, encodeRecordingFrame, maxRecordingFramePayloadBytes } from "../src/recording-protocol.ts" + +describe("recording frame protocol", () => { + it("encodes a byte-exact big-endian header and round trips data", () => { + const encoded = encodeRecordingFrame({ tabId: 7, sequence: 3, final: false, payload: Uint8Array.of(0xaa, 0xbb) }) + expect(Array.from(encoded)).toEqual([ + 0x42, 0x43, 0x52, 0x44, 1, 0, 0, 20, + 0, 0, 0, 7, + 0, 0, 0, 3, + 0, 0, 0, 2, + 0xaa, 0xbb, + ]) + expect(decodeRecordingFrame(encoded)).toEqual({ + tabId: 7, + sequence: 3, + final: false, + payload: Uint8Array.of(0xaa, 0xbb), + }) + }) + + it("round trips a final frame", () => { + expect(decodeRecordingFrame(encodeRecordingFrame({ + tabId: 1, + sequence: 0xffff_ffff, + final: true, + payload: new Uint8Array(), + }))).toMatchObject({ tabId: 1, sequence: 0xffff_ffff, final: true, payload: new Uint8Array() }) + }) + + it.each([ + ["short header", new Uint8Array(19), "shorter than its header"], + ["wrong magic", Uint8Array.of(0, 0, 0, 0, 1, 1, 0, 20, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0), "magic"], + ["unknown version", Uint8Array.of(0x42, 0x43, 0x52, 0x44, 2, 1, 0, 20, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0), "version"], + ["unknown flags", Uint8Array.of(0x42, 0x43, 0x52, 0x44, 1, 2, 0, 20, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0), "flags"], + ["wrong header length", Uint8Array.of(0x42, 0x43, 0x52, 0x44, 1, 1, 0, 19, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0), "header length"], + ["zero tab", Uint8Array.of(0x42, 0x43, 0x52, 0x44, 1, 1, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), "tabId"], + ])("rejects %s", (_label, frame, message) => { + expect(() => decodeRecordingFrame(frame as Uint8Array)).toThrow(message as string) + }) + + it("rejects mismatched and oversized payload lengths", () => { + const mismatched = encodeRecordingFrame({ tabId: 1, sequence: 0, final: false, payload: Uint8Array.of(1) }) + new DataView(mismatched.buffer).setUint32(16, 2) + expect(() => decodeRecordingFrame(mismatched)).toThrow("payload length") + + const oversized = new Uint8Array(20) + oversized.set([0x42, 0x43, 0x52, 0x44, 1, 0, 0, 20], 0) + const view = new DataView(oversized.buffer) + view.setUint32(8, 1) + view.setUint32(16, maxRecordingFramePayloadBytes + 1) + expect(() => decodeRecordingFrame(oversized)).toThrow("exceeds") + }) +}) diff --git a/test/recording-relay.test.ts b/test/recording-relay.test.ts index dc97f11..8f75ed1 100644 --- a/test/recording-relay.test.ts +++ b/test/recording-relay.test.ts @@ -3,6 +3,7 @@ import os from "node:os" import path from "node:path" import { afterEach, describe, expect, it, vi } from "vitest" import { RecordingRelay, type VideoEncoder } from "../src/recording-relay.ts" +import { encodeRecordingFrame } from "../src/recording-protocol.ts" const temporaryPaths: string[] = [] @@ -11,6 +12,309 @@ afterEach(async () => { await Promise.all(temporaryPaths.splice(0).map((temporaryPath) => fs.rm(temporaryPath, { force: true, recursive: true }))) }) +describe("RecordingRelay tab capture", () => { + it("streams intrinsically framed chunks to an atomic output", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const outputPath = path.join(directory, "demo.webm") + let relay: RecordingRelay + relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + if (command.method === "recording.start") { + return { success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" } + } + if (command.method === "recording.stop") { + queueMicrotask(() => relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId: 7, + sequence: 2, + final: true, + payload: new Uint8Array(), + })))) + return { success: true, tabId: 7, duration: 100 } + } + return { success: true } + }, + }) + + await expect(relay.startRecording({ tabId: 7, owner: "user", outputPath })).resolves.toMatchObject({ success: true }) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId: 7, + sequence: 0, + final: false, + payload: new TextEncoder().encode("first"), + }))) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId: 7, + sequence: 1, + final: false, + payload: new TextEncoder().encode("second"), + }))) + + await expect(relay.stopRecording({ tabId: 7 })).resolves.toMatchObject({ + success: true, + size: 11, + mode: "tab-capture", + }) + expect(await fs.readFile(outputPath, "utf8")).toBe("firstsecond") + expect((await fs.readdir(directory)).some((entry) => entry.includes(".partial-"))).toBe(false) + }) + + it("streams output larger than one frame without retaining the complete recording", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const outputPath = path.join(directory, "large.webm") + let relay: RecordingRelay + relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + if (command.method === "recording.start") return { success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" } + if (command.method === "recording.stop") { + queueMicrotask(() => relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId: 7, + sequence: 2, + final: true, + payload: new Uint8Array(), + })))) + return { success: true, tabId: 7, duration: 100 } + } + return { success: true } + }, + }) + await relay.startRecording({ tabId: 7, owner: "user", outputPath }) + const chunk = new Uint8Array(3 * 1024 * 1024).fill(0x5a) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 0, final: false, payload: chunk }))) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 1, final: false, payload: chunk }))) + + await expect(relay.stopRecording({ tabId: 7 })).resolves.toMatchObject({ success: true, size: 6 * 1024 * 1024 }) + const contents = await fs.readFile(outputPath) + expect(contents.byteLength).toBe(6 * 1024 * 1024) + expect(contents[0]).toBe(0x5a) + expect(contents.at(-1)).toBe(0x5a) + }) + + it("keeps interleaved tab frames isolated", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const outputs = new Map([[1, path.join(directory, "one.webm")], [2, path.join(directory, "two.webm")]]) + let relay: RecordingRelay + relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + const tabId = typeof command.params?.tabId === "number" ? command.params.tabId : 0 + if (command.method === "recording.start") return { success: true, tabId, startedAt: 1_000, mimeType: "video/webm" } + if (command.method === "recording.stop") { + queueMicrotask(() => relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId, + sequence: 1, + final: true, + payload: new Uint8Array(), + })))) + return { success: true, tabId, duration: 100 } + } + return { success: true } + }, + }) + + await relay.startRecording({ tabId: 1, owner: "user", outputPath: outputs.get(1) ?? "" }) + await relay.startRecording({ tabId: 2, owner: "user", outputPath: outputs.get(2) ?? "" }) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 2, sequence: 0, final: false, payload: Uint8Array.of(2) }))) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 1, sequence: 0, final: false, payload: Uint8Array.of(1) }))) + + await Promise.all([relay.stopRecording({ tabId: 1 }), relay.stopRecording({ tabId: 2 })]) + expect(Array.from(await fs.readFile(outputs.get(1) ?? ""))).toEqual([1]) + expect(Array.from(await fs.readFile(outputs.get(2) ?? ""))).toEqual([2]) + }) + + it("shares an in-flight stop across concurrent callers", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + let stopCalls = 0 + let relay: RecordingRelay + relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + if (command.method === "recording.start") return { success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" } + if (command.method === "recording.stop") { + stopCalls += 1 + queueMicrotask(() => relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId: 7, + sequence: 1, + final: true, + payload: new Uint8Array(), + })))) + return { success: true, tabId: 7, duration: 100 } + } + return { success: true } + }, + }) + await relay.startRecording({ tabId: 7, owner: "user", outputPath: path.join(directory, "shared.webm") }) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 0, final: false, payload: Uint8Array.of(1) }))) + + const [first, second] = await Promise.all([ + relay.stopRecording({ tabId: 7 }), + relay.stopRecording({ tabId: 7 }), + ]) + expect(first).toEqual(second) + expect(first).toMatchObject({ success: true, size: 1 }) + expect(stopCalls).toBe(1) + }) + + it("does not let cancellation race a stream that is already finalizing", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const outputPath = path.join(directory, "finalizing.webm") + const relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => command.method === "recording.start" + ? { success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" } + : { success: true }, + }) + await relay.startRecording({ tabId: 7, owner: "user", outputPath }) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 0, final: false, payload: Uint8Array.of(1, 2, 3) }))) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 1, final: true, payload: new Uint8Array() }))) + + await expect(relay.startRecording({ tabId: 7, owner: "user", outputPath })).resolves.toEqual({ + success: false, + error: "Recording already in progress for this tab", + }) + await expect(relay.cancelRecording({ tabId: 7 })).resolves.toEqual({ success: true }) + await relay.cleanupAll("test shutdown") + expect(Array.from(await fs.readFile(outputPath))).toEqual([1, 2, 3]) + expect((await fs.readdir(directory)).some((entry) => entry.includes(".partial-"))).toBe(false) + }) + + it("accepts frames that arrive before the extension start response", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const outputPath = path.join(directory, "early.webm") + let resolveStart: ((result: { success: true; tabId: number; startedAt: number; mimeType: string }) => void) | undefined + let relay: RecordingRelay + relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + if (command.method === "recording.start") { + return new Promise((resolve) => { + resolveStart = resolve + }) + } + if (command.method === "recording.stop") { + queueMicrotask(() => relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId: 7, + sequence: 1, + final: true, + payload: new Uint8Array(), + })))) + return { success: true, tabId: 7, duration: 100 } + } + return { success: true } + }, + }) + + const started = relay.startRecording({ tabId: 7, owner: "user", outputPath }) + await vi.waitFor(() => expect(resolveStart).toBeTypeOf("function")) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 0, final: false, payload: Uint8Array.of(1) }))) + resolveStart?.({ success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" }) + + await expect(started).resolves.toMatchObject({ success: true }) + await expect(relay.stopRecording({ tabId: 7 })).resolves.toMatchObject({ success: true, size: 1 }) + expect(Array.from(await fs.readFile(outputPath))).toEqual([1]) + }) + + it("does not let stale cleanup capture a recording started by a new generation", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + let resolveOldStart: ((result: { success: true; tabId: number; startedAt: number; mimeType: string }) => void) | undefined + const relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + const tabId = typeof command.params?.tabId === "number" ? command.params.tabId : 0 + if (command.method === "recording.start" && tabId === 1) { + return new Promise((resolve) => { + resolveOldStart = resolve + }) + } + if (command.method === "recording.start") return { success: true, tabId, startedAt: 2_000, mimeType: "video/webm" } + if (command.method === "recording.status") return { isRecording: true, tabId, startedAt: 2_000 } + return { success: true } + }, + }) + const oldStart = relay.startRecording({ tabId: 1, owner: "user", outputPath: path.join(directory, "old.webm") }) + await vi.waitFor(() => expect(resolveOldStart).toBeTypeOf("function")) + + const staleCleanup = relay.cleanupAll("Extension replaced") + await expect(relay.startRecording({ tabId: 2, owner: "user", outputPath: path.join(directory, "new.webm") })).resolves.toMatchObject({ success: true }) + resolveOldStart?.({ success: true, tabId: 1, startedAt: 1_000, mimeType: "video/webm" }) + await expect(oldStart).resolves.toEqual({ success: false, error: "Recording was cancelled while starting" }) + await staleCleanup + + await expect(relay.statusRecording({ tabId: 2 })).resolves.toMatchObject({ isRecording: true, tabId: 2 }) + await relay.cancelRecording({ tabId: 2 }) + }) + + it("aborts and removes partial output on a sequence violation", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const outputPath = path.join(directory, "bad.webm") + const commands: string[] = [] + const relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + commands.push(command.method) + return command.method === "recording.start" + ? { success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" } + : { success: true } + }, + }) + await relay.startRecording({ tabId: 7, owner: "user", outputPath }) + + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 1, final: false, payload: Uint8Array.of(1) }))) + await vi.waitFor(async () => { + expect((await relay.statusRecording({ tabId: 7 })).isRecording).toBe(false) + }) + + expect(commands).toContain("recording.cancel") + await vi.waitFor(async () => { + expect((await fs.readdir(directory)).some((entry) => entry.includes(".partial-"))).toBe(false) + }) + }) + + it("aborts when pending disk writes exceed the memory bound", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const commands: string[] = [] + const relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + commands.push(command.method) + return command.method === "recording.start" + ? { success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" } + : { success: true } + }, + }) + await relay.startRecording({ tabId: 7, owner: "user", outputPath: path.join(directory, "bounded.webm") }) + const chunk = new Uint8Array(4 * 1024 * 1024) + for (let sequence = 0; sequence < 5; sequence += 1) { + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence, final: false, payload: chunk }))) + } + + await vi.waitFor(async () => { + expect((await relay.statusRecording({ tabId: 7 })).isRecording).toBe(false) + expect((await fs.readdir(directory)).some((entry) => entry.includes(".partial-"))).toBe(false) + }) + expect(commands).toContain("recording.cancel") + }) +}) + describe("RecordingRelay CDP screencast", () => { it("acknowledges compositor frames and timestamps source frames for constant-rate encoding", async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-recording-")) diff --git a/test/relay-child-dedupe.test.ts b/test/relay-child-dedupe.test.ts index e6456da..36a98fe 100644 --- a/test/relay-child-dedupe.test.ts +++ b/test/relay-child-dedupe.test.ts @@ -61,7 +61,7 @@ describe("relay child target announce dedupe", () => { : {} extension.send(JSON.stringify({ id: command.id, result })) }) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.17" } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) extension.send(JSON.stringify({ method: "toolbar.clicked", params: { tabId: 1 } })) await waitFor(() => extensionCommands.some((command) => command.method === "action.setAttached")) @@ -201,7 +201,7 @@ describe("relay child target announce dedupe", () => { : {} extension.send(JSON.stringify({ id: command.id, result })) }) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.17" } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) extension.send(JSON.stringify({ method: "toolbar.clicked", params: { tabId: 1 } })) await waitFor(() => extensionCommands.some((command) => command.method === "action.setAttached")) @@ -264,7 +264,7 @@ describe("relay child target announce dedupe", () => { : {} extension.send(JSON.stringify({ id: command.id, result })) }) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.11" } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) extension.send(JSON.stringify({ method: "toolbar.clicked", params: { tabId: 1 } })) await waitFor(() => extensionCommands.some((command) => command.method === "action.setAttached")) @@ -361,6 +361,18 @@ describe("relay child target announce dedupe", () => { command.params?.sessionId === "worker-session" })) + client.send(JSON.stringify({ + id: 3, + sessionId: "worker-session", + method: "Target.setAutoAttach", + params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }, + })) + await waitFor(() => extensionCommands.some((command) => { + return command.method === "debugger.sendCommand" && + command.params?.method === "Target.setAutoAttach" && + command.params?.sessionId === "worker-session" + })) + client.close() extension.close() }) @@ -382,7 +394,7 @@ describe("relay child target announce dedupe", () => { : {} extension.send(JSON.stringify({ id: command.id, result })) }) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.7" } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) extension.send(JSON.stringify({ method: "toolbar.clicked", params: { tabId: 1 } })) await waitFor(() => extensionCommands.includes("action.setAttached")) @@ -428,6 +440,25 @@ describe("relay child target announce dedupe", () => { ["Target.attachedToTarget", "child-session-2"], ]) + const rootAttach = messages.find((message): message is CdpEvent => { + const info = "method" in message && message.method === "Target.attachedToTarget" ? message.params?.targetInfo : undefined + return info !== undefined && info !== null && typeof info === "object" && !Array.isArray(info) && info.targetId === "root-target" + }) + const rootSessionId = typeof rootAttach?.params?.sessionId === "string" ? rootAttach.params.sessionId : undefined + expect(rootSessionId).toBeDefined() + extension.send(JSON.stringify({ method: "tabs.removed", params: { tabId: 1 } })) + await waitFor(() => messages.some((message) => { + return "method" in message && message.method === "Target.detachedFromTarget" && message.params?.sessionId === rootSessionId + })) + + const detachEvents = messages.filter((message): message is CdpEvent => { + return "method" in message && message.method === "Target.detachedFromTarget" + }) + const childDetachIndex = detachEvents.findIndex((event) => event.params?.sessionId === "child-session-2") + const rootDetachIndex = detachEvents.findIndex((event) => event.params?.sessionId === rootSessionId) + expect(childDetachIndex).toBeGreaterThanOrEqual(0) + expect(rootDetachIndex).toBeGreaterThan(childDetachIndex) + client.close() extension.close() }) diff --git a/test/relay-extension-handshake.test.ts b/test/relay-extension-handshake.test.ts index 4d8bac3..25eeeb7 100644 --- a/test/relay-extension-handshake.test.ts +++ b/test/relay-extension-handshake.test.ts @@ -24,7 +24,7 @@ describe("relay extension handshake", () => { await Effect.runPromise(Effect.scoped(Effect.gen(function* () { const relay = yield* startRelay({ port, sessionCatalogPath: null }) const extension = yield* Effect.promise(() => connectExtension(relay.url)) - extension.send(JSON.stringify({ method: "hello", params: { version: "2.0.0", protocolVersion: 2 } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "2.0.0", protocolVersion: 3 } })) extension.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 7 } })) yield* Effect.sleep("20 millis") @@ -32,7 +32,7 @@ describe("relay extension handshake", () => { expect(status).toMatchObject({ connected: false, version: "2.0.0", - protocolVersion: 2, + protocolVersion: 3, protocolCompatible: false, protocolLegacy: false, activeTargets: 0, @@ -46,25 +46,44 @@ describe("relay extension handshake", () => { await Effect.runPromise(Effect.scoped(Effect.gen(function* () { const relay = yield* startRelay({ port, sessionCatalogPath: null }) const extension = yield* Effect.promise(() => connectExtension(relay.url)) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.19", protocolVersion: 1 } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) const beforeReady = yield* Effect.promise(() => fetch(`${relay.url}/extension/status`).then((response) => response.json())) - expect(beforeReady).toMatchObject({ connected: false, protocolVersion: 1, protocolCompatible: true }) + expect(beforeReady).toMatchObject({ connected: false, protocolVersion: 2, protocolCompatible: true }) extension.send(JSON.stringify({ method: "ready" })) yield* Effect.sleep("10 millis") const ready = yield* Effect.promise(() => fetch(`${relay.url}/extension/status`).then((response) => response.json())) - expect(ready).toMatchObject({ connected: true, protocolVersion: 1, protocolCompatible: true, protocolLegacy: false }) + expect(ready).toMatchObject({ connected: true, protocolVersion: 2, protocolCompatible: true, protocolLegacy: false }) extension.close() }))) }) + it("does not let an incompatible extension replace a compatible socket before ready", async () => { + const port = 24_000 + Math.floor(Math.random() * 10_000) + await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const relay = yield* startRelay({ port, sessionCatalogPath: null }) + const compatible = yield* Effect.promise(() => connectExtension(relay.url)) + compatible.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) + + const incompatible = yield* Effect.promise(() => connectExtension(relay.url)) + const closed = waitForClose(incompatible) + incompatible.send(JSON.stringify({ method: "hello", params: { version: "0.0.22", protocolVersion: 1 } })) + expect(yield* Effect.promise(() => closed)).toBe(4003) + + compatible.send(JSON.stringify({ method: "ready" })) + const status = yield* Effect.promise(() => waitForStatus(relay.url, (candidate) => candidate.connected === true)) + expect(status).toMatchObject({ connected: true, protocolVersion: 2, activeTargets: 0 }) + compatible.close() + }))) + }) + it("replaces the previous socket inventory before accepting a new ready event", async () => { const port = 24_000 + Math.floor(Math.random() * 10_000) await Effect.runPromise(Effect.scoped(Effect.gen(function* () { const relay = yield* startRelay({ port, sessionCatalogPath: null }) const first = yield* Effect.promise(() => connectRespondingExtension(relay.url, "stale-target")) - first.send(JSON.stringify({ method: "hello", params: { version: "0.0.20", protocolVersion: 1 } })) + first.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) first.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 7 } })) first.send(JSON.stringify({ method: "ready" })) yield* Effect.promise(() => waitForStatus(relay.url, (status) => status.connected === true && status.activeTargets === 1)) @@ -73,7 +92,7 @@ describe("relay extension handshake", () => { expect(client.events.some((event) => event.method === "Target.attachedToTarget")).toBe(true) const second = yield* Effect.promise(() => connectExtension(relay.url)) - second.send(JSON.stringify({ method: "hello", params: { version: "0.0.22", protocolVersion: 1 } })) + second.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) second.send(JSON.stringify({ method: "ready" })) const status = yield* Effect.promise(() => waitForStatus(relay.url, (candidate) => candidate.connected === true)) @@ -90,7 +109,7 @@ describe("relay extension handshake", () => { const relay = yield* startRelay({ port, sessionCatalogPath: null }) const extension = yield* Effect.promise(() => connectRespondingExtension(relay.url, undefined, "synthetic reconciliation failure")) const closed = waitForClose(extension) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.22", protocolVersion: 1 } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) extension.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 7 } })) extension.send(JSON.stringify({ method: "ready" })) diff --git a/test/relay-lifecycle.test.ts b/test/relay-lifecycle.test.ts index 40f86a5..5f1b70f 100644 --- a/test/relay-lifecycle.test.ts +++ b/test/relay-lifecycle.test.ts @@ -203,6 +203,7 @@ describe("relay lifecycle", () => { expect(managedRelayEntrypoint("/package/dist/index.js")).toBe("/package/dist/cli.js") expect(managedRelayEntrypoint("/package/src/browser-control-client.ts")).toBe("/package/src/cli.ts") expect(managedRelayEntrypoint("/package/dist/cli.js")).toBe("/package/dist/cli.js") + expect(managedRelayEntrypoint("/package/bin/browser-control-mcp")).toBe("/package/bin/browser-control") }) it("can launch the Node relay independently of a Bun consumer runtime", () => { diff --git a/test/relay-schema.test.ts b/test/relay-schema.test.ts index 161b294..8278ea3 100644 --- a/test/relay-schema.test.ts +++ b/test/relay-schema.test.ts @@ -248,7 +248,7 @@ describe("relay-schema", () => { const full = decodeExtensionStatus({ connected: true, version: "0.0.5", - protocolVersion: 1, + protocolVersion: 2, protocolCompatible: true, protocolLegacy: false, activeTargets: 2, diff --git a/test/relay-session-persistence.test.ts b/test/relay-session-persistence.test.ts index fbf6fd6..0221cff 100644 --- a/test/relay-session-persistence.test.ts +++ b/test/relay-session-persistence.test.ts @@ -260,7 +260,7 @@ async function openProtocolExtension(relayUrl: string, targetId?: string): Promi : {} extension.send(JSON.stringify({ id: command.id, result })) }) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.22", protocolVersion: 1 } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) if (targetId) extension.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 7 } })) extension.send(JSON.stringify({ method: "ready" })) return Object.assign(extension, { commands }) @@ -275,7 +275,7 @@ async function openFakeExtension(relayUrl: string, targetId: string): Promise { yield* Effect.promise(() => sendCdp(sessionClient, { id: 4, method: "Target.setAutoAttach", params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true } })) expect(sessionClient.events.some((event) => event.method === "Target.attachedToTarget" && event.params?.sessionId === "bc-tab-1")).toBe(true) + const alias = yield* Effect.promise(() => sendCdp(sessionClient, { + id: 5, + method: "Target.attachToTarget", + params: { targetId: "target-1", flatten: true }, + })) + const staleAliasId = typeof alias.result?.sessionId === "string" ? alias.result.sessionId : undefined + expect(staleAliasId).toBeDefined() + if (!staleAliasId) throw new Error("Expected target alias") - const ownedCreate = yield* Effect.promise(() => sendCdp(sessionClient, { id: 5, method: "Target.createTarget", params: { url: "about:blank" } })) - expect(ownedCreate.result?.targetId).toBe("target-2") + const ownedCreate = yield* Effect.promise(() => sendCdp(sessionClient, { id: 6, method: "Target.createTarget", params: { url: "about:blank" } })) + expect(ownedCreate.result?.targetId).toBe("target-2") expect(sessionClient.events).toContainEqual({ method: "Target.detachedFromTarget", params: { sessionId: "bc-tab-1", targetId: "target-1" }, }) - expect(sessionClient.events.some((event) => event.method === "Target.attachedToTarget" && event.params?.sessionId === "bc-tab-2")).toBe(true) + expect(sessionClient.events.some((event) => event.method === "Target.attachedToTarget" && event.params?.sessionId === "bc-tab-2")).toBe(true) extension.commands.length = 0 - yield* Effect.promise(() => sendCdp(sessionClient, { id: 6, method: "Target.setAutoAttach", params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true } })) + yield* Effect.promise(async () => { + await expect(sendCdp(sessionClient, { + id: 7, + sessionId: staleAliasId, + method: "Runtime.evaluate", + params: { expression: "1" }, + })).rejects.toThrow(`Unknown CDP session ${staleAliasId} for Runtime.evaluate`) + }) + expect(extension.commands).toEqual([]) + + yield* Effect.promise(async () => { + await expect(sendCdp(rawClient, { + id: 9, + method: "Target.getTargetInfo", + params: { targetId: "target-2" }, + })).rejects.toThrow("Target not found: target-2") + await expect(sendCdp(rawClient, { + id: 10, + sessionId: "bc-tab-2", + method: "Target.getTargetInfo", + params: {}, + })).rejects.toThrow("Target not found: bc-tab-2") + await expect(sendCdp(rawClient, { + id: 11, + sessionId: "bc-tab-2", + method: "Runtime.evaluate", + params: { expression: "1" }, + })).rejects.toThrow("Unknown CDP session bc-tab-2 for Runtime.evaluate") + }) + const hiddenClose = yield* Effect.promise(() => sendCdp(rawClient, { + id: 12, + method: "Target.closeTarget", + params: { targetId: "target-2" }, + })) + expect(hiddenClose.result).toEqual({ success: false }) + expect(extension.commands).toEqual([]) + + yield* Effect.promise(async () => { + await expect(sendCdp(sessionClient, { + id: 13, + sessionId: "bc-tab-1", + method: "Target.setAutoAttach", + params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }, + })).rejects.toThrow("Target not found: bc-tab-1") + }) + expect(extension.commands).toEqual([]) + + yield* Effect.promise(() => sendCdp(sessionClient, { id: 14, method: "Target.setAutoAttach", params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true } })) expect(extension.commands.filter((command) => { return command.method === "debugger.sendCommand" && command.params?.method === "Target.setAutoAttach" }).map((command) => command.params?.tabId)).toEqual([2]) @@ -59,12 +114,66 @@ describe("relay target visibility pruning", () => { } }))) }) + + it("uses the originating client's auto-attach settings for new targets", async () => { + const port = 24_000 + Math.floor(Math.random() * 10_000) + await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const relay = yield* startRelay({ port, sessionCatalogPath: null }) + const extension = yield* Effect.promise(() => connectFakeExtension(relay.url)) + const first = yield* Effect.promise(() => connectCdpClient(relay.url)) + const second = yield* Effect.promise(() => connectCdpClient(relay.url)) + + try { + yield* Effect.promise(() => sendCdp(first, { + id: 1, + method: "Target.setAutoAttach", + params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }, + })) + yield* Effect.promise(() => sendCdp(second, { + id: 2, + method: "Target.setAutoAttach", + params: { autoAttach: true, waitForDebuggerOnStart: true, flatten: false }, + })) + + yield* Effect.promise(() => sendCdp(first, { id: 3, method: "Target.createTarget", params: { url: "about:blank" } })) + yield* Effect.promise(() => sendCdp(second, { id: 4, method: "Target.createTarget", params: { url: "about:blank" } })) + + const setupCommands = extension.commands.filter((command) => { + return command.method === "debugger.sendCommand" && command.params?.method === "Target.setAutoAttach" + }) + expect(setupCommands.find((command) => command.params?.tabId === 1)?.params?.params).toEqual({ + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, + }) + expect(setupCommands.find((command) => command.params?.tabId === 2)?.params?.params).toEqual({ + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: false, + }) + + yield* Effect.promise(async () => { + await expect(sendCdp(first, { + id: 5, + method: "Runtime.evaluate", + params: { expression: "1" }, + })).rejects.toThrow("CDP sessionId is required for Runtime.evaluate") + }) + const targetInfo = yield* Effect.promise(() => sendCdp(first, { id: 6, method: "Target.getTargetInfo", params: {} })) + expect(targetInfo.result).toEqual({}) + } finally { + first.close() + second.close() + extension.close() + } + }))) + }) }) type ExtensionCommand = { readonly id: number readonly method: string - readonly params?: { readonly tabId?: number; readonly method?: string } + readonly params?: { readonly tabId?: number; readonly method?: string; readonly params?: JsonObject } } function connectFakeExtension(relayUrl: string): Promise { @@ -73,7 +182,7 @@ function connectFakeExtension(relayUrl: string): Promise { - socket.send(JSON.stringify({ method: "hello", params: { version: "test" } })) + socket.send(JSON.stringify({ method: "hello", params: { version: "test", protocolVersion: 2 } })) resolve(Object.assign(socket, { commands })) }) socket.on("error", reject) diff --git a/test/target-registry.test.ts b/test/target-registry.test.ts index 221dcd1..7627f44 100644 --- a/test/target-registry.test.ts +++ b/test/target-registry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { resolveTargetInfoTarget, TargetRegistry } from "../src/target-registry.ts" +import { TargetRegistry } from "../src/target-registry.ts" import type { ConnectedTarget } from "../src/relay-types.ts" function root(options: { @@ -56,16 +56,6 @@ describe("TargetRegistry root generations", () => { expect(registry.childTargets.size).toBe(0) }) - it("never falls back to an unrelated root for an explicit stale identity", () => { - const registry = new TargetRegistry() - const visible = root({ sessionId: "bc-tab-1", targetId: "target-1" }) - registry.addRootTarget(visible) - - expect(resolveTargetInfoTarget({ registry, targetId: "missing", fallback: () => visible })).toBeUndefined() - expect(resolveTargetInfoTarget({ registry, sessionId: "missing", fallback: () => visible })).toBeUndefined() - expect(resolveTargetInfoTarget({ registry, fallback: () => visible })).toBe(visible) - }) - it("does not preserve provisional adoption ownership across replacement", () => { const registry = new TargetRegistry() registry.addRootTarget(root({ sessionId: "bc-tab-1", targetId: "target-1" }))