diff --git a/clis/douyin/draft.test.ts b/clis/douyin/draft.test.ts index f4988ec08..48436f1a7 100644 --- a/clis/douyin/draft.test.ts +++ b/clis/douyin/draft.test.ts @@ -95,9 +95,12 @@ function createPageMock( getInterceptedRequests: vi.fn().mockResolvedValue([]), waitForCapture: vi.fn().mockResolvedValue(undefined), screenshot: vi.fn().mockResolvedValue(''), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), setFileInput: vi.fn().mockResolvedValue(undefined), ...overrides, - }; + } as IPage; } describe('douyin draft registration', () => { diff --git a/clis/facebook/search.test.ts b/clis/facebook/search.test.ts index 476606ae8..5b74ec67c 100644 --- a/clis/facebook/search.test.ts +++ b/clis/facebook/search.test.ts @@ -34,13 +34,16 @@ function createMockPage(): IPage { tabs: vi.fn().mockResolvedValue([]), selectTab: vi.fn(), networkRequests: vi.fn().mockResolvedValue([]), - consoleMessages: vi.fn().mockResolvedValue(''), + consoleMessages: vi.fn().mockResolvedValue([]), scroll: vi.fn(), autoScroll: vi.fn(), installInterceptor: vi.fn(), getInterceptedRequests: vi.fn().mockResolvedValue([]), waitForCapture: vi.fn().mockResolvedValue(undefined), screenshot: vi.fn().mockResolvedValue(''), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), }; } diff --git a/clis/hackernews/jobs.yaml b/clis/hackernews/jobs.yaml index 48b488bc7..840d40748 100644 --- a/clis/hackernews/jobs.yaml +++ b/clis/hackernews/jobs.yaml @@ -29,7 +29,15 @@ pipeline: rank: ${{ index + 1 }} title: ${{ item.title }} author: ${{ item.by }} - url: ${{ item.url }} + url: >- + ${{ + item.url + || item.text?.match(/href="([^"]+)"/)?.[1] + ?.replace(///g, '/') + ?.replace(/&/g, '&') + ?.replace(/'/g, "'") + || ('https://news.ycombinator.com/item?id=' + item.id) + }} - limit: ${{ args.limit }} diff --git a/clis/instagram/_shared/private-publish.test.ts b/clis/instagram/_shared/private-publish.test.ts index 77e3db3e9..79e55d09f 100644 --- a/clis/instagram/_shared/private-publish.test.ts +++ b/clis/instagram/_shared/private-publish.test.ts @@ -178,6 +178,33 @@ describe('instagram private publish helpers', () => { expect(evaluateAttempts).toBe(2); }); + it('resolves private publish config when capture metadata is unavailable', async () => { + const page = { + goto: async () => undefined, + wait: async () => undefined, + getCookies: async () => [{ name: 'csrftoken', value: 'csrf-cookie', domain: 'instagram.com' }], + startNetworkCapture: async () => undefined, + readNetworkCapture: async () => [{ url: 'https://www.instagram.com/api/v1/feed/timeline/', method: 'GET' }], + evaluate: async () => ({ + appId: '936619743392459', + csrfToken: 'csrf-from-html', + instagramAjax: '1036523242', + }), + } as any; + + await expect(resolveInstagramPrivatePublishConfig(page)).resolves.toEqual({ + apiContext: { + asbdId: '', + csrfToken: 'csrf-from-html', + igAppId: '936619743392459', + igWwwClaim: '', + instagramAjax: '1036523242', + webSessionId: '', + }, + jazoest: deriveInstagramJazoest('csrf-from-html'), + }); + }); + it('builds the single-image configure form body', () => { expect(buildConfigureBody({ uploadId: '1775134280303', diff --git a/clis/instagram/_shared/private-publish.ts b/clis/instagram/_shared/private-publish.ts index faf68707a..5ba0db3b6 100644 --- a/clis/instagram/_shared/private-publish.ts +++ b/clis/instagram/_shared/private-publish.ts @@ -5,7 +5,7 @@ import * as path from 'node:path'; import { spawnSync } from 'node:child_process'; import { CommandExecutionError } from '@jackwener/opencli/errors'; -import type { BrowserCookie, IPage } from '@jackwener/opencli/types'; +import type { BrowserCookie, CaptureCapablePage, IPage } from '@jackwener/opencli/types'; import type { InstagramProtocolCaptureEntry } from './protocol-capture.js'; import { instagramPrivateApiFetch } from './protocol-capture.js'; import { @@ -174,21 +174,18 @@ export async function resolveInstagramPrivatePublishConfig(page: IPage): Promise apiContext: InstagramPrivateApiContext; jazoest: string; }> { + const capturePage = page as CaptureCapablePage; let lastError: unknown; for (let attempt = 0; attempt < INSTAGRAM_PRIVATE_CONFIG_RETRY_BUDGET; attempt += 1) { try { - if (typeof page.startNetworkCapture === 'function') { - await page.startNetworkCapture(INSTAGRAM_PRIVATE_CAPTURE_PATTERN); - } + await capturePage.startNetworkCapture(INSTAGRAM_PRIVATE_CAPTURE_PATTERN); await page.goto(`${INSTAGRAM_HOME_URL}?__opencli_private_probe=${Date.now()}`); await page.wait({ time: 2 }); const [cookies, runtime, entries] = await Promise.all([ page.getCookies({ domain: 'instagram.com' }), page.evaluate(buildReadInstagramRuntimeInfoJs()) as Promise, - typeof page.readNetworkCapture === 'function' - ? page.readNetworkCapture() as Promise - : Promise.resolve([]), + capturePage.readNetworkCapture() as Promise, ]); const captureEntries = (Array.isArray(entries) ? entries : []) as InstagramProtocolCaptureEntry[]; diff --git a/clis/instagram/_shared/protocol-capture.test.ts b/clis/instagram/_shared/protocol-capture.test.ts index f1363c63f..55346619f 100644 --- a/clis/instagram/_shared/protocol-capture.test.ts +++ b/clis/instagram/_shared/protocol-capture.test.ts @@ -33,7 +33,8 @@ describe('instagram protocol capture helpers', () => { it('prefers native page network capture when available', async () => { const startNetworkCapture = vi.fn().mockResolvedValue(undefined); const evaluate = vi.fn(); - const page = { startNetworkCapture, evaluate } as unknown as IPage; + const hasNativeCaptureSupport = vi.fn().mockReturnValue(true); + const page = { startNetworkCapture, evaluate, hasNativeCaptureSupport } as unknown as IPage; await installInstagramProtocolCapture(page); @@ -41,6 +42,18 @@ describe('instagram protocol capture helpers', () => { expect(evaluate).not.toHaveBeenCalled(); }); + it('falls back to the page patch when native capture is unavailable after start', async () => { + const startNetworkCapture = vi.fn().mockResolvedValue(undefined); + const hasNativeCaptureSupport = vi.fn().mockReturnValue(false); + const evaluate = vi.fn().mockResolvedValue({ ok: true }); + const page = { startNetworkCapture, evaluate, hasNativeCaptureSupport } as unknown as IPage; + + await installInstagramProtocolCapture(page); + + expect(startNetworkCapture).toHaveBeenCalledTimes(1); + expect(evaluate).toHaveBeenCalledTimes(1); + }); + it('reads and normalizes captured protocol entries', async () => { const evaluate = vi.fn().mockResolvedValue({ data: [{ kind: 'fetch', url: 'https://www.instagram.com/api/v1/media/configure/' }], @@ -62,7 +75,8 @@ describe('instagram protocol capture helpers', () => { { kind: 'cdp', url: 'https://www.instagram.com/rupload_igphoto/test', method: 'POST' }, ]); const evaluate = vi.fn(); - const page = { readNetworkCapture, evaluate } as unknown as IPage; + const hasNativeCaptureSupport = vi.fn().mockReturnValue(true); + const page = { readNetworkCapture, evaluate, hasNativeCaptureSupport } as unknown as IPage; const result = await readInstagramProtocolCapture(page); @@ -74,6 +88,27 @@ describe('instagram protocol capture helpers', () => { }); }); + it('falls back to the page patch read when native capture is unavailable after read', async () => { + const readNetworkCapture = vi.fn().mockResolvedValue([ + { url: 'https://www.instagram.com/api/v1/web/resource/', method: 'GET' }, + ]); + const hasNativeCaptureSupport = vi.fn().mockReturnValue(false); + const evaluate = vi.fn().mockResolvedValue({ + data: [{ kind: 'fetch', url: 'https://www.instagram.com/api/v1/media/configure/', method: 'POST' }], + errors: ['fallback-used'], + }); + const page = { readNetworkCapture, evaluate, hasNativeCaptureSupport } as unknown as IPage; + + const result = await readInstagramProtocolCapture(page); + + expect(readNetworkCapture).toHaveBeenCalledTimes(1); + expect(evaluate).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + data: [{ kind: 'fetch', url: 'https://www.instagram.com/api/v1/media/configure/', method: 'POST' }], + errors: ['fallback-used'], + }); + }); + it('dumps protocol traces to /tmp only when capture env is enabled', async () => { process.env.OPENCLI_INSTAGRAM_CAPTURE = '1'; const page = { diff --git a/clis/instagram/_shared/protocol-capture.ts b/clis/instagram/_shared/protocol-capture.ts index d7fba5afb..2d80caa11 100644 --- a/clis/instagram/_shared/protocol-capture.ts +++ b/clis/instagram/_shared/protocol-capture.ts @@ -29,6 +29,14 @@ export interface InstagramProtocolCaptureEntry { timestamp: number; } +type CaptureAwarePage = IPage & { + hasNativeCaptureSupport?: () => boolean | undefined; +}; + +function hasNativeCaptureSupport(page: IPage): boolean | undefined { + return (page as CaptureAwarePage).hasNativeCaptureSupport?.(); +} + export function buildInstallInstagramProtocolCaptureJs( captureVar: string = DEFAULT_CAPTURE_VAR, captureErrorsVar: string = DEFAULT_CAPTURE_ERRORS_VAR, @@ -226,7 +234,9 @@ export async function installInstagramProtocolCapture(page: IPage): Promise = getInterceptedRequests: vi.fn().mockResolvedValue([]), waitForCapture: vi.fn().mockResolvedValue(undefined), screenshot: vi.fn().mockResolvedValue(''), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), setFileInput: vi.fn().mockResolvedValue(undefined), insertText: undefined, getCurrentUrl: vi.fn().mockResolvedValue(null), ...overrides, - }; + } as IPage; } afterAll(() => { @@ -708,7 +711,7 @@ describe('instagram post registration', () => { ]); }); - it('installs and dumps protocol capture when OPENCLI_INSTAGRAM_CAPTURE is enabled', async () => { + it('uses native protocol capture and dumps traces when OPENCLI_INSTAGRAM_CAPTURE is enabled', async () => { process.env.OPENCLI_INSTAGRAM_CAPTURE = '1'; const imagePath = createTempImage('capture-enabled.jpg'); const evaluate = vi.fn(async (js: string) => { @@ -735,8 +738,10 @@ describe('instagram post registration', () => { }); const evaluateCalls = evaluate.mock.calls.map((args) => String(args[0])); - expect(evaluateCalls.some((js) => js.includes('__opencli_ig_protocol_capture') && js.includes('PATCH_GUARD'))).toBe(true); - expect(evaluateCalls.some((js) => js.includes('const data = Array.isArray(window[') && js.includes('__opencli_ig_protocol_capture'))).toBe(true); + expect((page.startNetworkCapture as ReturnType)).toHaveBeenCalled(); + expect((page.readNetworkCapture as ReturnType)).toHaveBeenCalled(); + expect(evaluateCalls.some((js) => js.includes('__opencli_ig_protocol_capture') && js.includes('PATCH_GUARD'))).toBe(false); + expect(evaluateCalls.some((js) => js.includes('const data = Array.isArray(window[') && js.includes('__opencli_ig_protocol_capture'))).toBe(false); expect(result).toEqual([ { status: '✅ Posted', diff --git a/clis/instagram/reel.test.ts b/clis/instagram/reel.test.ts index 73b711352..ee87b5c6d 100644 --- a/clis/instagram/reel.test.ts +++ b/clis/instagram/reel.test.ts @@ -51,8 +51,11 @@ function createPageMock(evaluateResults: unknown[], overrides: Partial = setFileInput: vi.fn().mockResolvedValue(undefined), insertText: vi.fn().mockResolvedValue(undefined), getCurrentUrl: vi.fn().mockResolvedValue(null), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), ...overrides, - }; + } as IPage; } afterAll(() => { diff --git a/clis/instagram/reel.ts b/clis/instagram/reel.ts index 59370a962..f72c217f9 100644 --- a/clis/instagram/reel.ts +++ b/clis/instagram/reel.ts @@ -5,7 +5,7 @@ import * as path from 'node:path'; import { Page as BrowserPage } from '@jackwener/opencli/browser/page'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; -import type { BrowserCookie, IPage } from '@jackwener/opencli/types'; +import type { BrowserCookie, CaptureCapablePage, IPage } from '@jackwener/opencli/types'; import { buildClickActionJs, buildEnsureComposerOpenJs, @@ -809,9 +809,7 @@ cli({ activePage: IPage, existingMediaPaths: ReadonlySet = new Set(), ): Promise => { - if (typeof activePage.startNetworkCapture === 'function') { - await activePage.startNetworkCapture('/rupload_igvideo/|/api/v1/|/reel/|/clips/|/media/|/configure|/upload'); - } + await (activePage as CaptureCapablePage).startNetworkCapture('/rupload_igvideo/|/api/v1/|/reel/|/clips/|/media/|/configure|/upload'); await gotoInstagramHome(activePage, true); await activePage.wait({ time: 2 }); await dismissResidualDialogs(activePage); diff --git a/clis/instagram/story.test.ts b/clis/instagram/story.test.ts index 645845648..8fc57932f 100644 --- a/clis/instagram/story.test.ts +++ b/clis/instagram/story.test.ts @@ -51,8 +51,11 @@ function createPageMock(evaluateResults: unknown[] = [], overrides: Partial { diff --git a/clis/substack/utils.test.ts b/clis/substack/utils.test.ts index 1b9c92776..7ec7a72c9 100644 --- a/clis/substack/utils.test.ts +++ b/clis/substack/utils.test.ts @@ -24,6 +24,9 @@ function createPageMock(evaluateResult: unknown): IPage { getCookies: vi.fn().mockResolvedValue([]), screenshot: vi.fn().mockResolvedValue(''), waitForCapture: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), }; } diff --git a/clis/twitter/reply.test.ts b/clis/twitter/reply.test.ts index 1fa402b42..73f9e9acc 100644 --- a/clis/twitter/reply.test.ts +++ b/clis/twitter/reply.test.ts @@ -35,8 +35,11 @@ function createPageMock(evaluateResults: any[], overrides: Partial = {}): getCookies: vi.fn().mockResolvedValue([]), screenshot: vi.fn().mockResolvedValue(''), waitForCapture: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), ...overrides, - }; + } as IPage; } describe('twitter reply command', () => { diff --git a/clis/xianyu/item.test.ts b/clis/xianyu/item.test.ts index fd88c2044..ec761ce5e 100644 --- a/clis/xianyu/item.test.ts +++ b/clis/xianyu/item.test.ts @@ -27,6 +27,9 @@ function createPageMock(evaluateResult: unknown): IPage { getCookies: vi.fn().mockResolvedValue([]), screenshot: vi.fn().mockResolvedValue(''), waitForCapture: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), } as IPage; } diff --git a/clis/xiaohongshu/comments.test.ts b/clis/xiaohongshu/comments.test.ts index af8a7d35e..684813b69 100644 --- a/clis/xiaohongshu/comments.test.ts +++ b/clis/xiaohongshu/comments.test.ts @@ -25,6 +25,9 @@ function createPageMock(evaluateResult: any): IPage { getCookies: vi.fn().mockResolvedValue([]), screenshot: vi.fn().mockResolvedValue(''), waitForCapture: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), }; } diff --git a/clis/xiaohongshu/creator-note-detail.test.ts b/clis/xiaohongshu/creator-note-detail.test.ts index c2e767b6d..fee5a3194 100644 --- a/clis/xiaohongshu/creator-note-detail.test.ts +++ b/clis/xiaohongshu/creator-note-detail.test.ts @@ -32,6 +32,9 @@ function createPageMock(evaluateResult: any): IPage { getCookies: vi.fn().mockResolvedValue([]), screenshot: vi.fn().mockResolvedValue(''), waitForCapture: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), }; } diff --git a/clis/xiaohongshu/creator-notes.test.ts b/clis/xiaohongshu/creator-notes.test.ts index edb9f93f0..6bef814e4 100644 --- a/clis/xiaohongshu/creator-notes.test.ts +++ b/clis/xiaohongshu/creator-notes.test.ts @@ -36,6 +36,9 @@ function createPageMock(evaluateResult: any, interceptedRequests: any[] = []): I getCookies: vi.fn().mockResolvedValue([]), screenshot: vi.fn().mockResolvedValue(''), waitForCapture: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), }; } diff --git a/clis/xiaohongshu/download.test.ts b/clis/xiaohongshu/download.test.ts index c67ceee44..45921d333 100644 --- a/clis/xiaohongshu/download.test.ts +++ b/clis/xiaohongshu/download.test.ts @@ -39,6 +39,9 @@ function createPageMock(evaluateResult: any): IPage { getCookies: vi.fn().mockResolvedValue([{ name: 'sid', value: 'secret', domain: '.xiaohongshu.com' }]), screenshot: vi.fn().mockResolvedValue(''), waitForCapture: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), }; } diff --git a/clis/xiaohongshu/note.test.ts b/clis/xiaohongshu/note.test.ts index cfa07abe2..1f1bdaffa 100644 --- a/clis/xiaohongshu/note.test.ts +++ b/clis/xiaohongshu/note.test.ts @@ -26,6 +26,9 @@ function createPageMock(evaluateResult: any): IPage { getCookies: vi.fn().mockResolvedValue([]), screenshot: vi.fn().mockResolvedValue(''), waitForCapture: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), }; } diff --git a/clis/xiaohongshu/publish.test.ts b/clis/xiaohongshu/publish.test.ts index 1d8f26239..9cd411924 100644 --- a/clis/xiaohongshu/publish.test.ts +++ b/clis/xiaohongshu/publish.test.ts @@ -35,8 +35,11 @@ function createPageMock(evaluateResults: any[], overrides: Partial = {}): getCookies: vi.fn().mockResolvedValue([]), screenshot: vi.fn().mockResolvedValue(''), waitForCapture: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), ...overrides, - }; + } as IPage; } describe('xiaohongshu publish', () => { diff --git a/clis/xiaohongshu/search.test.ts b/clis/xiaohongshu/search.test.ts index 8de8287dc..8b1cb0645 100644 --- a/clis/xiaohongshu/search.test.ts +++ b/clis/xiaohongshu/search.test.ts @@ -30,6 +30,9 @@ function createPageMock(evaluateResults: any[]): IPage { getCookies: vi.fn().mockResolvedValue([]), screenshot: vi.fn().mockResolvedValue(''), waitForCapture: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), }; } diff --git a/extension/dist/background.js b/extension/dist/background.js index bd1caa221..328cf781f 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,981 +1,1306 @@ -const DAEMON_PORT = 19825; -const DAEMON_HOST = "localhost"; -const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; -const WS_RECONNECT_BASE_DELAY = 2e3; -const WS_RECONNECT_MAX_DELAY = 5e3; - -const attached = /* @__PURE__ */ new Set(); -const networkCaptures = /* @__PURE__ */ new Map(); +//#region src/protocol.ts +/** Default daemon port */ +var DAEMON_PORT = 19825; +var DAEMON_HOST = "localhost"; +var DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +/** Lightweight health-check endpoint — probed before each WebSocket attempt. */ +var DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; +/** Base reconnect delay for extension WebSocket (ms) */ +var WS_RECONNECT_BASE_DELAY = 2e3; +/** Max reconnect delay (ms) — kept short since daemon is long-lived */ +var WS_RECONNECT_MAX_DELAY = 5e3; +//#endregion +//#region src/cdp.ts +/** +* CDP execution via chrome.debugger API. +* +* chrome.debugger only needs the "debugger" permission — no host_permissions. +* It can attach to any http/https tab. Avoid chrome:// and chrome-extension:// +* tabs (resolveTabId in background.ts filters them). +*/ +var attached = /* @__PURE__ */ new Set(); +var networkCaptures = /* @__PURE__ */ new Map(); +var captureIntents = /* @__PURE__ */ new Map(); +/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ function isDebuggableUrl$1(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); +} +async function cleanupForeignExtensionSurface(tabId) { + try { + await chrome.scripting.executeScript({ + target: { tabId }, + world: "MAIN", + func: () => { + document.querySelectorAll("iframe[src^=\"chrome-extension://\"], [src^=\"chrome-extension://\"]").forEach((node) => node.remove()); + } + }); + } catch {} } async function ensureAttached(tabId, aggressiveRetry = false) { - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - attached.delete(tabId); - throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); - } - } catch (e) { - if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; - attached.delete(tabId); - throw new Error(`Tab ${tabId} no longer exists`); - } - if (attached.has(tabId)) { - try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression: "1", - returnByValue: true - }); - return; - } catch { - attached.delete(tabId); - } - } - const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; - const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; - let lastError = ""; - for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) { - try { - try { - await chrome.debugger.detach({ tabId }); - } catch { - } - await chrome.debugger.attach({ tabId }, "1.3"); - lastError = ""; - break; - } catch (e) { - lastError = e instanceof Error ? e.message : String(e); - if (attempt < MAX_ATTACH_RETRIES) { - console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - lastError = `Tab URL changed to ${tab.url} during retry`; - break; - } - } catch { - lastError = `Tab ${tabId} no longer exists`; - break; - } - } - } - } - if (lastError) { - let finalUrl = "unknown"; - let finalWindowId = "unknown"; - try { - const tab = await chrome.tabs.get(tabId); - finalUrl = tab.url ?? "undefined"; - finalWindowId = String(tab.windowId); - } catch { - } - console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); - const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; - throw new Error(`attach failed: ${lastError}${hint}`); - } - attached.add(tabId); - try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); - } catch { - } + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + attached.delete(tabId); + throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; + attached.delete(tabId); + throw new Error(`Tab ${tabId} no longer exists`); + } + if (attached.has(tabId)) try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression: "1", + returnByValue: true + }); + return; + } catch { + attached.delete(tabId); + } + const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; + const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; + let lastError = ""; + for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) try { + try { + await chrome.debugger.detach({ tabId }); + } catch {} + await chrome.debugger.attach({ tabId }, "1.3"); + lastError = ""; + break; + } catch (e) { + lastError = e instanceof Error ? e.message : String(e); + if (attempt < MAX_ATTACH_RETRIES) { + if (lastError.includes("chrome-extension://")) await cleanupForeignExtensionSurface(tabId); + console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + lastError = `Tab URL changed to ${tab.url} during retry`; + break; + } + } catch { + lastError = `Tab ${tabId} no longer exists`; + break; + } + } + } + if (lastError) { + let finalUrl = "unknown"; + let finalWindowId = "unknown"; + try { + const tab = await chrome.tabs.get(tabId); + finalUrl = tab.url ?? "undefined"; + finalWindowId = String(tab.windowId); + } catch {} + console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); + const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; + throw new Error(`attach failed: ${lastError}${hint}`); + } + attached.add(tabId); + const state = networkCaptures.get(tabId); + if (captureIntents.has(tabId) && !state?.armed) { + try { + await armCapture(tabId); + } catch (error) { + console.warn(`[opencli] failed to rearm capture for tab ${tabId}: ${error}`); + } + return; + } + try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); + } catch {} } async function evaluate(tabId, expression, aggressiveRetry = false) { - const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; - for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) { - try { - await ensureAttached(tabId, aggressiveRetry); - const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true - }); - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result.result?.value; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed"); - const isAttachError = isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://"); - if (isAttachError && attempt < MAX_EVAL_RETRIES) { - attached.delete(tabId); - const retryMs = isNavigateError ? 200 : 500; - await new Promise((resolve) => setTimeout(resolve, retryMs)); - continue; - } - throw e; - } - } - throw new Error("evaluate: max retries exhausted"); -} -const evaluateAsync = evaluate; + const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; + for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) try { + await ensureAttached(tabId, aggressiveRetry); + const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result.result?.value; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed"); + if ((isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://")) && attempt < MAX_EVAL_RETRIES) { + attached.delete(tabId); + const retryMs = isNavigateError ? 200 : 500; + await new Promise((resolve) => setTimeout(resolve, retryMs)); + continue; + } + throw e; + } + throw new Error("evaluate: max retries exhausted"); +} +var evaluateAsync = evaluate; +/** +* Capture a screenshot via CDP Page.captureScreenshot. +* Returns base64-encoded image data. +*/ async function screenshot(tabId, options = {}) { - await ensureAttached(tabId); - const format = options.format ?? "png"; - if (options.fullPage) { - const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); - const size = metrics.cssContentSize || metrics.contentSize; - if (size) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { - mobile: false, - width: Math.ceil(size.width), - height: Math.ceil(size.height), - deviceScaleFactor: 1 - }); - } - } - try { - const params = { format }; - if (format === "jpeg" && options.quality !== void 0) { - params.quality = Math.max(0, Math.min(100, options.quality)); - } - const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params); - return result.data; - } finally { - if (options.fullPage) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { - }); - } - } + await ensureAttached(tabId); + const format = options.format ?? "png"; + if (options.fullPage) { + const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); + const size = metrics.cssContentSize || metrics.contentSize; + if (size) await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + mobile: false, + width: Math.ceil(size.width), + height: Math.ceil(size.height), + deviceScaleFactor: 1 + }); + } + try { + const params = { format }; + if (format === "jpeg" && options.quality !== void 0) params.quality = Math.max(0, Math.min(100, options.quality)); + return (await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params)).data; + } finally { + if (options.fullPage) await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {}); + } } +/** +* Set local file paths on a file input element via CDP DOM.setFileInputFiles. +* This bypasses the need to send large base64 payloads through the message channel — +* Chrome reads the files directly from the local filesystem. +* +* @param tabId - Target tab ID +* @param files - Array of absolute local file paths +* @param selector - CSS selector to find the file input (optional, defaults to first file input) +*/ async function setFileInputFiles(tabId, files, selector) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); - const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); - const query = selector || 'input[type="file"]'; - const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { - nodeId: doc.root.nodeId, - selector: query - }); - if (!result.nodeId) { - throw new Error(`No element found matching selector: ${query}`); - } - await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { - files, - nodeId: result.nodeId - }); + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); + const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); + const query = selector || "input[type=\"file\"]"; + const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { + nodeId: doc.root.nodeId, + selector: query + }); + if (!result.nodeId) throw new Error(`No element found matching selector: ${query}`); + await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { + files, + nodeId: result.nodeId + }); } async function insertText(tabId, text) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text }); + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text }); } function normalizeCapturePatterns(pattern) { - return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean); + return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean); } function shouldCaptureUrl(url, patterns) { - if (!url) return false; - if (!patterns.length) return true; - return patterns.some((pattern) => url.includes(pattern)); + if (!url) return false; + if (!patterns.length) return true; + return patterns.some((pattern) => url.includes(pattern)); } function normalizeHeaders(headers) { - if (!headers || typeof headers !== "object") return {}; - const out = {}; - for (const [key, value] of Object.entries(headers)) { - out[String(key)] = String(value); - } - return out; + if (!headers || typeof headers !== "object") return {}; + const out = {}; + for (const [key, value] of Object.entries(headers)) out[String(key)] = String(value); + return out; +} +function resetNetworkBuffers(state) { + state.entries = []; + state.requestToIndex.clear(); +} +function resetConsoleBuffers(state) { + state.consoleErrors = []; + state.consoleOther = []; +} +function normalizeConsoleLevel(type) { + switch (type) { + case "warning": return "warn"; + case "verbose": return "debug"; + case "log": + case "warn": + case "error": + case "info": + case "debug": return type; + default: return null; + } +} +function pushConsoleMessage(state, message) { + const bucket = message.level === "error" || message.level === "warn" ? state.consoleErrors : state.consoleOther; + bucket.push(message); + const limit = bucket === state.consoleErrors ? 200 : 300; + if (bucket.length > limit) bucket.shift(); +} +async function armCapture(tabId) { + const patterns = captureIntents.get(tabId)?.patterns ?? networkCaptures.get(tabId)?.patterns ?? []; + await chrome.debugger.sendCommand({ tabId }, "Network.enable"); + await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); + const existing = networkCaptures.get(tabId); + networkCaptures.set(tabId, { + patterns, + entries: existing?.entries ?? [], + requestToIndex: existing?.requestToIndex ?? /* @__PURE__ */ new Map(), + consoleErrors: existing?.consoleErrors ?? [], + consoleOther: existing?.consoleOther ?? [], + armed: true + }); } function getOrCreateNetworkCaptureEntry(tabId, requestId, fallback) { - const state = networkCaptures.get(tabId); - if (!state) return null; - const existingIndex = state.requestToIndex.get(requestId); - if (existingIndex !== void 0) { - return state.entries[existingIndex] || null; - } - const url = fallback?.url || ""; - if (!shouldCaptureUrl(url, state.patterns)) return null; - const entry = { - kind: "cdp", - url, - method: fallback?.method || "GET", - requestHeaders: fallback?.requestHeaders || {}, - timestamp: Date.now() - }; - state.entries.push(entry); - state.requestToIndex.set(requestId, state.entries.length - 1); - return entry; + const state = networkCaptures.get(tabId); + if (!state) return null; + const existingIndex = state.requestToIndex.get(requestId); + if (existingIndex !== void 0) return state.entries[existingIndex] || null; + const url = fallback?.url || ""; + if (!shouldCaptureUrl(url, state.patterns)) return null; + const entry = { + kind: "cdp", + url, + method: fallback?.method || "GET", + requestHeaders: fallback?.requestHeaders || {}, + timestamp: Date.now() + }; + state.entries.push(entry); + state.requestToIndex.set(requestId, state.entries.length - 1); + return entry; } async function startNetworkCapture(tabId, pattern) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "Network.enable"); - networkCaptures.set(tabId, { - patterns: normalizeCapturePatterns(pattern), - entries: [], - requestToIndex: /* @__PURE__ */ new Map() - }); + const patterns = normalizeCapturePatterns(pattern); + captureIntents.set(tabId, { patterns }); + const existing = networkCaptures.get(tabId); + if (existing) { + existing.patterns = patterns; + resetNetworkBuffers(existing); + } + await ensureAttached(tabId); + if (networkCaptures.get(tabId)?.armed) return; + await armCapture(tabId); + resetNetworkBuffers(networkCaptures.get(tabId)); +} +async function ensureCaptureReadable(tabId) { + const state = networkCaptures.get(tabId); + if (!captureIntents.has(tabId) || state?.armed) return state; + await ensureAttached(tabId); + const refreshed = networkCaptures.get(tabId); + if (captureIntents.has(tabId) && !refreshed?.armed) throw new Error(`capture rearm failed for tab ${tabId}`); + return refreshed; } async function readNetworkCapture(tabId) { - const state = networkCaptures.get(tabId); - if (!state) return []; - const entries = state.entries.slice(); - state.entries = []; - state.requestToIndex.clear(); - return entries; + const state = await ensureCaptureReadable(tabId); + if (!state) return []; + const entries = state.entries.slice(); + state.entries = []; + state.requestToIndex.clear(); + return entries; +} +async function readConsoleCapture(tabId) { + const state = await ensureCaptureReadable(tabId); + if (!state) return []; + return [...state.consoleErrors, ...state.consoleOther].sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)); +} +async function stopCapture(tabId) { + captureIntents.delete(tabId); + networkCaptures.delete(tabId); } async function detach(tabId) { - if (!attached.has(tabId)) return; - attached.delete(tabId); - networkCaptures.delete(tabId); - try { - await chrome.debugger.detach({ tabId }); - } catch { - } + const state = networkCaptures.get(tabId); + const preserveCapture = captureIntents.has(tabId); + if (state) { + state.armed = false; + state.requestToIndex.clear(); + if (!preserveCapture) { + resetNetworkBuffers(state); + resetConsoleBuffers(state); + networkCaptures.delete(tabId); + } + } + if (!attached.has(tabId)) return; + attached.delete(tabId); + try { + await chrome.debugger.detach({ tabId }); + } catch {} +} +function hasCaptureIntent(tabId) { + return captureIntents.has(tabId); } function registerListeners() { - chrome.tabs.onRemoved.addListener((tabId) => { - attached.delete(tabId); - networkCaptures.delete(tabId); - }); - chrome.debugger.onDetach.addListener((source) => { - if (source.tabId) { - attached.delete(source.tabId); - networkCaptures.delete(source.tabId); - } - }); - chrome.tabs.onUpdated.addListener(async (tabId, info) => { - if (info.url && !isDebuggableUrl$1(info.url)) { - await detach(tabId); - } - }); - chrome.debugger.onEvent.addListener(async (source, method, params) => { - const tabId = source.tabId; - if (!tabId) return; - const state = networkCaptures.get(tabId); - if (!state) return; - if (method === "Network.requestWillBeSent") { - const requestId = String(params?.requestId || ""); - const request = params?.request; - const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { - url: request?.url, - method: request?.method, - requestHeaders: normalizeHeaders(request?.headers) - }); - if (!entry) return; - entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; - entry.requestBodyPreview = String(request?.postData || "").slice(0, 4e3); - try { - const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId }); - if (postData?.postData) { - entry.requestBodyKind = "string"; - entry.requestBodyPreview = postData.postData.slice(0, 4e3); - } - } catch { - } - return; - } - if (method === "Network.responseReceived") { - const requestId = String(params?.requestId || ""); - const response = params?.response; - const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { - url: response?.url - }); - if (!entry) return; - entry.responseStatus = response?.status; - entry.responseContentType = response?.mimeType || ""; - entry.responseHeaders = normalizeHeaders(response?.headers); - return; - } - if (method === "Network.loadingFinished") { - const requestId = String(params?.requestId || ""); - const stateEntryIndex = state.requestToIndex.get(requestId); - if (stateEntryIndex === void 0) return; - const entry = state.entries[stateEntryIndex]; - if (!entry) return; - try { - const body = await chrome.debugger.sendCommand({ tabId }, "Network.getResponseBody", { requestId }); - if (typeof body?.body === "string") { - entry.responsePreview = body.base64Encoded ? `base64:${body.body.slice(0, 4e3)}` : body.body.slice(0, 4e3); - } - } catch { - } - } - }); -} - -let ws = null; -let reconnectTimer = null; -let reconnectAttempts = 0; -const _origLog = console.log.bind(console); -const _origWarn = console.warn.bind(console); -const _origError = console.error.bind(console); + chrome.tabs.onRemoved.addListener((tabId) => { + attached.delete(tabId); + captureIntents.delete(tabId); + networkCaptures.delete(tabId); + }); + chrome.debugger.onDetach.addListener((source) => { + if (source.tabId) { + attached.delete(source.tabId); + const state = networkCaptures.get(source.tabId); + if (!state) return; + state.armed = false; + state.requestToIndex.clear(); + if (!captureIntents.has(source.tabId)) { + resetNetworkBuffers(state); + resetConsoleBuffers(state); + networkCaptures.delete(source.tabId); + } + } + }); + chrome.tabs.onUpdated.addListener(async (tabId, info) => { + if (info.url && !isDebuggableUrl$1(info.url)) await detach(tabId); + }); + chrome.debugger.onEvent.addListener(async (source, method, params) => { + const tabId = source.tabId; + if (!tabId) return; + const state = networkCaptures.get(tabId); + if (!state?.armed) return; + if (method === "Network.requestWillBeSent") { + const requestId = String(params?.requestId || ""); + const request = params?.request; + const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { + url: request?.url, + method: request?.method, + requestHeaders: normalizeHeaders(request?.headers) + }); + if (!entry) return; + entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; + entry.requestBodyPreview = String(request?.postData || "").slice(0, 4e3); + try { + const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId }); + if (postData?.postData) { + entry.requestBodyKind = "string"; + entry.requestBodyPreview = postData.postData.slice(0, 4e3); + } + } catch {} + return; + } + if (method === "Network.responseReceived") { + const requestId = String(params?.requestId || ""); + const response = params?.response; + const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { url: response?.url }); + if (!entry) return; + entry.responseStatus = response?.status; + entry.responseContentType = response?.mimeType || ""; + entry.responseHeaders = normalizeHeaders(response?.headers); + return; + } + if (method === "Network.loadingFinished") { + const requestId = String(params?.requestId || ""); + const stateEntryIndex = state.requestToIndex.get(requestId); + if (stateEntryIndex === void 0) return; + const entry = state.entries[stateEntryIndex]; + if (!entry) return; + try { + const body = await chrome.debugger.sendCommand({ tabId }, "Network.getResponseBody", { requestId }); + if (typeof body?.body === "string") entry.responsePreview = body.base64Encoded ? `base64:${body.body.slice(0, 4e3)}` : body.body.slice(0, 4e3); + } catch {} + return; + } + if (method === "Runtime.consoleAPICalled") { + const consoleParams = params; + const level = normalizeConsoleLevel(String(consoleParams.type || "")); + if (!level) return; + pushConsoleMessage(state, { + level, + text: (consoleParams.args || []).map((arg) => arg.value !== void 0 ? String(arg.value) : String(arg.description || "")).join(" "), + timestamp: consoleParams.timestamp, + source: "console-api" + }); + return; + } + if (method === "Runtime.exceptionThrown") { + const exceptionParams = params; + pushConsoleMessage(state, { + level: "error", + text: exceptionParams.exceptionDetails?.exception?.description || exceptionParams.exceptionDetails?.text || "Unknown exception", + timestamp: exceptionParams.timestamp, + source: "exception" + }); + } + }); +} +//#endregion +//#region src/background.ts +var ws = null; +var reconnectTimer = null; +var reconnectAttempts = 0; +var _origLog = console.log.bind(console); +var _origWarn = console.warn.bind(console); +var _origError = console.error.bind(console); function forwardLog(level, args) { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - try { - const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); - ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() })); - } catch { - } + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { + const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); + ws.send(JSON.stringify({ + type: "log", + level, + msg, + ts: Date.now() + })); + } catch {} } console.log = (...args) => { - _origLog(...args); - forwardLog("info", args); + _origLog(...args); + forwardLog("info", args); }; console.warn = (...args) => { - _origWarn(...args); - forwardLog("warn", args); + _origWarn(...args); + forwardLog("warn", args); }; console.error = (...args) => { - _origError(...args); - forwardLog("error", args); + _origError(...args); + forwardLog("error", args); }; +/** +* Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket +* connection. fetch() failures are silently catchable; new WebSocket() is not +* — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any +* JS handler can intercept it. By keeping the probe inside connect() every +* call site remains unchanged and the guard can never be accidentally skipped. +*/ async function connect() { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; - try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); - if (!res.ok) return; - } catch { - return; - } - try { - ws = new WebSocket(DAEMON_WS_URL); - } catch { - scheduleReconnect(); - return; - } - ws.onopen = () => { - console.log("[opencli] Connected to daemon"); - reconnectAttempts = 0; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); - }; - ws.onmessage = async (event) => { - try { - const command = JSON.parse(event.data); - const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); - } catch (err) { - console.error("[opencli] Message handling error:", err); - } - }; - ws.onclose = () => { - console.log("[opencli] Disconnected from daemon"); - ws = null; - scheduleReconnect(); - }; - ws.onerror = () => { - ws?.close(); - }; -} -const MAX_EAGER_ATTEMPTS = 6; + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + try { + if (!(await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) })).ok) return; + } catch { + return; + } + try { + ws = new WebSocket(DAEMON_WS_URL); + } catch { + scheduleReconnect(); + return; + } + ws.onopen = () => { + console.log("[opencli] Connected to daemon"); + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + ws?.send(JSON.stringify({ + type: "hello", + version: chrome.runtime.getManifest().version + })); + }; + ws.onmessage = async (event) => { + try { + const result = await handleCommand(JSON.parse(event.data)); + ws?.send(JSON.stringify(result)); + } catch (err) { + console.error("[opencli] Message handling error:", err); + } + }; + ws.onclose = () => { + console.log("[opencli] Disconnected from daemon"); + ws = null; + scheduleReconnect(); + }; + ws.onerror = () => { + ws?.close(); + }; +} +/** +* After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects. +* The keepalive alarm (~24s) will still call connect() periodically, but at a +* much lower frequency — reducing console noise when the daemon is not running. +*/ +var MAX_EAGER_ATTEMPTS = 6; function scheduleReconnect() { - if (reconnectTimer) return; - reconnectAttempts++; - if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; - const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - void connect(); - }, delay); -} -const automationSessions = /* @__PURE__ */ new Map(); -const WINDOW_IDLE_TIMEOUT = 3e4; + if (reconnectTimer) return; + reconnectAttempts++; + if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; + const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); +} +var automationSessions = /* @__PURE__ */ new Map(); +var WINDOW_IDLE_TIMEOUT = 3e4; function getWorkspaceKey(workspace) { - return workspace?.trim() || "default"; + return workspace?.trim() || "default"; } function resetWindowIdleTimer(workspace) { - const session = automationSessions.get(workspace); - if (!session) return; - if (session.idleTimer) clearTimeout(session.idleTimer); - session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT; - session.idleTimer = setTimeout(async () => { - const current = automationSessions.get(workspace); - if (!current) return; - if (!current.owned) { - console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`); - automationSessions.delete(workspace); - return; - } - try { - await chrome.windows.remove(current.windowId); - console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); - } catch { - } - automationSessions.delete(workspace); - }, WINDOW_IDLE_TIMEOUT); + const session = automationSessions.get(workspace); + if (!session) return; + if (session.idleTimer) clearTimeout(session.idleTimer); + session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT; + session.idleTimer = setTimeout(async () => { + const current = automationSessions.get(workspace); + if (!current) return; + if (!current.owned) { + console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`); + automationSessions.delete(workspace); + return; + } + try { + await chrome.windows.remove(current.windowId); + console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); + } catch {} + automationSessions.delete(workspace); + }, WINDOW_IDLE_TIMEOUT); } +/** Get or create the dedicated automation window. +* @param initialUrl — if provided (http/https), used as the initial page instead of about:blank. +* This avoids an extra blank-page→target-domain navigation on first command. +*/ async function getAutomationWindow(workspace, initialUrl) { - const existing = automationSessions.get(workspace); - if (existing) { - try { - await chrome.windows.get(existing.windowId); - return existing.windowId; - } catch { - automationSessions.delete(workspace); - } - } - const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; - const win = await chrome.windows.create({ - url: startUrl, - focused: false, - width: 1280, - height: 900, - type: "normal" - }); - const session = { - windowId: win.id, - idleTimer: null, - idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT, - owned: true, - preferredTabId: null - }; - automationSessions.set(workspace, session); - console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`); - resetWindowIdleTimer(workspace); - const tabs = await chrome.tabs.query({ windowId: win.id }); - if (tabs[0]?.id) { - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 500); - const listener = (tabId, info) => { - if (tabId === tabs[0].id && info.status === "complete") { - chrome.tabs.onUpdated.removeListener(listener); - clearTimeout(timeout); - resolve(); - } - }; - if (tabs[0].status === "complete") { - clearTimeout(timeout); - resolve(); - } else { - chrome.tabs.onUpdated.addListener(listener); - } - }); - } - return session.windowId; + const existing = automationSessions.get(workspace); + if (existing) try { + await chrome.windows.get(existing.windowId); + return existing.windowId; + } catch { + automationSessions.delete(workspace); + } + const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; + const win = await chrome.windows.create({ + url: startUrl, + focused: false, + width: 1280, + height: 900, + type: "normal" + }); + const session = { + windowId: win.id, + idleTimer: null, + idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT, + owned: true, + preferredTabId: null + }; + automationSessions.set(workspace, session); + console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`); + resetWindowIdleTimer(workspace); + const tabs = await chrome.tabs.query({ windowId: win.id }); + if (tabs[0]?.id) await new Promise((resolve) => { + const timeout = setTimeout(resolve, 500); + const listener = (tabId, info) => { + if (tabId === tabs[0].id && info.status === "complete") { + chrome.tabs.onUpdated.removeListener(listener); + clearTimeout(timeout); + resolve(); + } + }; + if (tabs[0].status === "complete") { + clearTimeout(timeout); + resolve(); + } else chrome.tabs.onUpdated.addListener(listener); + }); + return session.windowId; } chrome.windows.onRemoved.addListener((windowId) => { - for (const [workspace, session] of automationSessions.entries()) { - if (session.windowId === windowId) { - console.log(`[opencli] Automation window closed (${workspace})`); - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - } - } + for (const [workspace, session] of automationSessions.entries()) if (session.windowId === windowId) { + console.log(`[opencli] Automation window closed (${workspace})`); + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + } }); -let initialized = false; +var initialized = false; function initialize() { - if (initialized) return; - initialized = true; - chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); - registerListeners(); - void connect(); - console.log("[opencli] OpenCLI extension initialized"); + if (initialized) return; + initialized = true; + chrome.alarms.create("keepalive", { periodInMinutes: .4 }); + registerListeners(); + connect(); + console.log("[opencli] OpenCLI extension initialized"); } chrome.runtime.onInstalled.addListener(() => { - initialize(); + initialize(); }); chrome.runtime.onStartup.addListener(() => { - initialize(); + initialize(); }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "keepalive") void connect(); + if (alarm.name === "keepalive") connect(); }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type === "getStatus") { - sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - reconnecting: reconnectTimer !== null - }); - } - return false; + if (msg?.type === "getStatus") sendResponse({ + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null + }); + return false; }); async function handleCommand(cmd) { - const workspace = getWorkspaceKey(cmd.workspace); - resetWindowIdleTimer(workspace); - try { - switch (cmd.action) { - case "exec": - return await handleExec(cmd, workspace); - case "navigate": - return await handleNavigate(cmd, workspace); - case "tabs": - return await handleTabs(cmd, workspace); - case "cookies": - return await handleCookies(cmd); - case "screenshot": - return await handleScreenshot(cmd, workspace); - case "close-window": - return await handleCloseWindow(cmd, workspace); - case "cdp": - return await handleCdp(cmd, workspace); - case "sessions": - return await handleSessions(cmd); - case "set-file-input": - return await handleSetFileInput(cmd, workspace); - case "insert-text": - return await handleInsertText(cmd, workspace); - case "bind-current": - return await handleBindCurrent(cmd, workspace); - case "network-capture-start": - return await handleNetworkCaptureStart(cmd, workspace); - case "network-capture-read": - return await handleNetworkCaptureRead(cmd, workspace); - default: - return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; - } - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } -} -const BLANK_PAGE = "about:blank"; + const workspace = getWorkspaceKey(cmd.workspace); + resetWindowIdleTimer(workspace); + try { + switch (cmd.action) { + case "exec": return await handleExec(cmd, workspace); + case "navigate": return await handleNavigate(cmd, workspace); + case "tabs": return await handleTabs(cmd, workspace); + case "cookies": return await handleCookies(cmd); + case "screenshot": return await handleScreenshot(cmd, workspace); + case "close-window": return await handleCloseWindow(cmd, workspace); + case "cdp": return await handleCdp(cmd, workspace); + case "sessions": return await handleSessions(cmd); + case "set-file-input": return await handleSetFileInput(cmd, workspace); + case "insert-text": return await handleInsertText(cmd, workspace); + case "bind-current": return await handleBindCurrent(cmd, workspace); + case "network-capture-start": return await handleNetworkCaptureStart(cmd, workspace); + case "network-capture-read": return await handleNetworkCaptureRead(cmd, workspace); + case "console-read": return await handleConsoleRead(cmd, workspace); + case "capture-stop": return await handleCaptureStop(cmd, workspace); + default: return { + id: cmd.id, + ok: false, + error: `Unknown action: ${cmd.action}` + }; + } + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } +} +/** Internal blank page used when no user URL is provided. */ +var BLANK_PAGE = "about:blank"; +/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ function isDebuggableUrl(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); } +/** Check if a URL is safe for user-facing navigation (http/https only). */ function isSafeNavigationUrl(url) { - return url.startsWith("http://") || url.startsWith("https://"); + return url.startsWith("http://") || url.startsWith("https://"); } +/** Minimal URL normalization for same-page comparison: root slash + default port only. */ function normalizeUrlForComparison(url) { - if (!url) return ""; - try { - const parsed = new URL(url); - if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") { - parsed.port = ""; - } - const pathname = parsed.pathname === "/" ? "" : parsed.pathname; - return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; - } catch { - return url; - } + if (!url) return ""; + try { + const parsed = new URL(url); + if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") parsed.port = ""; + const pathname = parsed.pathname === "/" ? "" : parsed.pathname; + return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; + } catch { + return url; + } } function isTargetUrl(currentUrl, targetUrl) { - return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); + return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); } function matchesDomain(url, domain) { - if (!url) return false; - try { - const parsed = new URL(url); - return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); - } catch { - return false; - } + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); + } catch { + return false; + } } function matchesBindCriteria(tab, cmd) { - if (!tab.id || !isDebuggableUrl(tab.url)) return false; - if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; - if (cmd.matchPathPrefix) { - try { - const parsed = new URL(tab.url); - if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false; - } catch { - return false; - } - } - return true; + if (!tab.id || !isDebuggableUrl(tab.url)) return false; + if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; + if (cmd.matchPathPrefix) try { + if (!new URL(tab.url).pathname.startsWith(cmd.matchPathPrefix)) return false; + } catch { + return false; + } + return true; } function setWorkspaceSession(workspace, session) { - const existing = automationSessions.get(workspace); - if (existing?.idleTimer) clearTimeout(existing.idleTimer); - automationSessions.set(workspace, { - ...session, - idleTimer: null, - idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT - }); + const existing = automationSessions.get(workspace); + if (existing?.idleTimer) clearTimeout(existing.idleTimer); + automationSessions.set(workspace, { + ...session, + idleTimer: null, + idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT + }); } +function rememberWorkspaceTab(workspace, tabId) { + const session = automationSessions.get(workspace); + if (!session) return; + session.preferredTabId = tabId; +} +/** +* Resolve target tab in the automation window, returning both the tabId and +* the Tab object (when available) so callers can skip a redundant chrome.tabs.get(). +*/ async function resolveTab(tabId, workspace, initialUrl) { - if (tabId !== void 0) { - try { - const tab = await chrome.tabs.get(tabId); - const session = automationSessions.get(workspace); - const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false; - if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab }; - if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { - console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); - try { - await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); - const moved = await chrome.tabs.get(tabId); - if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) { - return { tabId, tab: moved }; - } - } catch (moveErr) { - console.warn(`[opencli] Failed to move tab back: ${moveErr}`); - } - } else if (!isDebuggableUrl(tab.url)) { - console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); - } - } catch { - console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); - } - } - const existingSession = automationSessions.get(workspace); - if (existingSession?.preferredTabId !== null) { - try { - const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); - if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id, tab: preferredTab }; - } catch { - automationSessions.delete(workspace); - } - } - const windowId = await getAutomationWindow(workspace, initialUrl); - const tabs = await chrome.tabs.query({ windowId }); - const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); - if (debuggableTab?.id) return { tabId: debuggableTab.id, tab: debuggableTab }; - const reuseTab = tabs.find((t) => t.id); - if (reuseTab?.id) { - await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); - await new Promise((resolve) => setTimeout(resolve, 300)); - try { - const updated = await chrome.tabs.get(reuseTab.id); - if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated }; - console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); - } catch { - } - } - const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true }); - if (!newTab.id) throw new Error("Failed to create tab in automation window"); - return { tabId: newTab.id, tab: newTab }; + if (tabId !== void 0) try { + const tab = await chrome.tabs.get(tabId); + const session = automationSessions.get(workspace); + const matchesSession = session ? session.owned ? tab.windowId === session.windowId : session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false; + if (isDebuggableUrl(tab.url) && matchesSession) return { + tabId, + tab + }; + if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { + console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); + try { + await chrome.tabs.move(tabId, { + windowId: session.windowId, + index: -1 + }); + const moved = await chrome.tabs.get(tabId); + if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) return { + tabId, + tab: moved + }; + } catch (moveErr) { + console.warn(`[opencli] Failed to move tab back: ${moveErr}`); + } + } else if (!isDebuggableUrl(tab.url)) console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); + } catch { + console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); + } + const existingSession = automationSessions.get(workspace); + if (existingSession?.preferredTabId !== null) try { + const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); + const ownedWindowMatches = !existingSession.owned || preferredTab.windowId === existingSession.windowId; + if (isDebuggableUrl(preferredTab.url) && ownedWindowMatches) return { + tabId: preferredTab.id, + tab: preferredTab + }; + } catch { + automationSessions.delete(workspace); + } + const windowId = await getAutomationWindow(workspace, initialUrl); + const tabs = await chrome.tabs.query({ windowId }); + const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); + if (debuggableTab?.id) return { + tabId: debuggableTab.id, + tab: debuggableTab + }; + const reuseTab = tabs.find((t) => t.id); + if (reuseTab?.id) { + await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); + await new Promise((resolve) => setTimeout(resolve, 300)); + try { + const updated = await chrome.tabs.get(reuseTab.id); + if (isDebuggableUrl(updated.url)) return { + tabId: reuseTab.id, + tab: updated + }; + console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); + } catch {} + } + const newTab = await chrome.tabs.create({ + windowId, + url: BLANK_PAGE, + active: true + }); + if (!newTab.id) throw new Error("Failed to create tab in automation window"); + return { + tabId: newTab.id, + tab: newTab + }; } +/** Convenience wrapper returning just the tabId (used by most handlers) */ async function resolveTabId(tabId, workspace, initialUrl) { - const resolved = await resolveTab(tabId, workspace, initialUrl); - return resolved.tabId; + return (await resolveTab(tabId, workspace, initialUrl)).tabId; } async function listAutomationTabs(workspace) { - const session = automationSessions.get(workspace); - if (!session) return []; - if (session.preferredTabId !== null) { - try { - return [await chrome.tabs.get(session.preferredTabId)]; - } catch { - automationSessions.delete(workspace); - return []; - } - } - try { - return await chrome.tabs.query({ windowId: session.windowId }); - } catch { - automationSessions.delete(workspace); - return []; - } + const session = automationSessions.get(workspace); + if (!session) return []; + if (session.preferredTabId !== null && !session.owned) try { + return [await chrome.tabs.get(session.preferredTabId)]; + } catch { + automationSessions.delete(workspace); + return []; + } + try { + return await chrome.tabs.query({ windowId: session.windowId }); + } catch { + automationSessions.delete(workspace); + return []; + } } async function listAutomationWebTabs(workspace) { - const tabs = await listAutomationTabs(workspace); - return tabs.filter((tab) => isDebuggableUrl(tab.url)); + return (await listAutomationTabs(workspace)).filter((tab) => isDebuggableUrl(tab.url)); } async function handleExec(cmd, workspace) { - if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const aggressive = workspace.startsWith("operate:"); - const data = await evaluateAsync(tabId, cmd.code, aggressive); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + if (!cmd.code) return { + id: cmd.id, + ok: false, + error: "Missing code" + }; + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + const aggressive = workspace.startsWith("operate:"); + const data = await evaluateAsync(tabId, cmd.code, aggressive); + return { + id: cmd.id, + ok: true, + data + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleNavigate(cmd, workspace) { - if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" }; - if (!isSafeNavigationUrl(cmd.url)) { - return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; - } - const resolved = await resolveTab(cmd.tabId, workspace, cmd.url); - const tabId = resolved.tabId; - const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); - const beforeNormalized = normalizeUrlForComparison(beforeTab.url); - const targetUrl = cmd.url; - if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) { - return { - id: cmd.id, - ok: true, - data: { title: beforeTab.title, url: beforeTab.url, tabId, timedOut: false } - }; - } - await detach(tabId); - await chrome.tabs.update(tabId, { url: targetUrl }); - let timedOut = false; - await new Promise((resolve) => { - let settled = false; - let checkTimer = null; - let timeoutTimer = null; - const finish = () => { - if (settled) return; - settled = true; - chrome.tabs.onUpdated.removeListener(listener); - if (checkTimer) clearTimeout(checkTimer); - if (timeoutTimer) clearTimeout(timeoutTimer); - resolve(); - }; - const isNavigationDone = (url) => { - return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; - }; - const listener = (id, info, tab2) => { - if (id !== tabId) return; - if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) { - finish(); - } - }; - chrome.tabs.onUpdated.addListener(listener); - checkTimer = setTimeout(async () => { - try { - const currentTab = await chrome.tabs.get(tabId); - if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) { - finish(); - } - } catch { - } - }, 100); - timeoutTimer = setTimeout(() => { - timedOut = true; - console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); - finish(); - }, 15e3); - }); - let tab = await chrome.tabs.get(tabId); - const session = automationSessions.get(workspace); - if (session && tab.windowId !== session.windowId) { - console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`); - try { - await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); - tab = await chrome.tabs.get(tabId); - } catch (moveErr) { - console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); - } - } - return { - id: cmd.id, - ok: true, - data: { title: tab.title, url: tab.url, tabId, timedOut } - }; + if (!cmd.url) return { + id: cmd.id, + ok: false, + error: "Missing url" + }; + if (!isSafeNavigationUrl(cmd.url)) return { + id: cmd.id, + ok: false, + error: "Blocked URL scheme -- only http:// and https:// are allowed" + }; + const resolved = await resolveTab(cmd.tabId, workspace, cmd.url); + const tabId = resolved.tabId; + rememberWorkspaceTab(workspace, tabId); + const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); + const beforeNormalized = normalizeUrlForComparison(beforeTab.url); + const targetUrl = cmd.url; + if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) return { + id: cmd.id, + ok: true, + data: { + title: beforeTab.title, + url: beforeTab.url, + tabId, + timedOut: false + } + }; + await detach(tabId); + await chrome.tabs.update(tabId, { url: targetUrl }); + let timedOut = false; + await new Promise((resolve) => { + let settled = false; + let checkTimer = null; + let timeoutTimer = null; + const finish = () => { + if (settled) return; + settled = true; + chrome.tabs.onUpdated.removeListener(listener); + if (checkTimer) clearTimeout(checkTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + resolve(); + }; + const isNavigationDone = (url) => { + return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; + }; + const listener = (id, info, tab) => { + if (id !== tabId) return; + if (info.status === "complete" && isNavigationDone(tab.url ?? info.url)) finish(); + }; + chrome.tabs.onUpdated.addListener(listener); + checkTimer = setTimeout(async () => { + try { + const currentTab = await chrome.tabs.get(tabId); + if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) finish(); + } catch {} + }, 100); + timeoutTimer = setTimeout(() => { + timedOut = true; + console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); + finish(); + }, 15e3); + }); + let tab = await chrome.tabs.get(tabId); + const session = automationSessions.get(workspace); + if (session && tab.windowId !== session.windowId) { + console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`); + try { + await chrome.tabs.move(tabId, { + windowId: session.windowId, + index: -1 + }); + tab = await chrome.tabs.get(tabId); + } catch (moveErr) { + console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); + } + } + if (hasCaptureIntent(tabId)) try { + await ensureAttached(tabId); + } catch (error) { + console.warn(`[opencli] failed to reattach after navigate: ${error}`); + } + return { + id: cmd.id, + ok: true, + data: { + title: tab.title, + url: tab.url, + tabId, + timedOut + } + }; } async function handleTabs(cmd, workspace) { - switch (cmd.op) { - case "list": { - const tabs = await listAutomationWebTabs(workspace); - const data = tabs.map((t, i) => ({ - index: i, - tabId: t.id, - url: t.url, - title: t.title, - active: t.active - })); - return { id: cmd.id, ok: true, data }; - } - case "new": { - if (cmd.url && !isSafeNavigationUrl(cmd.url)) { - return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; - } - const windowId = await getAutomationWindow(workspace); - const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); - return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } }; - } - case "close": { - if (cmd.index !== void 0) { - const tabs = await listAutomationWebTabs(workspace); - const target = tabs[cmd.index]; - if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; - await chrome.tabs.remove(target.id); - await detach(target.id); - return { id: cmd.id, ok: true, data: { closed: target.id } }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - await chrome.tabs.remove(tabId); - await detach(tabId); - return { id: cmd.id, ok: true, data: { closed: tabId } }; - } - case "select": { - if (cmd.index === void 0 && cmd.tabId === void 0) - return { id: cmd.id, ok: false, error: "Missing index or tabId" }; - if (cmd.tabId !== void 0) { - const session = automationSessions.get(workspace); - let tab; - try { - tab = await chrome.tabs.get(cmd.tabId); - } catch { - return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} no longer exists` }; - } - if (!session || tab.windowId !== session.windowId) { - return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} is not in the automation window` }; - } - await chrome.tabs.update(cmd.tabId, { active: true }); - return { id: cmd.id, ok: true, data: { selected: cmd.tabId } }; - } - const tabs = await listAutomationWebTabs(workspace); - const target = tabs[cmd.index]; - if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; - await chrome.tabs.update(target.id, { active: true }); - return { id: cmd.id, ok: true, data: { selected: target.id } }; - } - default: - return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` }; - } + switch (cmd.op) { + case "list": { + const data = (await listAutomationWebTabs(workspace)).map((t, i) => ({ + index: i, + tabId: t.id, + url: t.url, + title: t.title, + active: t.active + })); + return { + id: cmd.id, + ok: true, + data + }; + } + case "new": { + if (cmd.url && !isSafeNavigationUrl(cmd.url)) return { + id: cmd.id, + ok: false, + error: "Blocked URL scheme -- only http:// and https:// are allowed" + }; + const windowId = await getAutomationWindow(workspace); + const tab = await chrome.tabs.create({ + windowId, + url: cmd.url ?? BLANK_PAGE, + active: true + }); + if (tab.id) rememberWorkspaceTab(workspace, tab.id); + return { + id: cmd.id, + ok: true, + data: { + tabId: tab.id, + url: tab.url + } + }; + } + case "close": { + if (cmd.index !== void 0) { + const target = (await listAutomationWebTabs(workspace))[cmd.index]; + if (!target?.id) return { + id: cmd.id, + ok: false, + error: `Tab index ${cmd.index} not found` + }; + await chrome.tabs.remove(target.id); + await detach(target.id); + return { + id: cmd.id, + ok: true, + data: { closed: target.id } + }; + } + const tabId = await resolveTabId(cmd.tabId, workspace); + await chrome.tabs.remove(tabId); + await detach(tabId); + return { + id: cmd.id, + ok: true, + data: { closed: tabId } + }; + } + case "select": { + if (cmd.index === void 0 && cmd.tabId === void 0) return { + id: cmd.id, + ok: false, + error: "Missing index or tabId" + }; + if (cmd.tabId !== void 0) { + const session = automationSessions.get(workspace); + let tab; + try { + tab = await chrome.tabs.get(cmd.tabId); + } catch { + return { + id: cmd.id, + ok: false, + error: `Tab ${cmd.tabId} no longer exists` + }; + } + if (!session || tab.windowId !== session.windowId) return { + id: cmd.id, + ok: false, + error: `Tab ${cmd.tabId} is not in the automation window` + }; + await chrome.tabs.update(cmd.tabId, { active: true }); + rememberWorkspaceTab(workspace, cmd.tabId); + return { + id: cmd.id, + ok: true, + data: { selected: cmd.tabId } + }; + } + const target = (await listAutomationWebTabs(workspace))[cmd.index]; + if (!target?.id) return { + id: cmd.id, + ok: false, + error: `Tab index ${cmd.index} not found` + }; + await chrome.tabs.update(target.id, { active: true }); + rememberWorkspaceTab(workspace, target.id); + return { + id: cmd.id, + ok: true, + data: { selected: target.id } + }; + } + default: return { + id: cmd.id, + ok: false, + error: `Unknown tabs op: ${cmd.op}` + }; + } } async function handleCookies(cmd) { - if (!cmd.domain && !cmd.url) { - return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" }; - } - const details = {}; - if (cmd.domain) details.domain = cmd.domain; - if (cmd.url) details.url = cmd.url; - const cookies = await chrome.cookies.getAll(details); - const data = cookies.map((c) => ({ - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - secure: c.secure, - httpOnly: c.httpOnly, - expirationDate: c.expirationDate - })); - return { id: cmd.id, ok: true, data }; + if (!cmd.domain && !cmd.url) return { + id: cmd.id, + ok: false, + error: "Cookie scope required: provide domain or url to avoid dumping all cookies" + }; + const details = {}; + if (cmd.domain) details.domain = cmd.domain; + if (cmd.url) details.url = cmd.url; + const data = (await chrome.cookies.getAll(details)).map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.secure, + httpOnly: c.httpOnly, + expirationDate: c.expirationDate + })); + return { + id: cmd.id, + ok: true, + data + }; } async function handleScreenshot(cmd, workspace) { - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const data = await screenshot(tabId, { - format: cmd.format, - quality: cmd.quality, - fullPage: cmd.fullPage - }); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } -} -const CDP_ALLOWLIST = /* @__PURE__ */ new Set([ - // Agent DOM context - "Accessibility.getFullAXTree", - "DOM.getDocument", - "DOM.getBoxModel", - "DOM.getContentQuads", - "DOM.querySelectorAll", - "DOM.scrollIntoViewIfNeeded", - "DOMSnapshot.captureSnapshot", - // Native input events - "Input.dispatchMouseEvent", - "Input.dispatchKeyEvent", - "Input.insertText", - // Page metrics & screenshots - "Page.getLayoutMetrics", - "Page.captureScreenshot", - // Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action) - "Runtime.enable", - // Emulation (used by screenshot full-page) - "Emulation.setDeviceMetricsOverride", - "Emulation.clearDeviceMetricsOverride" + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + const data = await screenshot(tabId, { + format: cmd.format, + quality: cmd.quality, + fullPage: cmd.fullPage + }); + return { + id: cmd.id, + ok: true, + data + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } +} +/** CDP methods permitted via the 'cdp' passthrough action. */ +var CDP_ALLOWLIST = new Set([ + "Accessibility.getFullAXTree", + "DOM.getDocument", + "DOM.getBoxModel", + "DOM.getContentQuads", + "DOM.querySelectorAll", + "DOM.scrollIntoViewIfNeeded", + "DOMSnapshot.captureSnapshot", + "Input.dispatchMouseEvent", + "Input.dispatchKeyEvent", + "Input.insertText", + "Page.getLayoutMetrics", + "Page.captureScreenshot", + "Runtime.enable", + "Emulation.setDeviceMetricsOverride", + "Emulation.clearDeviceMetricsOverride" ]); async function handleCdp(cmd, workspace) { - if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: "Missing cdpMethod" }; - if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) { - return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const aggressive = workspace.startsWith("operate:"); - await ensureAttached(tabId, aggressive); - const data = await chrome.debugger.sendCommand( - { tabId }, - cmd.cdpMethod, - cmd.cdpParams ?? {} - ); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + if (!cmd.cdpMethod) return { + id: cmd.id, + ok: false, + error: "Missing cdpMethod" + }; + if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) return { + id: cmd.id, + ok: false, + error: `CDP method not permitted: ${cmd.cdpMethod}` + }; + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + await ensureAttached(tabId, workspace.startsWith("operate:")); + const data = await chrome.debugger.sendCommand({ tabId }, cmd.cdpMethod, cmd.cdpParams ?? {}); + return { + id: cmd.id, + ok: true, + data + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleCloseWindow(cmd, workspace) { - const session = automationSessions.get(workspace); - if (session) { - if (session.owned) { - try { - await chrome.windows.remove(session.windowId); - } catch { - } - } - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - } - return { id: cmd.id, ok: true, data: { closed: true } }; + const session = automationSessions.get(workspace); + if (session) { + if (session.owned) try { + await chrome.windows.remove(session.windowId); + } catch {} + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + } + return { + id: cmd.id, + ok: true, + data: { closed: true } + }; } async function handleSetFileInput(cmd, workspace) { - if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) { - return { id: cmd.id, ok: false, error: "Missing or empty files array" }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - await setFileInputFiles(tabId, cmd.files, cmd.selector); - return { id: cmd.id, ok: true, data: { count: cmd.files.length } }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) return { + id: cmd.id, + ok: false, + error: "Missing or empty files array" + }; + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + await setFileInputFiles(tabId, cmd.files, cmd.selector); + return { + id: cmd.id, + ok: true, + data: { count: cmd.files.length } + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleInsertText(cmd, workspace) { - if (typeof cmd.text !== "string") { - return { id: cmd.id, ok: false, error: "Missing text payload" }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - await insertText(tabId, cmd.text); - return { id: cmd.id, ok: true, data: { inserted: true } }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + if (typeof cmd.text !== "string") return { + id: cmd.id, + ok: false, + error: "Missing text payload" + }; + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + await insertText(tabId, cmd.text); + return { + id: cmd.id, + ok: true, + data: { inserted: true } + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleNetworkCaptureStart(cmd, workspace) { - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - await startNetworkCapture(tabId, cmd.pattern); - return { id: cmd.id, ok: true, data: { started: true } }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + const tabId = await resolveTabId(cmd.tabId, workspace); + rememberWorkspaceTab(workspace, tabId); + try { + await startNetworkCapture(tabId, cmd.pattern); + return { + id: cmd.id, + ok: true, + data: { started: true } + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleNetworkCaptureRead(cmd, workspace) { - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const data = await readNetworkCapture(tabId); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + const data = await readNetworkCapture(tabId); + return { + id: cmd.id, + ok: true, + data + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } +} +async function handleConsoleRead(cmd, workspace) { + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + const data = await readConsoleCapture(tabId); + return { + id: cmd.id, + ok: true, + data + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } +} +async function handleCaptureStop(cmd, workspace) { + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + await stopCapture(tabId); + return { + id: cmd.id, + ok: true, + data: { stopped: true } + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleSessions(cmd) { - const now = Date.now(); - const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ - workspace, - windowId: session.windowId, - tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, - idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) - }))); - return { id: cmd.id, ok: true, data }; + const now = Date.now(); + const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ + workspace, + windowId: session.windowId, + tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, + idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) + }))); + return { + id: cmd.id, + ok: true, + data + }; } async function handleBindCurrent(cmd, workspace) { - const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); - const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); - const allTabs = await chrome.tabs.query({}); - const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd)); - if (!boundTab?.id) { - return { - id: cmd.id, - ok: false, - error: cmd.matchDomain || cmd.matchPathPrefix ? `No visible tab matching ${cmd.matchDomain ?? "domain"}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ""}` : "No active debuggable tab found" - }; - } - setWorkspaceSession(workspace, { - windowId: boundTab.windowId, - owned: false, - preferredTabId: boundTab.id - }); - resetWindowIdleTimer(workspace); - console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); - return { - id: cmd.id, - ok: true, - data: { - tabId: boundTab.id, - windowId: boundTab.windowId, - url: boundTab.url, - title: boundTab.title, - workspace - } - }; + const activeTabs = await chrome.tabs.query({ + active: true, + lastFocusedWindow: true + }); + const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); + const allTabs = await chrome.tabs.query({}); + const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd)); + if (!boundTab?.id) return { + id: cmd.id, + ok: false, + error: cmd.matchDomain || cmd.matchPathPrefix ? `No visible tab matching ${cmd.matchDomain ?? "domain"}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ""}` : "No active debuggable tab found" + }; + setWorkspaceSession(workspace, { + windowId: boundTab.windowId, + owned: false, + preferredTabId: boundTab.id + }); + resetWindowIdleTimer(workspace); + console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); + return { + id: cmd.id, + ok: true, + data: { + tabId: boundTab.id, + windowId: boundTab.windowId, + url: boundTab.url, + title: boundTab.title, + workspace + } + }; } +//#endregion diff --git a/extension/manifest.json b/extension/manifest.json index a630b554e..c4637ec17 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -7,6 +7,7 @@ "debugger", "tabs", "cookies", + "scripting", "activeTab", "alarms" ], diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index f15964f14..080e4892c 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -96,10 +96,21 @@ function createChromeMock() { onStartup: { addListener: vi.fn() } as Listener<() => void>, onMessage: { addListener: vi.fn() } as Listener<(msg: unknown, sender: unknown, sendResponse: (value: unknown) => void) => void>, getManifest: vi.fn(() => ({ version: 'test-version' })), + id: 'opencli-test', }, cookies: { getAll: vi.fn(async () => []), }, + debugger: { + attach: vi.fn(async () => {}), + detach: vi.fn(async () => {}), + sendCommand: vi.fn(async (_target: unknown, _method: string) => ({})), + onDetach: { addListener: vi.fn() } as Listener<(source: { tabId?: number }) => void>, + onEvent: { addListener: vi.fn() } as Listener<(source: { tabId?: number }, method: string, params: unknown) => void>, + }, + scripting: { + executeScript: vi.fn(async () => [{ result: { removed: 1 } }]), + }, }; return { chrome, tabs, query, create, update }; @@ -151,6 +162,49 @@ describe('background tab isolation', () => { expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'https://new.example', active: true }); }); + it('lists all owned automation-window web tabs even when a preferred tab is remembered', async () => { + const { chrome, tabs } = createChromeMock(); + tabs.push({ id: 4, windowId: 1, url: 'https://second.example', title: 'second', active: false, status: 'complete' }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setSession('site:twitter', { windowId: 1, owned: true, preferredTabId: 4 }); + + const result = await mod.__test__.handleTabs({ id: 'owned-list', action: 'tabs', op: 'list', workspace: 'site:twitter' }, 'site:twitter'); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([ + { + index: 0, + tabId: 1, + url: 'https://automation.example', + title: 'automation', + active: true, + }, + { + index: 1, + tabId: 4, + url: 'https://second.example', + title: 'second', + active: false, + }, + ]); + }); + + it('does not reuse a preferred tab from another window for owned sessions', async () => { + const { chrome, tabs, create } = createChromeMock(); + tabs.push({ id: 4, windowId: 2, url: 'https://drifted.example', title: 'drifted', active: false, status: 'complete' }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setSession('site:twitter', { windowId: 1, owned: true, preferredTabId: 4 }); + + const result = await mod.__test__.handleTabs({ id: 'owned-new', action: 'tabs', op: 'new', url: 'https://new.example', workspace: 'site:twitter' }, 'site:twitter'); + + expect(result.ok).toBe(true); + expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'https://new.example', active: true }); + }); + it('treats normalized same-url navigate as already complete', async () => { const { chrome, tabs, update } = createChromeMock(); tabs[0].url = 'https://www.bilibili.com/'; @@ -276,4 +330,76 @@ describe('background tab isolation', () => { expect(chrome.windows.remove).toHaveBeenCalledWith(1); expect(mod.__test__.getSession('site:notebooklm')).toBeNull(); }); + + it('best-effort rearms capture after navigate when capture intent exists', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const executor = await import('./cdp'); + const mod = await import('./background'); + + mod.__test__.setAutomationWindowId('site:twitter', 1); + await executor.startNetworkCapture(1, '/api/'); + chrome.debugger.sendCommand.mockClear(); + + await mod.__test__.handleNavigate( + { id: 'n1', action: 'navigate', url: 'https://x.com/home', workspace: 'site:twitter' }, + 'site:twitter', + ); + + expect(chrome.debugger.sendCommand).toHaveBeenCalledWith({ tabId: 1 }, 'Network.enable'); + expect(chrome.debugger.sendCommand).toHaveBeenCalledWith({ tabId: 1 }, 'Runtime.enable'); + }); + + it('remembers the navigated tab for owned workspaces', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId('operate:default', 1); + + await mod.__test__.handleNavigate( + { id: 'remember-tab', action: 'navigate', url: 'https://example.com', workspace: 'operate:default' }, + 'operate:default', + ); + + expect(mod.__test__.getSession('operate:default')).toEqual(expect.objectContaining({ + windowId: 1, + owned: true, + preferredTabId: 1, + })); + }); + + it('routes console-read and capture-stop through the executor', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const executor = await import('./cdp'); + const mod = await import('./background'); + + mod.__test__.setAutomationWindowId('site:twitter', 1); + await executor.startNetworkCapture(1, '/api/'); + + const consoleResult = await mod.__test__.handleCommand({ + id: 'console-1', + action: 'console-read', + workspace: 'site:twitter', + }); + expect(consoleResult).toEqual({ + id: 'console-1', + ok: true, + data: [], + }); + + const stopResult = await mod.__test__.handleCommand({ + id: 'stop-1', + action: 'capture-stop', + workspace: 'site:twitter', + }); + expect(stopResult).toEqual({ + id: 'stop-1', + ok: true, + data: { stopped: true }, + }); + }); }); diff --git a/extension/src/background.ts b/extension/src/background.ts index da132a9d4..1504ea49c 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -296,6 +296,10 @@ async function handleCommand(cmd: Command): Promise { return await handleNetworkCaptureStart(cmd, workspace); case 'network-capture-read': return await handleNetworkCaptureRead(cmd, workspace); + case 'console-read': + return await handleConsoleRead(cmd, workspace); + case 'capture-stop': + return await handleCaptureStop(cmd, workspace); default: return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; } @@ -377,6 +381,12 @@ function setWorkspaceSession(workspace: string, session: Omit { const session = automationSessions.get(workspace); if (!session) return []; - if (session.preferredTabId !== null) { + if (session.preferredTabId !== null && !session.owned) { try { return [await chrome.tabs.get(session.preferredTabId)]; } catch { @@ -502,6 +515,7 @@ async function handleNavigate(cmd: Command, workspace: string): Promise // Pass target URL so that first-time window creation can start on the right domain const resolved = await resolveTab(cmd.tabId, workspace, cmd.url); const tabId = resolved.tabId; + rememberWorkspaceTab(workspace, tabId); const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); const beforeNormalized = normalizeUrlForComparison(beforeTab.url); @@ -590,6 +604,14 @@ async function handleNavigate(cmd: Command, workspace: string): Promise } } + if (executor.hasCaptureIntent(tabId)) { + try { + await executor.ensureAttached(tabId); + } catch (error) { + console.warn(`[opencli] failed to reattach after navigate: ${error}`); + } + } + return { id: cmd.id, ok: true, @@ -617,6 +639,7 @@ async function handleTabs(cmd: Command, workspace: string): Promise { } const windowId = await getAutomationWindow(workspace); const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); + if (tab.id) rememberWorkspaceTab(workspace, tab.id); return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } }; } case 'close': { @@ -648,12 +671,14 @@ async function handleTabs(cmd: Command, workspace: string): Promise { return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} is not in the automation window` }; } await chrome.tabs.update(cmd.tabId, { active: true }); + rememberWorkspaceTab(workspace, cmd.tabId); return { id: cmd.id, ok: true, data: { selected: cmd.tabId } }; } const tabs = await listAutomationWebTabs(workspace); const target = tabs[cmd.index!]; if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; await chrome.tabs.update(target.id, { active: true }); + rememberWorkspaceTab(workspace, target.id); return { id: cmd.id, ok: true, data: { selected: target.id } }; } default: @@ -783,6 +808,7 @@ async function handleInsertText(cmd: Command, workspace: string): Promise { const tabId = await resolveTabId(cmd.tabId, workspace); + rememberWorkspaceTab(workspace, tabId); try { await executor.startNetworkCapture(tabId, cmd.pattern); return { id: cmd.id, ok: true, data: { started: true } }; @@ -801,6 +827,26 @@ async function handleNetworkCaptureRead(cmd: Command, workspace: string): Promis } } +async function handleConsoleRead(cmd: Command, workspace: string): Promise { + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + const data = await executor.readConsoleCapture(tabId); + return { id: cmd.id, ok: true, data }; + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +async function handleCaptureStop(cmd: Command, workspace: string): Promise { + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + await executor.stopCapture(tabId); + return { id: cmd.id, ok: true, data: { stopped: true } }; + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + async function handleSessions(cmd: Command): Promise { const now = Date.now(); const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ @@ -850,6 +896,7 @@ async function handleBindCurrent(cmd: Command, workspace: string): Promise { expect(scripting.executeScript).toHaveBeenCalledTimes(1); expect(debuggerApi.attach).toHaveBeenCalledTimes(2); }); + + it('preserves capture intent across self-detach and rearms after reattach', async () => { + const { chrome, debuggerApi } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + + await mod.startNetworkCapture(1, '/api/'); + debuggerApi.sendCommand.mockClear(); + await mod.detach(1); + await mod.ensureAttached(1); + + expect(debuggerApi.sendCommand).toHaveBeenCalledWith({ tabId: 1 }, 'Network.enable'); + expect(debuggerApi.sendCommand).toHaveBeenCalledWith({ tabId: 1 }, 'Runtime.enable'); + }); + + it('marks capture unarmed before detach so failed detach cannot block rearm', async () => { + const { chrome, debuggerApi } = createChromeMock(); + debuggerApi.detach.mockRejectedValueOnce(new Error('detach failed')); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + + await mod.startNetworkCapture(1, '/api/'); + debuggerApi.sendCommand.mockClear(); + await mod.detach(1); + await mod.ensureAttached(1); + + expect(debuggerApi.attach).toHaveBeenCalled(); + expect(debuggerApi.sendCommand).toHaveBeenCalledWith({ tabId: 1 }, 'Network.enable'); + expect(debuggerApi.sendCommand).toHaveBeenCalledWith({ tabId: 1 }, 'Runtime.enable'); + }); + + it('preserves buffered capture data across detach when capture intent remains', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + mod.registerListeners(); + + const onEvent = chrome.debugger.onEvent.addListener.mock.calls[0][0] as (source: { tabId?: number }, method: string, params: unknown) => Promise; + const onDetach = chrome.debugger.onDetach.addListener.mock.calls[0][0] as (source: { tabId?: number }) => void; + + await mod.startNetworkCapture(1, '/api/'); + await onEvent({ tabId: 1 }, 'Network.requestWillBeSent', { + requestId: '1', + request: { url: 'https://x.test/api/items', method: 'GET', headers: {} }, + timestamp: 1, + }); + await onEvent({ tabId: 1 }, 'Runtime.consoleAPICalled', { + type: 'error', + args: [{ value: 'boom' }], + timestamp: 2, + }); + + onDetach({ tabId: 1 }); + + expect(await mod.readNetworkCapture(1)).toEqual([ + expect.objectContaining({ url: 'https://x.test/api/items', method: 'GET' }), + ]); + expect(await mod.readConsoleCapture(1)).toEqual([ + expect.objectContaining({ level: 'error', text: 'boom' }), + ]); + }); + + it('does not clear console history when restarting network capture', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + mod.registerListeners(); + + const onEvent = chrome.debugger.onEvent.addListener.mock.calls[0][0] as (source: { tabId?: number }, method: string, params: unknown) => Promise; + + await mod.startNetworkCapture(1, '/api/'); + await onEvent({ tabId: 1 }, 'Runtime.consoleAPICalled', { + type: 'error', + args: [{ value: 'boom' }], + timestamp: 2, + }); + + await mod.startNetworkCapture(1, '/other/'); + + expect(await mod.readConsoleCapture(1)).toEqual([ + expect.objectContaining({ level: 'error', text: 'boom' }), + ]); + }); + + it('rearms capture on read when intent exists but the state is unarmed', async () => { + const { chrome, debuggerApi } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + + await mod.startNetworkCapture(1, '/api/'); + debuggerApi.sendCommand.mockClear(); + await mod.detach(1); + await mod.readNetworkCapture(1); + + expect(debuggerApi.attach).toHaveBeenCalled(); + expect(debuggerApi.sendCommand).toHaveBeenCalledWith({ tabId: 1 }, 'Network.enable'); + expect(debuggerApi.sendCommand).toHaveBeenCalledWith({ tabId: 1 }, 'Runtime.enable'); + }); + + it('surfaces rearm failures on read instead of silently returning stale data', async () => { + const { chrome, debuggerApi } = createChromeMock(); + debuggerApi.attach.mockResolvedValueOnce(undefined).mockRejectedValue(new Error('attach failed')); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + + await mod.startNetworkCapture(1, '/api/'); + await mod.detach(1); + + await expect(mod.readNetworkCapture(1)).rejects.toThrow('attach failed'); + }); }); diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index 23c1d695d..3cf718209 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -8,6 +8,13 @@ const attached = new Set(); +type ConsoleMessage = { + level: 'log' | 'warn' | 'error' | 'info' | 'debug'; + text: string; + timestamp?: number; + source?: 'console-api' | 'exception'; +}; + type NetworkCaptureEntry = { kind: 'cdp'; url: string; @@ -26,15 +33,34 @@ type NetworkCaptureState = { patterns: string[]; entries: NetworkCaptureEntry[]; requestToIndex: Map; + consoleErrors: ConsoleMessage[]; + consoleOther: ConsoleMessage[]; + armed: boolean; }; const networkCaptures = new Map(); +const captureIntents = new Map(); /** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ function isDebuggableUrl(url?: string): boolean { if (!url) return true; // empty/undefined = tab still loading, allow it return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:'); } +async function cleanupForeignExtensionSurface(tabId: number): Promise { + try { + await chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + func: () => { + document.querySelectorAll('iframe[src^="chrome-extension://"], [src^="chrome-extension://"]') + .forEach((node) => node.remove()); + }, + }); + } catch { + // Best-effort cleanup only. + } +} + export async function ensureAttached(tabId: number, aggressiveRetry: boolean = false): Promise { // Verify the tab URL is debuggable before attempting attach try { @@ -82,6 +108,9 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f } catch (e: unknown) { lastError = e instanceof Error ? e.message : String(e); if (attempt < MAX_ATTACH_RETRIES) { + if (lastError.includes('chrome-extension://')) { + await cleanupForeignExtensionSurface(tabId); + } console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS)); // Re-verify tab URL before retrying (it may have changed) @@ -117,6 +146,18 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f } attached.add(tabId); + const state = networkCaptures.get(tabId); + if (captureIntents.has(tabId) && !state?.armed) { + try { + // armCapture() re-enables Runtime/Network and reinstalls listeners, so + // the generic Runtime.enable call below is intentionally skipped here. + await armCapture(tabId); + } catch (error) { + console.warn(`[opencli] failed to rearm capture for tab ${tabId}: ${error}`); + } + return; + } + try { await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); } catch { @@ -292,6 +333,57 @@ function normalizeHeaders(headers: unknown): Record { return out; } +function resetNetworkBuffers(state: NetworkCaptureState): void { + state.entries = []; + state.requestToIndex.clear(); +} + +function resetConsoleBuffers(state: NetworkCaptureState): void { + state.consoleErrors = []; + state.consoleOther = []; +} + +function normalizeConsoleLevel(type: string): ConsoleMessage['level'] | null { + switch (type) { + case 'warning': + return 'warn'; + case 'verbose': + return 'debug'; + case 'log': + case 'warn': + case 'error': + case 'info': + case 'debug': + return type; + default: + return null; + } +} + +function pushConsoleMessage(state: NetworkCaptureState, message: ConsoleMessage): void { + const bucket = message.level === 'error' || message.level === 'warn' + ? state.consoleErrors + : state.consoleOther; + bucket.push(message); + const limit = bucket === state.consoleErrors ? 200 : 300; + if (bucket.length > limit) bucket.shift(); +} + +async function armCapture(tabId: number): Promise { + const patterns = captureIntents.get(tabId)?.patterns ?? networkCaptures.get(tabId)?.patterns ?? []; + await chrome.debugger.sendCommand({ tabId }, 'Network.enable'); + await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); + const existing = networkCaptures.get(tabId); + networkCaptures.set(tabId, { + patterns, + entries: existing?.entries ?? [], + requestToIndex: existing?.requestToIndex ?? new Map(), + consoleErrors: existing?.consoleErrors ?? [], + consoleOther: existing?.consoleOther ?? [], + armed: true, + }); +} + function getOrCreateNetworkCaptureEntry(tabId: number, requestId: string, fallback?: { url?: string; method?: string; @@ -321,17 +413,36 @@ export async function startNetworkCapture( tabId: number, pattern?: string, ): Promise { + const patterns = normalizeCapturePatterns(pattern); + captureIntents.set(tabId, { patterns }); + + const existing = networkCaptures.get(tabId); + if (existing) { + existing.patterns = patterns; + resetNetworkBuffers(existing); + } + await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, 'Network.enable'); - networkCaptures.set(tabId, { - patterns: normalizeCapturePatterns(pattern), - entries: [], - requestToIndex: new Map(), - }); + + const state = networkCaptures.get(tabId); + if (state?.armed) return; + await armCapture(tabId); + resetNetworkBuffers(networkCaptures.get(tabId)!); } -export async function readNetworkCapture(tabId: number): Promise { +async function ensureCaptureReadable(tabId: number): Promise { const state = networkCaptures.get(tabId); + if (!captureIntents.has(tabId) || state?.armed) return state; + await ensureAttached(tabId); + const refreshed = networkCaptures.get(tabId); + if (captureIntents.has(tabId) && !refreshed?.armed) { + throw new Error(`capture rearm failed for tab ${tabId}`); + } + return refreshed; +} + +export async function readNetworkCapture(tabId: number): Promise { + const state = await ensureCaptureReadable(tabId); if (!state) return []; const entries = state.entries.slice(); state.entries = []; @@ -339,22 +450,60 @@ export async function readNetworkCapture(tabId: number): Promise { + const state = await ensureCaptureReadable(tabId); + if (!state) return []; + // Console capture is intentionally non-draining so callers can inspect the + // full rolling session log, unlike readNetworkCapture(), which drains new + // network entries after each read. + return [...state.consoleErrors, ...state.consoleOther] + .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)); +} + +export async function stopCapture(tabId: number): Promise { + captureIntents.delete(tabId); + networkCaptures.delete(tabId); +} + export async function detach(tabId: number): Promise { + const state = networkCaptures.get(tabId); + const preserveCapture = captureIntents.has(tabId); + if (state) { + state.armed = false; + state.requestToIndex.clear(); + if (!preserveCapture) { + resetNetworkBuffers(state); + resetConsoleBuffers(state); + networkCaptures.delete(tabId); + } + } if (!attached.has(tabId)) return; attached.delete(tabId); - networkCaptures.delete(tabId); try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ } } +export function hasCaptureIntent(tabId: number): boolean { + return captureIntents.has(tabId); +} + export function registerListeners(): void { chrome.tabs.onRemoved.addListener((tabId) => { attached.delete(tabId); + captureIntents.delete(tabId); networkCaptures.delete(tabId); }); chrome.debugger.onDetach.addListener((source) => { if (source.tabId) { attached.delete(source.tabId); - networkCaptures.delete(source.tabId); + const state = networkCaptures.get(source.tabId); + if (!state) return; + state.armed = false; + state.requestToIndex.clear(); + if (!captureIntents.has(source.tabId)) { + resetNetworkBuffers(state); + resetConsoleBuffers(state); + networkCaptures.delete(source.tabId); + } } }); // Invalidate attached cache when tab URL changes to non-debuggable @@ -367,7 +516,7 @@ export function registerListeners(): void { const tabId = source.tabId; if (!tabId) return; const state = networkCaptures.get(tabId); - if (!state) return; + if (!state?.armed) return; if (method === 'Network.requestWillBeSent') { const requestId = String(params?.requestId || ''); @@ -435,6 +584,42 @@ export function registerListeners(): void { } catch { // Optional; bodies are unavailable for some requests (e.g. uploads). } + return; + } + + if (method === 'Runtime.consoleAPICalled') { + const consoleParams = params as { + type?: string; + args?: Array<{ value?: unknown; description?: string }>; + timestamp?: number; + }; + const level = normalizeConsoleLevel(String(consoleParams.type || '')); + if (!level) return; + const text = (consoleParams.args || []) + .map((arg) => arg.value !== undefined ? String(arg.value) : String(arg.description || '')) + .join(' '); + pushConsoleMessage(state, { + level, + text, + timestamp: consoleParams.timestamp, + source: 'console-api', + }); + return; + } + + if (method === 'Runtime.exceptionThrown') { + const exceptionParams = params as { + timestamp?: number; + exceptionDetails?: { exception?: { description?: string }; text?: string }; + }; + pushConsoleMessage(state, { + level: 'error', + text: exceptionParams.exceptionDetails?.exception?.description + || exceptionParams.exceptionDetails?.text + || 'Unknown exception', + timestamp: exceptionParams.timestamp, + source: 'exception', + }); } }); } diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 3ed5ce86c..53c4e4664 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -18,6 +18,8 @@ export type Action = | 'bind-current' | 'network-capture-start' | 'network-capture-read' + | 'console-read' + | 'capture-stop' | 'cdp'; export interface Command { diff --git a/src/browser/base-page.ts b/src/browser/base-page.ts index 4ed0d64fa..441acd29b 100644 --- a/src/browser/base-page.ts +++ b/src/browser/base-page.ts @@ -9,7 +9,7 @@ * getCookies, screenshot, tabs, etc. */ -import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js'; +import type { BrowserCookie, ConsoleMessage, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js'; import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js'; import { clickJs, @@ -98,10 +98,18 @@ export abstract class BasePage implements IPage { return Array.isArray(result) ? result : []; } - async consoleMessages(_level: string = 'info'): Promise { + async consoleMessages(_level?: string): Promise { return []; } + async startNetworkCapture(_pattern?: string): Promise {} + + async readNetworkCapture(): Promise { + return []; + } + + async stopCapture(): Promise {} + async wait(options: number | WaitOptions): Promise { if (typeof options === 'number') { if (options >= 1) { diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index 480f32aee..76aa1b94a 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -64,3 +64,177 @@ describe('CDPBridge cookies', () => { ]); }); }); + +describe('CDPBridge capture', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns warn entries from consoleMessages("error")', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); + + const bridge = new CDPBridge(); + vi.spyOn(bridge, 'send').mockResolvedValue({}); + + const page = await bridge.connect() as any; + page._consoleErrors = [ + { level: 'warn', text: 'warning', timestamp: 1 }, + { level: 'error', text: 'boom', timestamp: 2 }, + ]; + page._consoleCapturing = true; + + await expect(page.consoleMessages('error')).resolves.toEqual([ + { level: 'warn', text: 'warning', timestamp: 1 }, + { level: 'error', text: 'boom', timestamp: 2 }, + ]); + }); + + it('stopCapture prevents later network events from appending', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); + + const bridge = new CDPBridge(); + vi.spyOn(bridge, 'send').mockResolvedValue({}); + + const page = await bridge.connect() as any; + await page.startNetworkCapture('/api/'); + await page.stopCapture(); + + page._onRequestWillBeSent({ + requestId: '1', + request: { url: 'https://x.test/api/items', method: 'GET' }, + timestamp: 1, + }); + + await expect(page.readNetworkCapture()).resolves.toEqual([]); + }); + + it('does not register duplicate listeners across restart cycles', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); + + const bridge = new CDPBridge(); + vi.spyOn(bridge, 'send').mockResolvedValue({}); + const onSpy = vi.spyOn(bridge, 'on'); + + const page = await bridge.connect() as any; + await page.startNetworkCapture('/api/'); + await page.stopCapture(); + await page.startNetworkCapture('/api/'); + + expect(onSpy.mock.calls.filter(([event]) => event === 'Network.requestWillBeSent')).toHaveLength(1); + expect(onSpy.mock.calls.filter(([event]) => event === 'Network.responseReceived')).toHaveLength(1); + expect(onSpy.mock.calls.filter(([event]) => event === 'Network.loadingFinished')).toHaveLength(1); + expect(onSpy.mock.calls.filter(([event]) => event === 'Runtime.consoleAPICalled')).toHaveLength(1); + expect(onSpy.mock.calls.filter(([event]) => event === 'Runtime.exceptionThrown')).toHaveLength(1); + }); + + it('ignores stale response bodies from a previous capture generation', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); + + let resolveBody: ((value: unknown) => void) | undefined; + const bridge = new CDPBridge(); + vi.spyOn(bridge, 'send').mockImplementation(async (method: string) => { + if (method === 'Network.getResponseBody') { + return await new Promise((resolve) => { + resolveBody = resolve; + }); + } + return {}; + }); + + const page = await bridge.connect() as any; + await page.startNetworkCapture('/api/'); + page._onRequestWillBeSent({ + requestId: 'old', + request: { url: 'https://x.test/api/old', method: 'GET' }, + timestamp: 1, + }); + page._onLoadingFinished({ requestId: 'old' }); + + await page.stopCapture(); + await page.startNetworkCapture('/api/'); + page._onRequestWillBeSent({ + requestId: 'new', + request: { url: 'https://x.test/api/new', method: 'GET' }, + timestamp: 2, + }); + + resolveBody?.({ body: '{"stale":true}', base64Encoded: false }); + await Promise.resolve(); + + await expect(page.readNetworkCapture()).resolves.toEqual([ + { url: 'https://x.test/api/new', method: 'GET', timestamp: 2 }, + ]); + }); + + it('does not clear console history when restarting network capture', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); + + const bridge = new CDPBridge(); + vi.spyOn(bridge, 'send').mockResolvedValue({}); + + const page = await bridge.connect() as any; + page._consoleErrors = [ + { level: 'error', text: 'boom', timestamp: 1 }, + ]; + page._consoleCapturing = true; + + await page.startNetworkCapture('/api/'); + + await expect(page.consoleMessages('error')).resolves.toEqual([ + { level: 'error', text: 'boom', timestamp: 1 }, + ]); + }); + + it('matches pipe-delimited capture patterns like the daemon path', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); + + const bridge = new CDPBridge(); + vi.spyOn(bridge, 'send').mockResolvedValue({}); + + const page = await bridge.connect() as any; + await page.startNetworkCapture('/foo|/bar'); + + page._onRequestWillBeSent({ + requestId: 'bar', + request: { url: 'https://x.test/api/bar', method: 'GET' }, + timestamp: 1, + }); + page._onRequestWillBeSent({ + requestId: 'baz', + request: { url: 'https://x.test/api/baz', method: 'GET' }, + timestamp: 2, + }); + + await expect(page.readNetworkCapture()).resolves.toEqual([ + { url: 'https://x.test/api/bar', method: 'GET', timestamp: 1 }, + ]); + }); + + it('drops drained in-flight request bookkeeping after readNetworkCapture', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); + + const bridge = new CDPBridge(); + vi.spyOn(bridge, 'send').mockResolvedValue({}); + + const page = await bridge.connect() as any; + await page.startNetworkCapture('/api/'); + + page._onRequestWillBeSent({ + requestId: 'drained', + request: { url: 'https://x.test/api/items', method: 'GET' }, + timestamp: 1, + }); + + await expect(page.readNetworkCapture()).resolves.toEqual([ + { url: 'https://x.test/api/items', method: 'GET', timestamp: 1 }, + ]); + + expect(() => { + page._onResponseReceived({ + requestId: 'drained', + response: { status: 200, mimeType: 'application/json' }, + }); + page._onLoadingFinished({ requestId: 'drained' }); + }).not.toThrow(); + }); +}); diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 52e8c6ef4..fe3f25561 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -11,7 +11,7 @@ import { WebSocket, type RawData } from 'ws'; import { request as httpRequest } from 'node:http'; import { request as httpsRequest } from 'node:https'; -import type { BrowserCookie, IPage, ScreenshotOptions } from '../types.js'; +import type { BrowserCookie, ConsoleMessage, IPage, ScreenshotOptions } from '../types.js'; import type { IBrowserFactory } from '../runtime.js'; import { wrapForEval } from './utils.js'; import { generateStealthJs } from './stealth.js'; @@ -40,6 +40,13 @@ interface RuntimeEvaluateResult { const CDP_SEND_TIMEOUT = 30_000; +function parseCapturePattern(pattern: string): string[] { + return pattern + .split('|') + .map((entry) => entry.trim()) + .filter(Boolean); +} + export class CDPBridge implements IBrowserFactory { private _ws: WebSocket | null = null; private _idCounter = 0; @@ -169,14 +176,90 @@ class CDPPage extends BasePage { // Network capture state (mirrors extension/src/cdp.ts NetworkCaptureEntry shape) private _networkCapturing = false; private _networkCapturePattern = ''; + private _networkCaptureFilters: string[] = []; private _networkEntries: Array<{ url: string; method: string; responseStatus?: number; responseContentType?: string; responsePreview?: string; timestamp: number; }> = []; private _pendingRequests = new Map(); // requestId → index in _networkEntries private _pendingBodyFetches: Set> = new Set(); // track in-flight getResponseBody calls - private _consoleMessages: Array<{ type: string; text: string; timestamp: number }> = []; + private _consoleErrors: ConsoleMessage[] = []; + private _consoleOther: ConsoleMessage[] = []; private _consoleCapturing = false; + private _networkListenersInstalled = false; + private _consoleListenersInstalled = false; + private _captureGeneration = 0; + + private _onRequestWillBeSent = (params: unknown): void => { + if (!this._networkCapturing) return; + const p = params as { requestId: string; request: { method: string; url: string }; timestamp: number }; + if (this._networkCaptureFilters.length > 0 + && !this._networkCaptureFilters.some((filter) => p.request.url.includes(filter))) return; + const idx = this._networkEntries.push({ + url: p.request.url, + method: p.request.method, + timestamp: p.timestamp, + }) - 1; + this._pendingRequests.set(p.requestId, idx); + }; + + private _onResponseReceived = (params: unknown): void => { + if (!this._networkCapturing) return; + const p = params as { requestId: string; response: { status: number; mimeType?: string } }; + const idx = this._pendingRequests.get(p.requestId); + if (idx === undefined) return; + this._networkEntries[idx].responseStatus = p.response.status; + this._networkEntries[idx].responseContentType = p.response.mimeType || ''; + }; + + private _onLoadingFinished = (params: unknown): void => { + if (!this._networkCapturing) return; + const p = params as { requestId: string }; + const idx = this._pendingRequests.get(p.requestId); + if (idx === undefined) return; + const captureGeneration = this._captureGeneration; + const bodyFetch = this.bridge.send('Network.getResponseBody', { requestId: p.requestId }).then((result: unknown) => { + const r = result as { body?: string; base64Encoded?: boolean } | undefined; + if (captureGeneration !== this._captureGeneration || !this._networkCapturing) return; + if (typeof r?.body === 'string' && this._networkEntries[idx]) { + this._networkEntries[idx].responsePreview = r.base64Encoded + ? `base64:${r.body.slice(0, 4000)}` + : r.body.slice(0, 4000); + } + }).catch(() => { + // Body unavailable for some requests, this is best-effort only. + }).finally(() => { + this._pendingBodyFetches.delete(bodyFetch); + }); + this._pendingBodyFetches.add(bodyFetch); + this._pendingRequests.delete(p.requestId); + }; + + private _onConsoleAPI = (params: unknown): void => { + if (!this._consoleCapturing) return; + const p = params as { type: string; args: Array<{ value?: unknown; description?: string }>; timestamp: number }; + const level = normalizeConsoleLevel(p.type); + if (!level) return; + const text = (p.args || []).map(a => a.value !== undefined ? String(a.value) : (a.description || '')).join(' '); + this._pushConsoleMessage({ + level, + text, + timestamp: p.timestamp, + source: 'console-api', + }); + }; + + private _onException = (params: unknown): void => { + if (!this._consoleCapturing) return; + const p = params as { timestamp: number; exceptionDetails?: { exception?: { description?: string }; text?: string } }; + const desc = p.exceptionDetails?.exception?.description || p.exceptionDetails?.text || 'Unknown exception'; + this._pushConsoleMessage({ + level: 'error', + text: desc, + timestamp: p.timestamp, + source: 'exception', + }); + }; constructor(private bridge: CDPBridge) { super(); @@ -233,61 +316,21 @@ class CDPPage extends BasePage { } async startNetworkCapture(pattern: string = ''): Promise { + this._captureGeneration += 1; this._networkCapturePattern = pattern; + this._networkCaptureFilters = parseCapturePattern(pattern); this._networkEntries = []; this._pendingRequests.clear(); this._pendingBodyFetches.clear(); - - if (!this._networkCapturing) { - await this.bridge.send('Network.enable'); - - // Step 1: Record request method/url on requestWillBeSent - this.bridge.on('Network.requestWillBeSent', (params: unknown) => { - const p = params as { requestId: string; request: { method: string; url: string }; timestamp: number }; - if (!pattern || p.request.url.includes(pattern)) { - const idx = this._networkEntries.push({ - url: p.request.url, - method: p.request.method, - timestamp: p.timestamp, - }) - 1; - this._pendingRequests.set(p.requestId, idx); - } - }); - - // Step 2: Fill in response metadata on responseReceived - this.bridge.on('Network.responseReceived', (params: unknown) => { - const p = params as { requestId: string; response: { status: number; mimeType?: string } }; - const idx = this._pendingRequests.get(p.requestId); - if (idx !== undefined) { - this._networkEntries[idx].responseStatus = p.response.status; - this._networkEntries[idx].responseContentType = p.response.mimeType || ''; - } - }); - - // Step 3: Fetch body on loadingFinished (body is only reliably available after this) - this.bridge.on('Network.loadingFinished', (params: unknown) => { - const p = params as { requestId: string }; - const idx = this._pendingRequests.get(p.requestId); - if (idx !== undefined) { - const bodyFetch = this.bridge.send('Network.getResponseBody', { requestId: p.requestId }).then((result: unknown) => { - const r = result as { body?: string; base64Encoded?: boolean } | undefined; - if (typeof r?.body === 'string') { - this._networkEntries[idx].responsePreview = r.base64Encoded - ? `base64:${r.body.slice(0, 4000)}` - : r.body.slice(0, 4000); - } - }).catch(() => { - // Body unavailable for some requests (e.g. uploads) — non-fatal - }).finally(() => { - this._pendingBodyFetches.delete(bodyFetch); - }); - this._pendingBodyFetches.add(bodyFetch); - this._pendingRequests.delete(p.requestId); - } - }); - - this._networkCapturing = true; + await this.ensureConsoleCapture(); + await this.bridge.send('Network.enable'); + if (!this._networkListenersInstalled) { + this.bridge.on('Network.requestWillBeSent', this._onRequestWillBeSent); + this.bridge.on('Network.responseReceived', this._onResponseReceived); + this.bridge.on('Network.loadingFinished', this._onLoadingFinished); + this._networkListenersInstalled = true; } + this._networkCapturing = true; } async readNetworkCapture(): Promise { @@ -297,31 +340,51 @@ class CDPPage extends BasePage { } const entries = [...this._networkEntries]; this._networkEntries = []; + this._pendingRequests.clear(); return entries; } - async consoleMessages(level: string = 'all'): Promise> { - if (!this._consoleCapturing) { - await this.bridge.send('Runtime.enable'); - this.bridge.on('Runtime.consoleAPICalled', (params: unknown) => { - const p = params as { type: string; args: Array<{ value?: unknown; description?: string }>; timestamp: number }; - const text = (p.args || []).map(a => a.value !== undefined ? String(a.value) : (a.description || '')).join(' '); - this._consoleMessages.push({ type: p.type, text, timestamp: p.timestamp }); - if (this._consoleMessages.length > 500) this._consoleMessages.shift(); - }); - // Capture uncaught exceptions as error-level messages - this.bridge.on('Runtime.exceptionThrown', (params: unknown) => { - const p = params as { timestamp: number; exceptionDetails?: { exception?: { description?: string }; text?: string } }; - const desc = p.exceptionDetails?.exception?.description || p.exceptionDetails?.text || 'Unknown exception'; - this._consoleMessages.push({ type: 'error', text: desc, timestamp: p.timestamp }); - if (this._consoleMessages.length > 500) this._consoleMessages.shift(); - }); - this._consoleCapturing = true; + async stopCapture(): Promise { + this._networkCapturing = false; + this._consoleCapturing = false; + this._networkCapturePattern = ''; + this._networkCaptureFilters = []; + this._networkEntries = []; + this._pendingRequests.clear(); + this._pendingBodyFetches.clear(); + this._consoleErrors = []; + this._consoleOther = []; + } + + async consoleMessages(level: string = 'all'): Promise { + await this.ensureConsoleCapture(); + const allMessages = this.getConsoleMessages(); + if (level === 'all') return allMessages; + if (level === 'error') return allMessages.filter(m => m.level === 'error' || m.level === 'warn'); + return allMessages.filter(m => m.level === level); + } + + private async ensureConsoleCapture(): Promise { + await this.bridge.send('Runtime.enable'); + if (!this._consoleListenersInstalled) { + this.bridge.on('Runtime.consoleAPICalled', this._onConsoleAPI); + this.bridge.on('Runtime.exceptionThrown', this._onException); + this._consoleListenersInstalled = true; } - if (level === 'all') return [...this._consoleMessages]; - // 'error' level includes both console.error() and uncaught exceptions - if (level === 'error') return this._consoleMessages.filter(m => m.type === 'error' || m.type === 'warning'); - return this._consoleMessages.filter(m => m.type === level); + this._consoleCapturing = true; + } + + private _pushConsoleMessage(message: ConsoleMessage): void { + const bucket = message.level === 'error' || message.level === 'warn' + ? this._consoleErrors + : this._consoleOther; + bucket.push(message); + const limit = bucket === this._consoleErrors ? 200 : 300; + if (bucket.length > limit) bucket.shift(); + } + + private getConsoleMessages(): ConsoleMessage[] { + return [...this._consoleErrors, ...this._consoleOther].sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)); } async tabs(): Promise { @@ -333,6 +396,23 @@ class CDPPage extends BasePage { } } +function normalizeConsoleLevel(type: string): ConsoleMessage['level'] | null { + switch (type) { + case 'warning': + return 'warn'; + case 'verbose': + return 'debug'; + case 'log': + case 'warn': + case 'error': + case 'info': + case 'debug': + return type; + default: + return null; + } +} + function isCookie(value: unknown): value is BrowserCookie { return isRecord(value) && typeof value.name === 'string' diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 002850dc0..c29021f48 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -21,7 +21,7 @@ function generateId(): string { export interface DaemonCommand { id: string; - action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp'; + action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'console-read' | 'capture-stop' | 'cdp'; tabId?: number; code?: string; workspace?: string; diff --git a/src/browser/dom-helpers.test.ts b/src/browser/dom-helpers.test.ts index e14a2c247..38d1692c1 100644 --- a/src/browser/dom-helpers.test.ts +++ b/src/browser/dom-helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { autoScrollJs, waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js'; +import { autoScrollJs, networkRequestsJs, waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js'; describe('autoScrollJs', () => { it('returns early without error when document.body is null', async () => { @@ -112,3 +112,47 @@ describe('waitForSelectorJs', () => { delete g.MutationObserver; }); }); + +describe('networkRequestsJs', () => { + it('includes the main document navigation in the fallback request list', () => { + const g = globalThis as any; + const origPerformance = g.performance; + const origDocument = g.document; + + g.performance = { + getEntriesByType: (type: string) => { + if (type === 'navigation') { + return [{ + name: 'https://example.com/', + duration: 123.4, + transferSize: 321, + encodedBodySize: 300, + responseStatus: 200, + }]; + } + if (type === 'resource') return []; + return []; + }, + }; + g.document = { contentType: 'text/html' }; + + const requests = eval(networkRequestsJs(false)) as Array>; + + expect(requests).toEqual([ + { + url: 'https://example.com/', + method: 'GET', + type: 'navigation', + duration: 123, + size: 321, + status: 200, + responseStatus: 200, + ct: 'text/html', + responseContentType: 'text/html', + }, + ]); + + g.performance = origPerformance; + g.document = origDocument; + }); +}); diff --git a/src/browser/dom-helpers.ts b/src/browser/dom-helpers.ts index bc5342ef4..3f02414fa 100644 --- a/src/browser/dom-helpers.ts +++ b/src/browser/dom-helpers.ts @@ -142,19 +142,36 @@ export function autoScrollJs(times: number, delayMs: number): string { `; } -/** Generate JS to read performance resource entries as network requests */ +/** Generate JS to read performance timing entries as fallback network requests */ export function networkRequestsJs(includeStatic: boolean): string { return ` (() => { + const navigationEntries = performance.getEntriesByType('navigation'); + const navigation = navigationEntries[0]; + const requests = []; + if (navigation?.name && /^https?:\\/\\//.test(navigation.name)) { + requests.push({ + url: navigation.name, + method: 'GET', + type: 'navigation', + duration: Math.round(navigation.duration || 0), + size: navigation.transferSize || navigation.encodedBodySize || 0, + status: typeof navigation.responseStatus === 'number' ? navigation.responseStatus : 0, + responseStatus: typeof navigation.responseStatus === 'number' ? navigation.responseStatus : 0, + ct: typeof document.contentType === 'string' ? document.contentType : '', + responseContentType: typeof document.contentType === 'string' ? document.contentType : '', + }); + } const entries = performance.getEntriesByType('resource'); - return entries + return requests.concat(entries ${includeStatic ? '' : '.filter(e => !["img", "font", "css", "script"].some(t => e.initiatorType === t))'} .map(e => ({ url: e.name, + method: 'GET', type: e.initiatorType, duration: Math.round(e.duration), size: e.transferSize || 0, - })); + }))); })() `; } diff --git a/src/browser/page.test.ts b/src/browser/page.test.ts index 61b76adc6..7f8bbd7de 100644 --- a/src/browser/page.test.ts +++ b/src/browser/page.test.ts @@ -4,15 +4,34 @@ const { sendCommandMock } = vi.hoisted(() => ({ sendCommandMock: vi.fn(), })); +const { + loadWorkspaceTabIdMock, + saveWorkspaceTabIdMock, + clearWorkspaceTabIdMock, +} = vi.hoisted(() => ({ + loadWorkspaceTabIdMock: vi.fn(), + saveWorkspaceTabIdMock: vi.fn(), + clearWorkspaceTabIdMock: vi.fn(), +})); + vi.mock('./daemon-client.js', () => ({ sendCommand: sendCommandMock, })); +vi.mock('./workspace-tab-cache.js', () => ({ + loadWorkspaceTabId: loadWorkspaceTabIdMock, + saveWorkspaceTabId: saveWorkspaceTabIdMock, + clearWorkspaceTabId: clearWorkspaceTabIdMock, +})); + import { Page } from './page.js'; describe('Page.getCurrentUrl', () => { beforeEach(() => { sendCommandMock.mockReset(); + loadWorkspaceTabIdMock.mockReset().mockReturnValue(undefined); + saveWorkspaceTabIdMock.mockReset(); + clearWorkspaceTabIdMock.mockReset(); }); it('reads the real browser URL when no local navigation cache exists', async () => { @@ -37,11 +56,28 @@ describe('Page.getCurrentUrl', () => { expect(sendCommandMock).toHaveBeenCalledTimes(1); }); + + it('reuses the cached workspace tab id for later commands', async () => { + loadWorkspaceTabIdMock.mockReturnValueOnce(42); + sendCommandMock.mockResolvedValueOnce('https://example.com/'); + + const page = new Page('operate:default'); + const url = await page.getCurrentUrl(); + + expect(url).toBe('https://example.com/'); + expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({ + workspace: 'operate:default', + tabId: 42, + })); + }); }); describe('Page.evaluate', () => { beforeEach(() => { sendCommandMock.mockReset(); + loadWorkspaceTabIdMock.mockReset().mockReturnValue(undefined); + saveWorkspaceTabIdMock.mockReset(); + clearWorkspaceTabIdMock.mockReset(); }); it('retries once when the inspected target navigated during exec', async () => { @@ -56,3 +92,71 @@ describe('Page.evaluate', () => { expect(sendCommandMock).toHaveBeenCalledTimes(2); }); }); + +describe('Page.consoleMessages', () => { + beforeEach(() => { + sendCommandMock.mockReset(); + loadWorkspaceTabIdMock.mockReset().mockReturnValue(undefined); + saveWorkspaceTabIdMock.mockReset(); + clearWorkspaceTabIdMock.mockReset(); + }); + + it('filters daemon console messages locally and keeps warn in error mode', async () => { + sendCommandMock.mockResolvedValueOnce([ + { level: 'warn', text: 'careful' }, + { level: 'error', text: 'boom' }, + { level: 'info', text: 'hello' }, + ]); + + const page = new Page('site:test'); + + await expect(page.consoleMessages('error')).resolves.toEqual([ + { level: 'warn', text: 'careful' }, + { level: 'error', text: 'boom' }, + ]); + expect(sendCommandMock).toHaveBeenCalledWith('console-read', expect.objectContaining({ + workspace: 'site:test', + })); + }); + + it('sends capture-stop to the daemon', async () => { + sendCommandMock.mockResolvedValueOnce({ stopped: true }); + + const page = new Page('site:test'); + await page.stopCapture(); + + expect(sendCommandMock).toHaveBeenCalledWith('capture-stop', expect.objectContaining({ + workspace: 'site:test', + })); + }); + + it('gracefully tolerates unsupported capture actions from a stale extension', async () => { + sendCommandMock + .mockRejectedValueOnce(new Error('Unknown action: network-capture-start')) + .mockRejectedValueOnce(new Error('Unknown action: network-capture-read')) + .mockResolvedValueOnce([{ url: 'https://fallback.test', method: 'GET' }]) + .mockRejectedValueOnce(new Error('Unknown action: console-read')) + .mockRejectedValueOnce(new Error('Unknown action: capture-stop')); + + const page = new Page('site:test'); + + await expect(page.startNetworkCapture('/api/')).resolves.toBeUndefined(); + await expect(page.readNetworkCapture()).resolves.toEqual([{ url: 'https://fallback.test', method: 'GET' }]); + await expect(page.consoleMessages('error')).resolves.toEqual([]); + await expect(page.stopCapture()).resolves.toBeUndefined(); + expect(page.hasNativeCaptureSupport()).toBe(false); + }); + + it('persists the resolved tab after navigation and clears it when the window closes', async () => { + sendCommandMock + .mockResolvedValueOnce({ tabId: 99 }) + .mockResolvedValueOnce(null); + + const page = new Page('operate:default'); + await page.goto('https://example.com', { waitUntil: 'none' }); + await page.closeWindow(); + + expect(saveWorkspaceTabIdMock).toHaveBeenCalledWith('operate:default', 99); + expect(clearWorkspaceTabIdMock).toHaveBeenCalledWith('operate:default'); + }); +}); diff --git a/src/browser/page.ts b/src/browser/page.ts index e97f69553..1bcbeba34 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -10,13 +10,14 @@ * chrome-extension:// tab that can't be debugged. */ -import type { BrowserCookie, ScreenshotOptions } from '../types.js'; +import type { BrowserCookie, ConsoleMessage, ScreenshotOptions } from '../types.js'; import { sendCommand } from './daemon-client.js'; import { wrapForEval } from './utils.js'; import { saveBase64ToFile } from '../utils.js'; import { generateStealthJs } from './stealth.js'; import { waitForDomStableJs } from './dom-helpers.js'; import { BasePage } from './base-page.js'; +import { clearWorkspaceTabId, loadWorkspaceTabId, saveWorkspaceTabId } from './workspace-tab-cache.js'; export function isRetryableSettleError(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err); @@ -24,12 +25,23 @@ export function isRetryableSettleError(err: unknown): boolean { || (message.includes('-32000') && message.toLowerCase().includes('target')); } +function isUnsupportedCaptureError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return message.includes('Unknown action') + || message.includes('network-capture') + || message.includes('console-read') + || message.includes('capture-stop'); +} + /** * Page — implements IPage by talking to the daemon via HTTP. */ export class Page extends BasePage { + private _nativeCaptureSupported: boolean | undefined; + constructor(private readonly workspace: string = 'default') { super(); + this._tabId = loadWorkspaceTabId(workspace); } /** Active tab ID, set after navigate and used in all subsequent commands */ @@ -56,6 +68,7 @@ export class Page extends BasePage { // Remember the tabId and URL for subsequent calls if (result?.tabId) { this._tabId = result.tabId; + saveWorkspaceTabId(this.workspace, result.tabId); } this._lastUrl = url; // Inject stealth + settle in a single round-trip instead of two sequential exec calls. @@ -123,6 +136,7 @@ export class Page extends BasePage { } finally { this._tabId = undefined; this._lastUrl = null; + clearWorkspaceTabId(this.workspace); } } @@ -133,7 +147,10 @@ export class Page extends BasePage { async selectTab(index: number): Promise { const result = await sendCommand('tabs', { op: 'select', index, ...this._wsOpt() }) as { selected?: number }; - if (result?.selected) this._tabId = result.selected; + if (result?.selected) { + this._tabId = result.selected; + saveWorkspaceTabId(this.workspace, result.selected); + } } /** @@ -155,18 +172,65 @@ export class Page extends BasePage { } async startNetworkCapture(pattern: string = ''): Promise { - await sendCommand('network-capture-start', { - pattern, - ...this._cmdOpts(), - }); + try { + await sendCommand('network-capture-start', { + pattern, + ...this._cmdOpts(), + }); + this._nativeCaptureSupported = true; + } catch (err) { + if (!isUnsupportedCaptureError(err)) throw err; + this._nativeCaptureSupported = false; + } } async readNetworkCapture(): Promise { - const result = await sendCommand('network-capture-read', { - ...this._cmdOpts(), - }); - return Array.isArray(result) ? result : []; + try { + const result = await sendCommand('network-capture-read', { + ...this._cmdOpts(), + }); + this._nativeCaptureSupported = true; + return Array.isArray(result) ? result : []; + } catch (err) { + if (!isUnsupportedCaptureError(err)) throw err; + this._nativeCaptureSupported = false; + return this.networkRequests(false); + } + } + + async stopCapture(): Promise { + try { + await sendCommand('capture-stop', { + ...this._cmdOpts(), + }); + this._nativeCaptureSupported = true; + } catch (err) { + if (!isUnsupportedCaptureError(err)) throw err; + this._nativeCaptureSupported = false; + } + } + + async consoleMessages(level: string = 'all'): Promise { + let messages: ConsoleMessage[] = []; + try { + const result = await sendCommand('console-read', { + ...this._cmdOpts(), + }); + this._nativeCaptureSupported = true; + messages = Array.isArray(result) ? result as ConsoleMessage[] : []; + } catch (err) { + if (!isUnsupportedCaptureError(err)) throw err; + this._nativeCaptureSupported = false; + } + if (level === 'all') return messages; + if (level === 'error') return messages.filter((message) => message.level === 'error' || message.level === 'warn'); + return messages.filter((message) => message.level === level); + } + + hasNativeCaptureSupport(): boolean | undefined { + return this._nativeCaptureSupported; } + /** * Set local file paths on a file input element via CDP DOM.setFileInputFiles. * Chrome reads the files directly from the local filesystem, avoiding the diff --git a/src/browser/workspace-tab-cache.ts b/src/browser/workspace-tab-cache.ts new file mode 100644 index 000000000..3edb09b80 --- /dev/null +++ b/src/browser/workspace-tab-cache.ts @@ -0,0 +1,41 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const CACHE_FILE = path.join(os.homedir(), '.opencli', 'workspace-tabs.json'); + +type WorkspaceTabState = Record; + +function readCache(): WorkspaceTabState { + try { + return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8')) as WorkspaceTabState; + } catch { + return {}; + } +} + +function writeCache(cache: WorkspaceTabState): void { + const cacheDir = path.dirname(CACHE_FILE); + const tempFile = path.join(cacheDir, `workspace-tabs.${process.pid}.${Date.now()}.tmp`); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(tempFile, JSON.stringify(cache, null, 2) + '\n', 'utf-8'); + fs.renameSync(tempFile, CACHE_FILE); +} + +export function loadWorkspaceTabId(workspace: string): number | undefined { + const tabId = readCache()[workspace]; + return typeof tabId === 'number' ? tabId : undefined; +} + +export function saveWorkspaceTabId(workspace: string, tabId: number): void { + const cache = readCache(); + cache[workspace] = tabId; + writeCache(cache); +} + +export function clearWorkspaceTabId(workspace: string): void { + const cache = readCache(); + if (!(workspace in cache)) return; + delete cache[workspace]; + writeCache(cache); +} diff --git a/src/cli.test.ts b/src/cli.test.ts index f4393b2db..981561d73 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -13,6 +13,7 @@ const { mockRenderCascadeResult, mockGetBrowserFactory, mockBrowserSession, + mockBridgeConnect, } = vi.hoisted(() => ({ mockExploreUrl: vi.fn(), mockRenderExploreSummary: vi.fn(), @@ -24,6 +25,7 @@ const { mockRenderCascadeResult: vi.fn(), mockGetBrowserFactory: vi.fn(() => ({ name: 'BrowserFactory' })), mockBrowserSession: vi.fn(), + mockBridgeConnect: vi.fn(), })); vi.mock('./explore.js', () => ({ @@ -51,6 +53,12 @@ vi.mock('./runtime.js', () => ({ browserSession: mockBrowserSession, })); +vi.mock('./browser/index.js', () => ({ + BrowserBridge: class { + connect = mockBridgeConnect; + }, +})); + import { createProgram, findPackageRoot, resolveOperateVerifyInvocation } from './cli.js'; describe('built-in browser commands verbose wiring', () => { @@ -226,3 +234,63 @@ describe('findPackageRoot', () => { expect(findPackageRoot(cliFile, (candidate) => exists.has(candidate))).toBe(packageRoot); }); }); + +describe('operate capture compatibility', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + beforeEach(() => { + process.exitCode = undefined; + mockGetBrowserFactory.mockClear(); + mockBrowserSession.mockReset(); + mockBridgeConnect.mockReset(); + consoleLogSpy.mockClear(); + consoleErrorSpy.mockClear(); + }); + + it('installs the legacy interceptor after open when native capture is unavailable', async () => { + const page = { + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + hasNativeCaptureSupport: vi.fn().mockReturnValue(false), + installInterceptor: vi.fn().mockResolvedValue(undefined), + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + getCurrentUrl: vi.fn().mockResolvedValue('https://example.com'), + } as unknown as IPage; + + mockBridgeConnect.mockResolvedValue(page); + + const program = createProgram('', ''); + await program.parseAsync(['node', 'opencli', 'operate', 'open', 'https://example.com']); + + expect(page.startNetworkCapture).toHaveBeenCalledTimes(1); + expect(page.installInterceptor).toHaveBeenCalledWith(''); + }); + + it('falls back to legacy intercepted requests for operate network when native capture is unavailable', async () => { + const page = { + readNetworkCapture: vi.fn().mockResolvedValue([]), + hasNativeCaptureSupport: vi.fn().mockReturnValue(false), + getInterceptedRequests: vi.fn().mockResolvedValue([ + { + url: 'https://api.example.com/items', + method: 'GET', + status: 200, + size: 15, + ct: 'application/json', + body: { ok: true }, + }, + ]), + } as unknown as IPage; + + mockBridgeConnect.mockResolvedValue(page); + + const program = createProgram('', ''); + await program.parseAsync(['node', 'opencli', 'operate', 'network']); + + expect(page.readNetworkCapture).toHaveBeenCalledTimes(1); + expect(page.getInterceptedRequests).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Captured 1 API requests:')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('https://api.example.com/items')); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 17e7ec823..e519a811d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; import chalk from 'chalk'; +import type { CaptureCapablePage, IPage } from './types.js'; import { findPackageRoot, getBuiltEntryCandidates } from './package-paths.js'; import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js'; import { serializeCommand, formatArgSummary } from './serialization.js'; @@ -31,6 +32,60 @@ async function getOperatePage(): Promise { return bridge.connect({ timeout: 30, workspace: 'operate:default' }); } +type CaptureAwarePage = IPage & { + hasNativeCaptureSupport?: () => boolean | undefined; +}; + +function asCapturePage(page: IPage): CaptureCapablePage { + return page as CaptureCapablePage; +} + +function hasNativeCaptureSupport(page: IPage): boolean | undefined { + return (page as CaptureAwarePage).hasNativeCaptureSupport?.(); +} + +type CapturedRequest = { url: string; method: string; status: number; size: number; ct: string; body: unknown }; + +function normalizeCapturedRequests(raw: unknown[]): CapturedRequest[] { + return (raw as Array>).map((entry) => { + const preview = typeof entry.responsePreview === 'string' ? entry.responsePreview : null; + let body: unknown = entry.body ?? null; + if (preview) { + try { body = JSON.parse(preview); } catch { body = preview; } + } + return { + url: typeof entry.url === 'string' ? entry.url : '', + method: typeof entry.method === 'string' ? entry.method : 'GET', + status: typeof entry.responseStatus === 'number' ? entry.responseStatus : (typeof entry.status === 'number' ? entry.status : 0), + size: typeof entry.size === 'number' ? entry.size : (preview ? preview.length : 0), + ct: typeof entry.responseContentType === 'string' ? entry.responseContentType : (typeof entry.ct === 'string' ? entry.ct : ''), + body, + }; + }); +} + +async function startOperateCapture(page: IPage): Promise { + await asCapturePage(page).startNetworkCapture(); +} + +async function installOperateFallbackCapture(page: IPage): Promise { + if (hasNativeCaptureSupport(page) !== false) return; + try { + await page.installInterceptor(''); + } catch { + // The legacy interceptor is best-effort compatibility for stale extensions. + } +} + +async function readOperateCapture(page: IPage): Promise { + const raw = await asCapturePage(page).readNetworkCapture(); + if (hasNativeCaptureSupport(page) === false) { + const intercepted = await page.getInterceptedRequests(); + if (intercepted.length > 0) return normalizeCapturedRequests(intercepted); + } + return normalizeCapturedRequests(raw); +} + function applyVerbose(opts: { verbose?: boolean }): void { if (opts.verbose) process.env.OPENCLI_VERBOSE = '1'; } @@ -307,19 +362,13 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command // ── Navigation ── - /** Network interceptor JS — injected on every open/navigate to capture fetch/XHR */ - const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=50000,F=window.fetch;window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();if(window.__opencli_net.length').description('Open URL in automation window') .action(operateAction(async (page, url) => { // Start session-level capture before navigation (catches initial requests) - await page.startNetworkCapture?.(); + await startOperateCapture(page); await page.goto(url); await page.wait(2); - // Fallback: also inject JS interceptor for pages without session capture - if (!page.startNetworkCapture) { - try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ } - } + await installOperateFallbackCapture(page); console.log(`Navigated to: ${await page.getCurrentUrl?.() ?? url}`); })); @@ -509,35 +558,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command .option('--all', 'Show all requests including static resources') .description('Show captured network requests (auto-captured since last open)') .action(operateAction(async (page, opts) => { - let items: Array<{ url: string; method: string; status: number; size: number; ct: string; body: unknown }> = []; - if (page.readNetworkCapture) { - const raw = await page.readNetworkCapture(); - // Normalize daemon/CDP capture entries to __opencli_net shape. - // Daemon returns: responseStatus, responseContentType, responsePreview - // CDP returns the same shape after PR A fix. - items = (raw as Array>).map(e => { - const preview = (e.responsePreview as string) ?? null; - let body: unknown = null; - if (preview) { - try { body = JSON.parse(preview); } catch { body = preview; } - } - return { - url: (e.url as string) || '', - method: (e.method as string) || 'GET', - status: (e.responseStatus as number) || 0, - size: preview ? preview.length : 0, - ct: (e.responseContentType as string) || '', - body, - }; - }); - } else { - // Fallback to JS interceptor data - const requests = await page.evaluate(`(function(){ - var reqs = window.__opencli_net || []; - return JSON.stringify(reqs); - })()`) as string; - try { items = JSON.parse(requests); } catch { console.log('No network data captured. Run "operate open " first.'); return; } - } + let items = await readOperateCapture(page); if (items.length === 0) { console.log('No requests captured.'); return; } diff --git a/src/diagnostic.test.ts b/src/diagnostic.test.ts index 858928934..589fa4504 100644 --- a/src/diagnostic.test.ts +++ b/src/diagnostic.test.ts @@ -6,7 +6,25 @@ import { } from './diagnostic.js'; import { SelectorError, CommandExecutionError } from './errors.js'; import type { InternalCliCommand } from './registry.js'; -import type { IPage } from './types.js'; +import type { ConsoleMessage, IPage } from './types.js'; + +type Assert = T; +type IsEqual = + (() => T extends A ? 1 : 2) extends + (() => T extends B ? 1 : 2) ? true : false; + +type _ConsoleMessagesReturnType = Assert< + IsEqual>, ConsoleMessage[]> +>; +type _StartCaptureOptional = Assert< + undefined extends IPage['startNetworkCapture'] ? true : false +>; +type _ReadCaptureOptional = Assert< + undefined extends IPage['readNetworkCapture'] ? true : false +>; +type _StopCapturePresent = Assert< + 'stopCapture' extends keyof IPage ? true : false +>; function makeCmd(overrides: Partial = {}): InternalCliCommand { return { @@ -276,6 +294,9 @@ function makePage(overrides: Partial = {}): IPage { getInterceptedRequests: vi.fn().mockResolvedValue([]), waitForCapture: vi.fn(), screenshot: vi.fn(), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), getCurrentUrl: vi.fn().mockResolvedValue('https://example.com/page'), ...overrides, } as IPage; @@ -355,4 +376,25 @@ describe('collectDiagnostic', () => { expect(body).toContain('[truncated,'); expect(body.length).toBeLessThan(5000); }); + + it('prefers readNetworkCapture and redacts structured console entries', async () => { + const page = makePage({ + getCurrentUrl: vi.fn().mockResolvedValue('https://example.com/?token=secret'), + readNetworkCapture: vi.fn().mockResolvedValue([{ url: 'https://api.test', responseStatus: 500 }]), + networkRequests: vi.fn().mockResolvedValue([{ url: 'https://fallback.test' }]), + consoleMessages: vi.fn().mockResolvedValue([{ level: 'error', text: 'Bearer abc.def.ghi' }]), + }); + + const ctx = await collectDiagnostic(new Error('boom'), makeCmd(), page); + + expect(ctx.page).toEqual({ + url: 'https://example.com/?token=[REDACTED]', + snapshot: '
...
', + networkRequests: [{ url: 'https://api.test', responseStatus: 500 }], + capturedPayloads: [], + consoleErrors: [{ level: 'error', text: 'Bearer [REDACTED]' }], + }); + expect(page.readNetworkCapture).toHaveBeenCalledTimes(1); + expect(page.networkRequests).not.toHaveBeenCalled(); + }); }); diff --git a/src/diagnostic.ts b/src/diagnostic.ts index a2b8d5ccb..e2d4a6d0e 100644 --- a/src/diagnostic.ts +++ b/src/diagnostic.ts @@ -269,17 +269,34 @@ function normalizeInterceptedRequests(interceptedRequests: unknown[]): unknown[] })); } +function redactConsoleEntry(entry: unknown): unknown { + if (typeof entry === 'string') { + return redactText(entry); + } + if (!entry || typeof entry !== 'object') { + return entry; + } + const consoleEntry = entry as Record; + return { + ...consoleEntry, + ...(typeof consoleEntry.text === 'string' ? { text: redactText(consoleEntry.text) } : {}), + }; +} + /** Safely collect page diagnostic state with redaction, size caps, and timeout. */ async function collectPageState(page: IPage): Promise { const collect = async (): Promise => { try { - const [url, snapshot, networkRequests, interceptedRequests, consoleErrors] = await Promise.all([ + const [url, snapshot, capturedNetworkRequests, interceptedRequests, consoleErrors] = await Promise.all([ page.getCurrentUrl?.().catch(() => null) ?? Promise.resolve(null), page.snapshot().catch(() => '(snapshot unavailable)'), - page.networkRequests().catch(() => []), + page.readNetworkCapture?.().catch(() => null) ?? Promise.resolve(null), page.getInterceptedRequests().catch(() => []), page.consoleMessages('error').catch(() => []), ]); + const networkRequests = Array.isArray(capturedNetworkRequests) && capturedNetworkRequests.length > 0 + ? capturedNetworkRequests + : await page.networkRequests().catch(() => []); const rawUrl = url ?? 'unknown'; const capturedResponses = normalizeInterceptedRequests(interceptedRequests as unknown[]); @@ -292,7 +309,7 @@ async function collectPageState(page: IPage): Promise typeof e === 'string' ? redactText(e) : e), + .map(redactConsoleEntry), }; } catch { return undefined; diff --git a/src/doctor.test.ts b/src/doctor.test.ts index df28637e9..fcd0ea3b9 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -108,4 +108,36 @@ describe('doctor report rendering', () => { expect.stringContaining('Daemon is not running'), ])); }); + + it('does not report extension patch-version mismatch within the same minor version', async () => { + const status = { + running: true, + extensionConnected: true, + extensionVersion: '1.6.5', + }; + mockCheckDaemonStatus.mockResolvedValueOnce(status); + mockCheckDaemonStatus.mockResolvedValueOnce(status); + + const report = await runBrowserDoctor({ live: false, cliVersion: '1.6.8' }); + + expect(report.issues).not.toEqual(expect.arrayContaining([ + expect.stringContaining('Extension version mismatch'), + ])); + }); + + it('reports extension version mismatch when the minor version differs', async () => { + const status = { + running: true, + extensionConnected: true, + extensionVersion: '1.5.5', + }; + mockCheckDaemonStatus.mockResolvedValueOnce(status); + mockCheckDaemonStatus.mockResolvedValueOnce(status); + + const report = await runBrowserDoctor({ live: false, cliVersion: '1.6.8' }); + + expect(report.issues).toEqual(expect.arrayContaining([ + expect.stringContaining('Extension version mismatch: extension v1.5.5 ≠ CLI v1.6.8'), + ])); + }); }); diff --git a/src/doctor.ts b/src/doctor.ts index 3d0511120..55aff83ce 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -95,11 +95,11 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise ({ + mockBrowserSession: vi.fn(), + mockGetBrowserFactory: vi.fn(() => class MockBrowserFactory {}), +})); + +vi.mock('./runtime.js', async () => { + const actual = await vi.importActual('./runtime.js'); + return { + ...actual, + browserSession: mockBrowserSession, + getBrowserFactory: mockGetBrowserFactory, + }; +}); + +vi.mock('./browser/discover.js', () => ({ + checkDaemonStatus: vi.fn().mockResolvedValue({ running: false, extensionConnected: false }), +})); + describe('executeCommand — non-browser timeout', () => { + const originalDiagnostic = process.env.OPENCLI_DIAGNOSTIC; + + beforeEach(() => { + mockBrowserSession.mockReset(); + mockGetBrowserFactory.mockClear(); + delete process.env.OPENCLI_DIAGNOSTIC; + }); + + afterEach(() => { + if (originalDiagnostic === undefined) delete process.env.OPENCLI_DIAGNOSTIC; + else process.env.OPENCLI_DIAGNOSTIC = originalDiagnostic; + }); + it('applies timeoutSeconds to non-browser commands', async () => { const cmd = cli({ site: 'test-execution', @@ -44,4 +75,32 @@ describe('executeCommand — non-browser timeout', () => { withTimeoutMs(executeCommand(cmd, {}), 50, 'sentinel timeout'), ).rejects.toThrow('sentinel timeout'); }); + + it('starts and stops capture in diagnostic mode around browser commands', async () => { + process.env.OPENCLI_DIAGNOSTIC = '1'; + + const startNetworkCapture = vi.fn().mockResolvedValue(undefined); + const stopCapture = vi.fn().mockResolvedValue(undefined); + const page = { + goto: vi.fn(), + startNetworkCapture, + stopCapture, + } as any; + + mockBrowserSession.mockImplementationOnce(async (_factory, fn) => fn(page)); + + const cmd = cli({ + site: 'test-execution', + name: 'browser-diagnostic', + description: 'test browser diagnostic lifecycle', + browser: true, + strategy: Strategy.PUBLIC, + func: async () => ({ ok: true }), + }); + + await executeCommand(cmd, {}); + + expect(startNetworkCapture).toHaveBeenCalledTimes(1); + expect(stopCapture).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/execution.ts b/src/execution.ts index fbd8226dc..62bd511eb 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -11,7 +11,7 @@ */ import { type CliCommand, type InternalCliCommand, type Arg, type CommandArgs, Strategy, getRegistry, fullName } from './registry.js'; -import type { IPage } from './types.js'; +import type { CaptureCapablePage, IPage } from './types.js'; import { pathToFileURL } from 'node:url'; import { executePipeline } from './pipeline/index.js'; import { AdapterLoadError, ArgumentError, BrowserConnectError, CommandExecutionError, getErrorMessage } from './errors.js'; @@ -193,6 +193,8 @@ export async function executeCommand( ensureRequiredEnv(cmd); const BrowserFactory = getBrowserFactory(cmd.site); result = await browserSession(BrowserFactory, async (page) => { + const capturePage = page as CaptureCapablePage; + const diagnosticEnabled = isDiagnosticEnabled(); const preNavUrl = resolvePreNav(cmd); if (preNavUrl) { // Navigate directly — the extension's handleNavigate already has a fast-path @@ -206,6 +208,13 @@ export async function executeCommand( if (debug) log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`); } } + if (diagnosticEnabled) { + try { + await capturePage.startNetworkCapture(); + } catch (err) { + if (debug) log.debug(`[capture] Failed to start capture: ${err instanceof Error ? err.message : err}`); + } + } try { return await runWithTimeout(runCommand(cmd, page, kwargs, debug), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, @@ -220,6 +229,14 @@ export async function executeCommand( diagnosticEmitted = true; } throw err; + } finally { + if (diagnosticEnabled) { + try { + await capturePage.stopCapture(); + } catch (err) { + if (debug) log.debug(`[capture] Failed to stop capture: ${err instanceof Error ? err.message : err}`); + } + } } }, { workspace: `site:${cmd.site}`, cdpEndpoint }); } else { diff --git a/src/pipeline/executor.test.ts b/src/pipeline/executor.test.ts index 54bdee210..c03778b20 100644 --- a/src/pipeline/executor.test.ts +++ b/src/pipeline/executor.test.ts @@ -22,7 +22,7 @@ function createMockPage(overrides: Partial = {}): IPage { tabs: vi.fn().mockResolvedValue([]), selectTab: vi.fn(), networkRequests: vi.fn().mockResolvedValue([]), - consoleMessages: vi.fn().mockResolvedValue(''), + consoleMessages: vi.fn().mockResolvedValue([]), scroll: vi.fn(), scrollTo: vi.fn(), autoScroll: vi.fn(), @@ -30,8 +30,11 @@ function createMockPage(overrides: Partial = {}): IPage { getInterceptedRequests: vi.fn().mockResolvedValue([]), screenshot: vi.fn().mockResolvedValue(''), waitForCapture: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), ...overrides, - }; + } as IPage; } describe('executePipeline', () => { diff --git a/src/pipeline/steps/download.test.ts b/src/pipeline/steps/download.test.ts index 192170679..a6118bf62 100644 --- a/src/pipeline/steps/download.test.ts +++ b/src/pipeline/steps/download.test.ts @@ -43,6 +43,9 @@ function createMockPage(getCookies: IPage['getCookies']): IPage { getInterceptedRequests: vi.fn().mockResolvedValue([]), screenshot: vi.fn().mockResolvedValue(''), waitForCapture: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + stopCapture: vi.fn().mockResolvedValue(undefined), }; } diff --git a/src/types.ts b/src/types.ts index 952a7e21a..097637b08 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,6 +44,13 @@ export interface BrowserSessionInfo { [key: string]: unknown; } +export interface ConsoleMessage { + level: 'log' | 'warn' | 'error' | 'info' | 'debug'; + text: string; + timestamp?: number; + source?: 'console-api' | 'exception'; +} + export interface IPage { goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise; evaluate(js: string): Promise; @@ -60,7 +67,7 @@ export interface IPage { newTab?(): Promise; selectTab(index: number): Promise; networkRequests(includeStatic?: boolean): Promise; - consoleMessages(level?: string): Promise; + consoleMessages(level?: string): Promise; scroll(direction?: string, amount?: number): Promise; autoScroll(options?: { times?: number; delayMs?: number }): Promise; installInterceptor(pattern: string): Promise; @@ -69,6 +76,7 @@ export interface IPage { screenshot(options?: ScreenshotOptions): Promise; startNetworkCapture?(pattern?: string): Promise; readNetworkCapture?(): Promise; + stopCapture?(): Promise; /** * Set local file paths on a file input element via CDP DOM.setFileInputFiles. * Chrome reads the files directly — no base64 encoding or payload size limits. @@ -93,3 +101,5 @@ export interface IPage { /** Press a key via CDP Input.dispatchKeyEvent. */ nativeKeyPress?(key: string, modifiers?: string[]): Promise; } + +export type CaptureCapablePage = IPage & Required>; diff --git a/vitest.config.ts b/vitest.config.ts index 0c1cc470c..b8beec469 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ { test: { name: 'unit', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'extension/src/**/*.test.ts'], exclude: ['clis/**/*.test.ts'], sequence: { groupOrder: 0 }, },