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
5 changes: 4 additions & 1 deletion scripts/postinstall.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,10 @@ function main() {
console.log(' \x1b[1mNext step — Browser Bridge setup\x1b[0m');
console.log(' Browser commands (bilibili, zhihu, twitter...) require the extension:');
console.log(' 1. Download: https://github.com/jackwener/opencli/releases');
console.log(' 2. In Chrome or Chromium, open chrome://extensions → enable Developer Mode → Load unpacked');
console.log(' 2. In a Chromium-based browser, open the extensions page:');
console.log(' - Chrome: chrome://extensions');
console.log(' - Edge: edge://extensions');
console.log(' Enable Developer Mode → Load unpacked');
console.log('');
console.log(' Then run \x1b[36mopencli doctor\x1b[0m to verify.');
console.log('');
Expand Down
204 changes: 200 additions & 4 deletions src/browser.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
import { describe, it, expect, vi } from 'vitest';
import { beforeEach, describe, it, expect, vi } from 'vitest';
const {
mockFetchDaemonStatus,
mockIsExtensionConnected,
mockGetBrowserCandidates,
mockLaunchBrowserCandidate,
} = vi.hoisted(() => ({
mockFetchDaemonStatus: vi.fn(),
mockIsExtensionConnected: vi.fn(),
mockGetBrowserCandidates: vi.fn(),
mockLaunchBrowserCandidate: vi.fn(),
}));

vi.mock('./browser/daemon-client.js', () => ({
fetchDaemonStatus: mockFetchDaemonStatus,
isExtensionConnected: mockIsExtensionConnected,
}));

vi.mock('./browser/candidates.js', () => ({
getBrowserCandidates: mockGetBrowserCandidates,
launchBrowserCandidate: mockLaunchBrowserCandidate,
}));

import { BrowserBridge, generateStealthJs } from './browser/index.js';
import { extractTabEntries, diffTabIndexes, appendLimited } from './browser/tabs.js';
import { withTimeoutMs } from './runtime.js';
import { __test__ as cdpTest } from './browser/cdp.js';
import { isRetryableSettleError } from './browser/page.js';
import * as daemonClient from './browser/daemon-client.js';

describe('browser helpers', () => {
it('extracts tab entries from string snapshots', () => {
Expand Down Expand Up @@ -103,6 +124,14 @@ describe('browser helpers', () => {
});

describe('BrowserBridge state', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchDaemonStatus.mockReset();
mockIsExtensionConnected.mockReset();
mockGetBrowserCandidates.mockReset();
mockLaunchBrowserCandidate.mockReset();
});

it('transitions to closed after close()', async () => {
const bridge = new BrowserBridge();

Expand Down Expand Up @@ -135,13 +164,180 @@ describe('BrowserBridge state', () => {
});

it('fails fast when daemon is running but extension is disconnected', async () => {
vi.spyOn(daemonClient, 'isExtensionConnected').mockResolvedValue(false);
vi.spyOn(daemonClient, 'fetchDaemonStatus').mockResolvedValue({ extensionConnected: false } as any);
mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any);
mockIsExtensionConnected.mockResolvedValue(false);
mockGetBrowserCandidates.mockReturnValue([]);

const bridge = new BrowserBridge();

await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Extension is not connected');
});

it('tries detected browsers in order until the extension connects', async () => {
vi.useFakeTimers();
try {
mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any);
mockGetBrowserCandidates.mockReturnValue([
{ id: 'chrome', name: 'Chrome', executable: '/chrome', running: false },
{ id: 'edge', name: 'Edge', executable: '/edge', running: false },
]);
mockIsExtensionConnected.mockResolvedValue(false);
mockLaunchBrowserCandidate.mockImplementation(async (candidate: { id: string }) => {
if (candidate.id === 'edge') {
mockIsExtensionConnected.mockResolvedValue(true);
}
});

const bridge = new BrowserBridge();
const promise = bridge.connect({ timeout: 5 });

await vi.advanceTimersByTimeAsync(5000);
await promise;

expect(mockLaunchBrowserCandidate).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'chrome' }));
expect(mockLaunchBrowserCandidate).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: 'edge' }));
expect(bridge.inferredBrowserName).toBe('Edge');
} finally {
vi.useRealTimers();
}
});

it('waits on running browsers without launching them', async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(0);
mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any);
mockGetBrowserCandidates.mockReturnValue([
{ id: 'chrome', name: 'Chrome', executable: '/chrome', running: true },
{ id: 'edge', name: 'Edge', executable: '/edge', running: true },
{ id: 'chromium', name: 'Chromium', executable: '/chromium', running: false },
]);

let connected = false;
mockIsExtensionConnected.mockImplementation(async () => connected);
mockLaunchBrowserCandidate.mockResolvedValue(undefined);

const bridge = new BrowserBridge();
const promise = bridge.connect({ timeout: 5 });

setTimeout(() => {
connected = true;
}, 450);

await vi.advanceTimersByTimeAsync(5000);
await promise;

// Running browsers should not be launched
expect(mockLaunchBrowserCandidate).not.toHaveBeenCalled();
// Chrome is first running candidate being polled when extension connects
expect(bridge.inferredBrowserName).toBe('Chrome');
} finally {
vi.useRealTimers();
}
});

it('launches unopened browsers only after running browsers fail', async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(0);
mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any);
mockGetBrowserCandidates.mockReturnValue([
{ id: 'edge', name: 'Edge', executable: '/edge', running: true },
{ id: 'chromium', name: 'Chromium', executable: '/chromium', running: false },
]);

let connected = false;
mockIsExtensionConnected.mockImplementation(async () => connected);
mockLaunchBrowserCandidate.mockImplementation(async (candidate: { id: string }) => {
if (candidate.id === 'chromium') connected = true;
});

const bridge = new BrowserBridge();
const promise = bridge.connect({ timeout: 5 });

await vi.advanceTimersByTimeAsync(5000);
await promise;

expect(mockLaunchBrowserCandidate).toHaveBeenCalledTimes(1);
expect(mockLaunchBrowserCandidate).toHaveBeenCalledWith(expect.objectContaining({ id: 'chromium' }));
expect(bridge.inferredBrowserName).toBe('Chromium');
} finally {
vi.useRealTimers();
}
});

it('includes detected and tried browsers in the final error', async () => {
vi.useFakeTimers();
try {
mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any);
mockGetBrowserCandidates.mockReturnValue([
{ id: 'chrome', name: 'Chrome', executable: '/chrome', running: false },
{ id: 'edge', name: 'Edge', executable: '/edge', running: false },
]);
mockIsExtensionConnected.mockResolvedValue(false);

const bridge = new BrowserBridge();
let message = '';

const promise = bridge.connect({ timeout: 5 }).catch((error) => {
message = error instanceof Error ? error.message : String(error);
});

await vi.advanceTimersByTimeAsync(5000);
await promise;

expect(message).toContain('Detected browsers: Chrome, Edge');
expect(message).toContain('Tried browsers: Chrome, Edge');
} finally {
vi.useRealTimers();
}
});

it('honors short timeouts without waiting a full poll interval', async () => {
vi.useFakeTimers();
mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any);
mockGetBrowserCandidates.mockReturnValue([]);
mockIsExtensionConnected.mockResolvedValue(false);

const bridge = new BrowserBridge();
const promise = bridge.connect({ timeout: 0.05 });
const rejection = expect(promise).rejects.toThrow('Browser Extension is not connected');

await vi.advanceTimersByTimeAsync(60);
await rejection;

vi.useRealTimers();
});

it('does not count browser discovery time against trying later browsers', async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any);
mockGetBrowserCandidates.mockImplementation(() => {
vi.setSystemTime(800);
return [
{ id: 'chrome', name: 'Chrome', executable: '/chrome', running: false },
{ id: 'edge', name: 'Edge', executable: '/edge', running: false },
];
});
mockIsExtensionConnected.mockResolvedValue(false);
mockLaunchBrowserCandidate.mockImplementation(async (candidate: { id: string }) => {
if (candidate.id === 'edge') {
mockIsExtensionConnected.mockResolvedValue(true);
}
});

const bridge = new BrowserBridge();
const promise = bridge.connect({ timeout: 5 });

await vi.advanceTimersByTimeAsync(5000);
await promise;

expect(mockLaunchBrowserCandidate).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'chrome' }));
expect(mockLaunchBrowserCandidate).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: 'edge' }));

vi.useRealTimers();
});
});

describe('stealth anti-detection', () => {
Expand Down
Loading