diff --git a/extension/dist/background.js b/extension/dist/background.js index f456097fa..bf6f0d9a5 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -119,6 +119,41 @@ async function evaluate(tabId, expression, aggressiveRetry = false) { throw new Error("evaluate: max retries exhausted"); } const evaluateAsync = evaluate; +async function evaluateViaScripting(tabId, expression) { + let results; + try { + results = await chrome.scripting.executeScript({ + target: { tabId }, + world: "MAIN", + func: async (code) => { + try { + const value = await (0, eval)(code); + return { ok: true, value }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + args: [expression] + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`--via-extension eval failed: ${msg}`); + } + if (!results || results.length === 0) { + throw new Error("--via-extension eval failed: executeScript returned no results"); + } + const payload = results[0].result; + if (!payload) { + throw new Error("--via-extension eval failed: script returned undefined (return value may not be structured-cloneable)"); + } + if (!payload.ok) { + const msg = payload.error ?? "Script evaluation failed"; + const isCsp = /unsafe-eval|content security policy|EvalError/i.test(msg); + throw new Error(isCsp ? `${msg} +(--via-extension eval runs in the page's MAIN world and is subject to its CSP; sites that block unsafe-eval will reject this. Use plain \`browser eval\` (CDP path) on such pages.)` : msg); + } + return payload.value; +} async function screenshot(tabId, options = {}) { await ensureAttached(tabId); const format = options.format ?? "png"; @@ -700,6 +735,7 @@ console.error = (...args) => { forwardLog("error", args); }; function isDaemonSocketActive(socket = ws) { + if (typeof WebSocket === "undefined") return false; return socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING; } function connect() { @@ -1383,6 +1419,8 @@ async function handleCommand(cmd) { switch (cmd.action) { case "exec": return await handleExec(cmd, leaseKey); + case "exec-via-scripting": + return await handleExecViaScripting(cmd, leaseKey); case "navigate": return await handleNavigate(cmd, leaseKey); case "tabs": @@ -1613,6 +1651,17 @@ async function listAutomationWebTabs(leaseKey) { const tabs = await listAutomationTabs(leaseKey); return tabs.filter((tab) => isDebuggableUrl(tab.url)); } +async function handleExecViaScripting(cmd, leaseKey) { + if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + const data = await evaluateViaScripting(tabId, cmd.code); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} async function handleExec(cmd, leaseKey) { if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; const cmdTabId = await resolveCommandTabId(cmd); diff --git a/extension/manifest.json b/extension/manifest.json index 371db6ef4..cbc7fe11e 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -5,6 +5,7 @@ "description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in Chrome tab leases via a local daemon.", "permissions": [ "debugger", + "scripting", "tabs", "cookies", "activeTab", diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index 510da347e..7bfcda1e7 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; type Listener void> = { addListener: any; @@ -216,6 +216,11 @@ function createChromeMock() { }; } +// Keep WebSocket stubbed for the entire file so that any pending connectAttempt() +// Promises can resolve safely between describe blocks. +beforeAll(() => { vi.stubGlobal('WebSocket', MockWebSocket); }); +afterAll(() => { vi.unstubAllGlobals(); }); + describe('background tab isolation', () => { beforeEach(() => { vi.resetModules(); @@ -2000,3 +2005,159 @@ describe('background tab isolation', () => { expect(chrome.tabs.create).not.toHaveBeenCalled(); }); }); + +describe('handleExecViaScripting path', () => { + // Keep WebSocket stubbed for the entire describe block so that any pending + // connectAttempt() Promises left over from the previous describe block can + // resolve safely (without "WebSocket is not defined") during our tests. + beforeAll(() => { + MockWebSocket.instances = []; + vi.stubGlobal('WebSocket', MockWebSocket); + }); + + afterAll(() => { + vi.unstubAllGlobals(); + }); + + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + MockWebSocket.instances = []; + vi.stubGlobal('WebSocket', MockWebSocket); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it('calls evaluateViaScripting and NOT evaluateAsync for exec-via-scripting action', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const evaluateAsync = vi.fn(async () => 'cdp-result'); + const evaluateViaScripting = vi.fn(async () => 'scripting-result'); + vi.doMock('./cdp', () => ({ + registerListeners: vi.fn(), + registerFrameTracking: vi.fn(), + hasActiveNetworkCapture: vi.fn(() => false), + detach: vi.fn(async () => {}), + evaluateAsync, + evaluateViaScripting, + screenshot: vi.fn(), + })); + + const mod = await import('./background'); + mod.__test__.setSession(browserKey('default'), { windowId: 2, owned: false, preferredTabId: 2 }); + + const result = await mod.__test__.handleExecViaScripting({ + id: 'scripting-exec', + action: 'exec-via-scripting', + session: 'default', + surface: 'browser', + code: 'document.title', + }, browserKey('default')); + + expect(result.ok).toBe(true); + expect(result.data).toBe('scripting-result'); + expect(evaluateViaScripting).toHaveBeenCalledOnce(); + expect(evaluateAsync).not.toHaveBeenCalled(); + }); + + it('exec action still uses evaluateAsync (CDP) and does NOT call evaluateViaScripting', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const evaluateAsync = vi.fn(async () => 'cdp-result'); + const evaluateViaScripting = vi.fn(); + vi.doMock('./cdp', () => ({ + registerListeners: vi.fn(), + registerFrameTracking: vi.fn(), + hasActiveNetworkCapture: vi.fn(() => false), + detach: vi.fn(async () => {}), + evaluateAsync, + evaluateViaScripting, + screenshot: vi.fn(), + })); + + const mod = await import('./background'); + mod.__test__.setSession(browserKey('default'), { windowId: 2, owned: false, preferredTabId: 2 }); + + const result = await mod.__test__.handleExec({ + id: 'cdp-exec', + action: 'exec', + session: 'default', + surface: 'browser', + code: 'document.title', + }, browserKey('default')); + + expect(result.ok).toBe(true); + expect(result.data).toBe('cdp-result'); + expect(evaluateAsync).toHaveBeenCalledOnce(); + expect(evaluateViaScripting).not.toHaveBeenCalled(); + }); + + it('propagates evaluateViaScripting errors as ok:false', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + vi.doMock('./cdp', () => ({ + registerListeners: vi.fn(), + registerFrameTracking: vi.fn(), + hasActiveNetworkCapture: vi.fn(() => false), + detach: vi.fn(async () => {}), + evaluateAsync: vi.fn(), + evaluateViaScripting: vi.fn(async () => { throw new Error('--via-extension eval failed: Cannot access chrome:// URL'); }), + screenshot: vi.fn(), + })); + + const mod = await import('./background'); + mod.__test__.setSession(browserKey('default'), { windowId: 2, owned: false, preferredTabId: 2 }); + + const result = await mod.__test__.handleExecViaScripting({ + id: 'scripting-error', + action: 'exec-via-scripting', + session: 'default', + surface: 'browser', + code: 'document.title', + }, browserKey('default')); + + expect(result.ok).toBe(false); + expect(result.error).toContain('--via-extension eval failed'); + }); + + it('handleCommand returns ok:false for unrecognized actions (old extension fails loudly, no silent CDP fallback)', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const evaluateAsync = vi.fn(async () => 'cdp-result'); + const evaluateViaScripting = vi.fn(async () => 'scripting-result'); + vi.doMock('./cdp', () => ({ + registerListeners: vi.fn(), + registerFrameTracking: vi.fn(), + hasActiveNetworkCapture: vi.fn(() => false), + detach: vi.fn(async () => {}), + evaluateAsync, + evaluateViaScripting, + screenshot: vi.fn(), + })); + + const mod = await import('./background'); + + // An old extension that doesn't know exec-via-scripting would hit the default + // switch branch and return Unknown action — not silently fall through to CDP. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await mod.__test__.handleCommand({ + id: 'unrecognized', + action: 'exec-unrecognized-future-action' as Parameters[0]['action'], + session: 'default', + surface: 'browser', + code: 'document.title', + }); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/Unknown action/i); + expect(evaluateAsync).not.toHaveBeenCalled(); + expect(evaluateViaScripting).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/background.ts b/extension/src/background.ts index 7ec54dbb2..52dd83b55 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -94,6 +94,7 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error // ─── WebSocket connection ──────────────────────────────────────────── function isDaemonSocketActive(socket: WebSocket | null | undefined = ws): boolean { + if (typeof WebSocket === 'undefined') return false; return socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING; } @@ -983,6 +984,8 @@ async function handleCommand(cmd: Command): Promise { switch (cmd.action) { case 'exec': return await handleExec(cmd, leaseKey); + case 'exec-via-scripting': + return await handleExecViaScripting(cmd, leaseKey); case 'navigate': return await handleNavigate(cmd, leaseKey); case 'tabs': @@ -1274,6 +1277,18 @@ async function listAutomationWebTabs(leaseKey: string): Promise isDebuggableUrl(tab.url)); } +async function handleExecViaScripting(cmd: Command, leaseKey: string): Promise { + if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' }; + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + const data = await executor.evaluateViaScripting(tabId, cmd.code); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + async function handleExec(cmd: Command, leaseKey: string): Promise { if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' }; const cmdTabId = await resolveCommandTabId(cmd); @@ -1832,6 +1847,7 @@ async function handleBind(cmd: Command, leaseKey: string): Promise { export const __test__ = { handleExec, + handleExecViaScripting, handleNavigate, isTargetUrl, handleTabs, diff --git a/extension/src/cdp.test.ts b/extension/src/cdp.test.ts index 2eeb9a716..5f8fbd61e 100644 --- a/extension/src/cdp.test.ts +++ b/extension/src/cdp.test.ts @@ -393,3 +393,107 @@ describe('cdp download waits', () => { }); }); }); + +describe('evaluateViaScripting', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function makeScriptingMock(result: unknown) { + const { chrome } = createChromeMock(); + const scripting = { + executeScript: vi.fn(async () => [{ result }]), + }; + return { chrome: { ...chrome, scripting }, scripting }; + } + + it('returns the value from executeScript result payload', async () => { + const { chrome } = makeScriptingMock({ ok: true, value: 'hello' }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + const result = await mod.evaluateViaScripting(1, 'document.title'); + + expect(result).toBe('hello'); + expect(chrome.scripting.executeScript).toHaveBeenCalledWith( + expect.objectContaining({ target: { tabId: 1 }, world: 'MAIN' }), + ); + }); + + it('returns undefined when the expression evaluates to undefined', async () => { + const { chrome } = makeScriptingMock({ ok: true, value: undefined }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + const result = await mod.evaluateViaScripting(1, 'void 0'); + + expect(result).toBeUndefined(); + }); + + it('returns structured values (objects, arrays)', async () => { + const { chrome } = makeScriptingMock({ ok: true, value: { count: 3, items: ['a', 'b', 'c'] } }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + const result = await mod.evaluateViaScripting(1, 'someExpression'); + + expect(result).toEqual({ count: 3, items: ['a', 'b', 'c'] }); + }); + + it('throws when the script itself throws (ok: false payload)', async () => { + const { chrome } = makeScriptingMock({ ok: false, error: 'ReferenceError: foo is not defined' }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await expect(mod.evaluateViaScripting(1, 'foo')).rejects.toThrow('ReferenceError: foo is not defined'); + }); + + it('throws when executeScript returns an empty results array', async () => { + const { chrome } = createChromeMock(); + (chrome.scripting as any).executeScript = vi.fn(async () => []); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await expect(mod.evaluateViaScripting(1, 'document.title')).rejects.toThrow('executeScript returned no results'); + }); + + it('wraps executeScript errors with a --via-extension prefix', async () => { + const { chrome } = createChromeMock(); + (chrome.scripting as any).executeScript = vi.fn(async () => { throw new Error('Cannot access a chrome:// URL'); }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await expect(mod.evaluateViaScripting(1, 'document.title')).rejects.toThrow('--via-extension eval failed: Cannot access a chrome:// URL'); + }); + + it('throws when result payload is undefined (non-cloneable return)', async () => { + const { chrome } = makeScriptingMock(undefined); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await expect(mod.evaluateViaScripting(1, 'document.body')).rejects.toThrow('script returned undefined'); + }); + + it('appends CSP hint when error mentions unsafe-eval', async () => { + const cspMsg = "EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: \"script-src 'self'\""; + const { chrome } = makeScriptingMock({ ok: false, error: cspMsg }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await expect(mod.evaluateViaScripting(1, 'eval("1")')).rejects.toThrow(/--via-extension eval runs in the page.*MAIN world.*CSP/i); + }); + + it('does not call chrome.debugger.attach', async () => { + const { chrome } = makeScriptingMock({ ok: true, value: 42 }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await mod.evaluateViaScripting(1, '42'); + + expect(chrome.debugger.attach).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index b292f516b..74fa57f62 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -201,6 +201,53 @@ export async function evaluate(tabId: number, expression: string, aggressiveRetr export const evaluateAsync = evaluate; +/** + * Execute JS in a tab via chrome.scripting.executeScript (MAIN world). + * Does not attach chrome.debugger — invisible to anti-bot fingerprinting. + * Limitations vs CDP path: return value must be structured-cloneable (no DOM + * nodes, functions, or circular refs); error stacks are not preserved. + */ +export async function evaluateViaScripting(tabId: number, expression: string): Promise { + let results: chrome.scripting.InjectionResult[]; + try { + results = await chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + func: async (code: string) => { + try { + // indirect eval: runs in page global scope, not extension scope + // eslint-disable-next-line no-eval + const value = await (0, eval)(code); + return { ok: true, value }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + args: [expression], + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`--via-extension eval failed: ${msg}`); + } + + if (!results || results.length === 0) { + throw new Error('--via-extension eval failed: executeScript returned no results'); + } + + const payload = results[0].result as { ok: boolean; value?: unknown; error?: string } | undefined; + if (!payload) { + throw new Error('--via-extension eval failed: script returned undefined (return value may not be structured-cloneable)'); + } + if (!payload.ok) { + const msg = payload.error ?? 'Script evaluation failed'; + const isCsp = /unsafe-eval|content security policy|EvalError/i.test(msg); + throw new Error(isCsp + ? `${msg}\n(--via-extension eval runs in the page's MAIN world and is subject to its CSP; sites that block unsafe-eval will reject this. Use plain \`browser eval\` (CDP path) on such pages.)` + : msg); + } + return payload.value; +} + /** * Capture a screenshot via CDP Page.captureScreenshot. * Returns base64-encoded image data. diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index ba5acc79d..7965b0942 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -7,6 +7,7 @@ export type Action = | 'exec' + | 'exec-via-scripting' | 'navigate' | 'tabs' | 'cookies' diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 1a789a87f..88829cde1 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' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames'; + action: 'exec' | 'exec-via-scripting' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames'; /** Target page identity (targetId). Cross-layer contract with the extension. */ page?: string; code?: string; diff --git a/src/browser/page.ts b/src/browser/page.ts index eedf4b28c..629ab6634 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -333,6 +333,11 @@ export class Page extends BasePage { return sendCommand('exec', { code, frameIndex, ...this._cmdOpts() }); } + async evaluateNoDebugger(js: string): Promise { + const code = buildEvaluateExpression(js); + return sendCommand('exec-via-scripting', { code, ...this._cmdOpts() }); + } + async cdp(method: string, params: Record = {}): Promise { return sendCommand('cdp', { cdpMethod: method, diff --git a/src/cli.ts b/src/cli.ts index bac77032c..8303da24b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2205,11 +2205,22 @@ Examples: browser.command('eval') .argument('', 'JavaScript code') .option('--frame ', 'Cross-origin iframe index from "browser frames"') + .option('--via-extension', 'Run via chrome.scripting (MAIN world) instead of CDP — bypasses debugger-detection on sites like zhipin.com; return value must be JSON-serializable, --frame is unsupported, and pages with strict CSP (no unsafe-eval) will reject the eval') .description('Execute JS in page context, return result'), ) .action(browserAction(async (page, js, opts) => { let result: unknown; - if (opts.frame !== undefined) { + if (opts.viaExtension) { + if (opts.frame !== undefined) { + console.error('--via-extension and --frame cannot be used together. Omit --frame or remove --via-extension.'); + process.exitCode = EXIT_CODES.USAGE_ERROR; + return; + } + if (!page.evaluateNoDebugger) { + throw new Error('This browser session does not support --via-extension evaluation'); + } + result = await page.evaluateNoDebugger(js); + } else if (opts.frame !== undefined) { const frameIndex = Number.parseInt(opts.frame, 10); if (!Number.isInteger(frameIndex) || frameIndex < 0) { console.error(`Invalid frame index "${opts.frame}". Use a 0-based index from "browser frames".`); diff --git a/src/types.ts b/src/types.ts index 9ed140552..20cc8df85 100644 --- a/src/types.ts +++ b/src/types.ts @@ -146,6 +146,8 @@ export interface IPage { frames?(): Promise>; /** Evaluate JavaScript inside a cross-origin iframe identified by its frame index. */ evaluateInFrame?(js: string, frameIndex: number): Promise; + /** Evaluate JavaScript via chrome.scripting instead of CDP — bypasses debugger detection. */ + evaluateNoDebugger?(js: string): Promise; /** Click at native coordinates via CDP Input.dispatchMouseEvent. */ nativeClick?(x: number, y: number): Promise; /** Type text via CDP Input.insertText. */ diff --git a/tests/e2e/browser-eval-via-extension.test.ts b/tests/e2e/browser-eval-via-extension.test.ts new file mode 100644 index 000000000..5eab0c7a8 --- /dev/null +++ b/tests/e2e/browser-eval-via-extension.test.ts @@ -0,0 +1,190 @@ +/** + * E2E tests for `browser eval --via-extension` flag. + * + * Uses a fake daemon to verify: + * - The CLI sends action 'exec-via-scripting' (not 'exec') when --via-extension is given + * - The CLI sends plain 'exec' when --via-extension is absent (no silent fallback) + * - --via-extension combined with --frame exits with a usage error + * - Results from the daemon are printed correctly + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runCli } from './helpers.js'; + +const PKG_VERSION: string = (() => { + const here = path.dirname(fileURLToPath(import.meta.url)); + const pkgPath = path.resolve(here, '..', '..', 'package.json'); + try { + return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version; + } catch { + return '0.0.0'; + } +})(); + +async function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + req.on('error', reject); + }); +} + +function json(res: ServerResponse, status: number, payload: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); +} + +type RecordedCommand = { + action: string; + code: string; + frameIndex?: number; +}; + +type FakeDaemon = { + port: number; + close: () => Promise; + lastCommand: () => RecordedCommand | null; +}; + +async function startFakeDaemon(evalResult: unknown = 'fake-result'): Promise { + let lastCommand: RecordedCommand | null = null; + + const server = createServer(async (req, res) => { + const pathname = req.url?.split('?')[0] ?? '/'; + + if (req.method === 'GET' && pathname === '/status') { + const addr = server.address(); + json(res, 200, { + ok: true, + pid: process.pid, + uptime: 1, + daemonVersion: PKG_VERSION, + extensionConnected: true, + extensionVersion: 'test', + pending: 0, + memoryMB: 1, + port: typeof addr === 'object' && addr ? addr.port : 0, + }); + return; + } + + if (req.method === 'GET' && pathname === '/ping') { + json(res, 200, { ok: true }); + return; + } + + if (req.method !== 'POST' || pathname !== '/command') { + json(res, 404, { ok: false, error: 'Not found' }); + return; + } + + const body = JSON.parse(await readBody(req)) as { + id: string; + action: string; + code?: string; + frameIndex?: number; + session?: string; + surface?: string; + }; + + if (body.action === 'bind') { + json(res, 200, { id: body.id, ok: true, data: { session: body.session, url: 'https://example.com', title: 'Example' } }); + return; + } + + if (body.action === 'exec' || body.action === 'exec-via-scripting') { + lastCommand = { + action: body.action, + code: body.code ?? '', + frameIndex: body.frameIndex, + }; + json(res, 200, { id: body.id, ok: true, page: 'page-1', data: evalResult }); + return; + } + + json(res, 200, { id: body.id, ok: false, error: `Unhandled action: ${body.action}` }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + + const addr = server.address(); + if (!addr || typeof addr !== 'object') throw new Error('Failed to bind fake daemon'); + + return { + port: addr.port, + close: () => new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve())), + lastCommand: () => lastCommand, + }; +} + +describe('browser eval --via-extension e2e', () => { + const daemons: FakeDaemon[] = []; + + afterEach(async () => { + while (daemons.length > 0) await daemons.pop()!.close(); + }); + + it('sends exec-via-scripting action to the daemon when --via-extension is passed', async () => { + const daemon = await startFakeDaemon('page-title'); + daemons.push(daemon); + const env = { OPENCLI_DAEMON_PORT: String(daemon.port) }; + + const result = await runCli(['browser', 'work', 'eval', 'document.title', '--via-extension'], { env }); + + expect(result.code).toBe(0); + expect(result.stdout.trim()).toBe('page-title'); + expect(daemon.lastCommand()?.action).toBe('exec-via-scripting'); + expect(daemon.lastCommand()?.code).toContain('document.title'); + }); + + it('sends plain exec action (not exec-via-scripting) when --via-extension is absent', async () => { + const daemon = await startFakeDaemon('plain-result'); + daemons.push(daemon); + const env = { OPENCLI_DAEMON_PORT: String(daemon.port) }; + + const result = await runCli(['browser', 'work', 'eval', 'document.title'], { env }); + + expect(result.code).toBe(0); + expect(daemon.lastCommand()?.action).toBe('exec'); + }); + + it('prints JSON for non-string results', async () => { + const daemon = await startFakeDaemon({ count: 5 }); + daemons.push(daemon); + const env = { OPENCLI_DAEMON_PORT: String(daemon.port) }; + + const result = await runCli(['browser', 'work', 'eval', 'someExpr', '--via-extension'], { env }); + + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ count: 5 }); + expect(daemon.lastCommand()?.action).toBe('exec-via-scripting'); + }); + + it('exits with non-zero code when --via-extension and --frame are combined', async () => { + const daemon = await startFakeDaemon(); + daemons.push(daemon); + const env = { OPENCLI_DAEMON_PORT: String(daemon.port) }; + + const result = await runCli(['browser', 'work', 'eval', 'document.title', '--via-extension', '--frame', '0'], { env }); + + expect(result.code).not.toBe(0); + expect(result.stderr + result.stdout).toMatch(/--via-extension.*--frame|--frame.*--via-extension/i); + expect(daemon.lastCommand()).toBeNull(); + }); + + it('forwards the expression unchanged to the daemon', async () => { + const expr = 'Array.from(document.querySelectorAll(".item")).length'; + const daemon = await startFakeDaemon(42); + daemons.push(daemon); + const env = { OPENCLI_DAEMON_PORT: String(daemon.port) }; + + await runCli(['browser', 'work', 'eval', expr, '--via-extension'], { env }); + + expect(daemon.lastCommand()?.code).toContain(expr); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 566d41283..d5a9d1c30 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ name: 'e2e', include: [ 'tests/e2e/browser-public.test.ts', + 'tests/e2e/browser-eval-via-extension.test.ts', 'tests/e2e/band-auth.test.ts', 'tests/e2e/public-commands.test.ts', 'tests/e2e/management.test.ts',