Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/browser/cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const { MockWebSocket } = vi.hoisted(() => {
class MockWebSocket {
static OPEN = 1;
static urls: string[] = [];
readyState = 1;
private handlers = new Map<string, Array<(...args: any[]) => void>>();

constructor(_url: string) {
constructor(url: string) {
MockWebSocket.urls.push(url);
queueMicrotask(() => this.emit('open'));
}

Expand Down Expand Up @@ -41,6 +43,7 @@ import { CDPBridge } from './cdp.js';
describe('CDPBridge cookies', () => {
beforeEach(() => {
vi.unstubAllEnvs();
MockWebSocket.urls = [];
});

it('filters cookies by actual domain match instead of substring match', async () => {
Expand All @@ -63,4 +66,15 @@ describe('CDPBridge cookies', () => {
{ name: 'exact', value: '2', domain: 'example.com' },
]);
});

it('trims OPENCLI_CDP_ENDPOINT before opening the websocket', async () => {
vi.stubEnv('OPENCLI_CDP_ENDPOINT', ' ws://127.0.0.1:9222/devtools/page/1 ');

const bridge = new CDPBridge();
vi.spyOn(bridge, 'send').mockResolvedValue({});

await bridge.connect();

expect(MockWebSocket.urls).toEqual(['ws://127.0.0.1:9222/devtools/page/1']);
});
});
2 changes: 1 addition & 1 deletion src/browser/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class CDPBridge implements IBrowserFactory {
async connect(opts?: { timeout?: number; workspace?: string; cdpEndpoint?: string }): Promise<IPage> {
if (this._ws) throw new Error('CDPBridge is already connected. Call close() before reconnecting.');

const endpoint = opts?.cdpEndpoint ?? process.env.OPENCLI_CDP_ENDPOINT;
const endpoint = (opts?.cdpEndpoint ?? process.env.OPENCLI_CDP_ENDPOINT)?.trim();
if (!endpoint) throw new Error('CDP endpoint not provided (pass cdpEndpoint or set OPENCLI_CDP_ENDPOINT)');

let wsUrl = endpoint;
Expand Down
63 changes: 63 additions & 0 deletions src/cli-operate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const {
mockCdpConnect,
mockBridgeConnect,
} = vi.hoisted(() => ({
mockCdpConnect: vi.fn(),
mockBridgeConnect: vi.fn(),
}));

vi.mock('./browser/index.js', () => ({
BrowserBridge: class {
connect = mockBridgeConnect;
close = vi.fn();
},
CDPBridge: class {
connect = mockCdpConnect;
close = vi.fn();
},
}));

import { createProgram } from './cli.js';

describe('operate manual CDP routing', () => {
beforeEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
vi.spyOn(console, 'log').mockImplementation(() => {});

const page = {
evaluate: vi.fn(),
wait: vi.fn(),
};

mockBridgeConnect.mockResolvedValue(page);
mockCdpConnect.mockResolvedValue(page);
});

it('uses CDPBridge when OPENCLI_CDP_ENDPOINT is set', async () => {
vi.stubEnv('OPENCLI_CDP_ENDPOINT', ' https://abcdef.ngrok.app ');

const program = createProgram('', '');
await program.parseAsync(['node', 'opencli', 'operate', 'back']);

expect(mockCdpConnect).toHaveBeenCalledWith({
timeout: 30,
workspace: 'operate:default',
cdpEndpoint: 'https://abcdef.ngrok.app',
});
expect(mockBridgeConnect).not.toHaveBeenCalled();
});

it('keeps BrowserBridge when OPENCLI_CDP_ENDPOINT is not set', async () => {
const program = createProgram('', '');
await program.parseAsync(['node', 'opencli', 'operate', 'back']);

expect(mockBridgeConnect).toHaveBeenCalledWith({
timeout: 30,
workspace: 'operate:default',
});
expect(mockCdpConnect).not.toHaveBeenCalled();
});
});
7 changes: 4 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ const CLI_FILE = fileURLToPath(import.meta.url);

/** Create a browser page for operate commands. Uses 'operate' workspace for session persistence. */
async function getOperatePage(): Promise<import('./types.js').IPage> {
const { BrowserBridge } = await import('./browser/index.js');
const bridge = new BrowserBridge();
return bridge.connect({ timeout: 30, workspace: 'operate:default' });
const cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT?.trim() || undefined;
const BrowserFactory = getBrowserFactory();
const browser = new BrowserFactory();
return browser.connect({ timeout: 30, workspace: 'operate:default', cdpEndpoint });
}

function applyVerbose(opts: { verbose?: boolean }): void {
Expand Down
103 changes: 103 additions & 0 deletions src/execution-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const {
mockBrowserSession,
mockCheckDaemonStatus,
mockProbeCDP,
mockResolveElectronEndpoint,
mockEmitHook,
} = vi.hoisted(() => ({
mockBrowserSession: vi.fn(async (_Factory, fn) => fn({
goto: vi.fn(),
wait: vi.fn(),
} as any)),
mockCheckDaemonStatus: vi.fn(),
mockProbeCDP: vi.fn(),
mockResolveElectronEndpoint: vi.fn(),
mockEmitHook: vi.fn(),
}));

vi.mock('./runtime.js', async () => {
const actual = await vi.importActual<typeof import('./runtime.js')>('./runtime.js');
return {
...actual,
browserSession: mockBrowserSession,
};
});

vi.mock('./browser/discover.js', () => ({
checkDaemonStatus: mockCheckDaemonStatus,
}));

vi.mock('./launcher.js', () => ({
probeCDP: mockProbeCDP,
resolveElectronEndpoint: mockResolveElectronEndpoint,
}));

vi.mock('./hooks.js', () => ({
emitHook: mockEmitHook,
}));

import { CDPBridge } from './browser/index.js';
import { executeCommand } from './execution.js';
import { cli, Strategy } from './registry.js';

const youtubeCommand = cli({
site: 'youtube',
name: 'search',
description: 'search',
browser: true,
strategy: Strategy.COOKIE,
domain: 'www.youtube.com',
navigateBefore: false,
func: vi.fn(async () => 'ok'),
});

const cursorCommand = cli({
site: 'cursor',
name: 'status',
description: 'status',
browser: true,
strategy: Strategy.COOKIE,
navigateBefore: false,
func: vi.fn(async () => 'ok'),
});

describe('executeCommand manual CDP routing', () => {
beforeEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
mockCheckDaemonStatus.mockResolvedValue({ running: true, extensionConnected: true });
mockProbeCDP.mockResolvedValue(true);
mockResolveElectronEndpoint.mockResolvedValue('http://127.0.0.1:9333');
});

it('uses CDPBridge for non-Electron browser commands when OPENCLI_CDP_ENDPOINT is set', async () => {
vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'https://abcdef.ngrok.app');

await expect(executeCommand(youtubeCommand, {})).resolves.toBe('ok');

expect(mockCheckDaemonStatus).not.toHaveBeenCalled();
expect(mockProbeCDP).not.toHaveBeenCalled();
expect(mockBrowserSession).toHaveBeenCalledWith(
CDPBridge,
expect.any(Function),
expect.objectContaining({ cdpEndpoint: 'https://abcdef.ngrok.app' }),
);
});

it('preserves manual-endpoint validation for Electron apps', async () => {
vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'http://127.0.0.1:9222');

await expect(executeCommand(cursorCommand, {})).resolves.toBe('ok');

expect(mockProbeCDP).toHaveBeenCalledWith(9222);
expect(mockCheckDaemonStatus).not.toHaveBeenCalled();
});

it('keeps Browser Bridge checks when no manual endpoint is set', async () => {
mockCheckDaemonStatus.mockResolvedValue({ running: true, extensionConnected: false });

await expect(executeCommand(youtubeCommand, {})).rejects.toThrow('Browser Bridge extension not connected');
});
});
27 changes: 16 additions & 11 deletions src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,17 +176,22 @@ export async function executeCommand(
cdpEndpoint = await resolveElectronEndpoint(cmd.site);
}
} else {
// Browser Bridge: fail-fast when daemon is up but extension is missing.
// 300ms timeout avoids a full 2s wait on cold-start.
const status = await checkDaemonStatus({ timeout: 300 });
if (status.running && !status.extensionConnected) {
throw new BrowserConnectError(
'Browser Bridge extension not connected',
'Install the Browser Bridge:\n' +
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
' 2. In Chrome or Chromium, open chrome://extensions β†’ Developer Mode β†’ Load unpacked\n' +
' Then run: opencli doctor',
);
const manualEndpoint = process.env.OPENCLI_CDP_ENDPOINT?.trim() || undefined;
if (manualEndpoint) {
cdpEndpoint = manualEndpoint;
} else {
// Browser Bridge: fail-fast when daemon is up but extension is missing.
// 300ms timeout avoids a full 2s wait on cold-start.
const status = await checkDaemonStatus({ timeout: 300 });
if (status.running && !status.extensionConnected) {
throw new BrowserConnectError(
'Browser Bridge extension not connected',
'Install the Browser Bridge:\n' +
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
' 2. In Chrome or Chromium, open chrome://extensions β†’ Developer Mode β†’ Load unpacked\n' +
' Then run: opencli doctor',
);
}
}
}

Expand Down
26 changes: 26 additions & 0 deletions src/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BrowserBridge, CDPBridge } from './browser/index.js';
import { getBrowserFactory } from './runtime.js';

describe('getBrowserFactory', () => {
beforeEach(() => {
vi.unstubAllEnvs();
});

it('uses BrowserBridge by default for non-Electron sites', () => {
expect(getBrowserFactory()).toBe(BrowserBridge);
expect(getBrowserFactory('bilibili')).toBe(BrowserBridge);
});

it('uses CDPBridge for registered Electron apps', () => {
expect(getBrowserFactory('cursor')).toBe(CDPBridge);
});

it('prefers CDPBridge whenever OPENCLI_CDP_ENDPOINT is set, including zero-arg callers', () => {
vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'http://127.0.0.1:9222');

expect(getBrowserFactory()).toBe(CDPBridge);
expect(getBrowserFactory('bilibili')).toBe(CDPBridge);
expect(getBrowserFactory('cursor')).toBe(CDPBridge);
});
});
5 changes: 3 additions & 2 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { TimeoutError } from './errors.js';
import { isElectronApp } from './electron-apps.js';

/**
* Returns the appropriate browser factory based on site type.
* Uses CDPBridge for registered Electron apps, otherwise BrowserBridge.
* Returns the appropriate browser factory for the current command path.
* Manual CDP endpoint overrides shared browser-factory callers.
*/
export function getBrowserFactory(site?: string): new () => IBrowserFactory {
if (process.env.OPENCLI_CDP_ENDPOINT?.trim()) return CDPBridge;
if (site && isElectronApp(site)) return CDPBridge;
return BrowserBridge;
}
Expand Down
Loading