mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
5f59f9b563
* refactor: make daemon persistent, remove idle timeout - Remove IdleManager and 4-hour idle auto-exit - Daemon now stays alive until explicit shutdown or uninstall - Add preuninstall hook for best-effort daemon cleanup on npm uninstall - Update docs to reflect persistent daemon model * fix: remove stale idle timeout references from code and docs * refactor: remove daemon status/restart commands and lastCliRequestTime - Remove `daemon status` and `daemon restart` CLI commands (doctor covers diagnostics) - Remove `lastCliRequestTime` tracking (no longer needed without idle timeout) - Keep only `daemon stop` as the explicit shutdown command * Add AbortSignal.timeout(3s) to preuninstall shutdown fetch Prevents npm uninstall from hanging if the daemon port accepts connections but never responds.
216 lines
7.2 KiB
TypeScript
216 lines
7.2 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
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 { classifyBrowserError } from './browser/errors.js';
|
|
import * as daemonClient from './browser/daemon-client.js';
|
|
|
|
describe('browser helpers', () => {
|
|
it('extracts tab entries from string snapshots', () => {
|
|
const entries = extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
|
|
|
|
expect(entries).toEqual([
|
|
{ index: 0, identity: 'https://example.com' },
|
|
{ index: 1, identity: 'Chrome Extension' },
|
|
]);
|
|
});
|
|
|
|
it('extracts tab entries from MCP markdown format', () => {
|
|
const entries = extractTabEntries(
|
|
'- 0: (current) [Playwright MCP extension](chrome-extension://abc/connect.html)\n- 1: [知乎 - 首页](https://www.zhihu.com/)'
|
|
);
|
|
|
|
expect(entries).toEqual([
|
|
{ index: 0, identity: '(current) [Playwright MCP extension](chrome-extension://abc/connect.html)' },
|
|
{ index: 1, identity: '[知乎 - 首页](https://www.zhihu.com/)' },
|
|
]);
|
|
});
|
|
|
|
it('closes only tabs that were opened during the session', () => {
|
|
const tabsToClose = diffTabIndexes(
|
|
['https://example.com', 'Chrome Extension'],
|
|
[
|
|
{ index: 0, identity: 'https://example.com' },
|
|
{ index: 1, identity: 'Chrome Extension' },
|
|
{ index: 2, identity: 'https://target.example/page' },
|
|
{ index: 3, identity: 'chrome-extension://bridge' },
|
|
],
|
|
);
|
|
|
|
expect(tabsToClose).toEqual([3, 2]);
|
|
});
|
|
|
|
it('keeps only the tail of stderr buffers', () => {
|
|
expect(appendLimited('12345', '67890', 8)).toBe('34567890');
|
|
});
|
|
|
|
it('times out slow promises', async () => {
|
|
await expect(withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
|
|
});
|
|
|
|
it('classifies browser errors with correct kind and retry advice', () => {
|
|
// CDP target navigation — page-level settle retry
|
|
const nav = classifyBrowserError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'));
|
|
expect(nav.kind).toBe('target-navigation');
|
|
expect(nav.delayMs).toBe(200);
|
|
|
|
// Extension transient — daemon-client retry only, NOT page-level
|
|
const ext = classifyBrowserError(new Error('Extension disconnected'));
|
|
expect(ext.kind).toBe('extension-transient');
|
|
expect(ext.delayMs).toBe(1500);
|
|
|
|
// Non-transient errors — not retryable
|
|
expect(classifyBrowserError(new Error('malformed exec payload')).kind).toBe('non-retryable');
|
|
expect(classifyBrowserError(new Error('Permission denied')).kind).toBe('non-retryable');
|
|
});
|
|
|
|
it('prefers the real Electron app target over DevTools and blank pages', () => {
|
|
const target = cdpTest.selectCDPTarget([
|
|
{
|
|
type: 'page',
|
|
title: 'DevTools - localhost:9224',
|
|
url: 'devtools://devtools/bundled/inspector.html',
|
|
webSocketDebuggerUrl: 'ws://127.0.0.1:9224/devtools',
|
|
},
|
|
{
|
|
type: 'page',
|
|
title: '',
|
|
url: 'about:blank',
|
|
webSocketDebuggerUrl: 'ws://127.0.0.1:9224/blank',
|
|
},
|
|
{
|
|
type: 'app',
|
|
title: 'Antigravity',
|
|
url: 'http://localhost:3000/',
|
|
webSocketDebuggerUrl: 'ws://127.0.0.1:9224/app',
|
|
},
|
|
]);
|
|
|
|
expect(target?.webSocketDebuggerUrl).toBe('ws://127.0.0.1:9224/app');
|
|
});
|
|
|
|
it('honors OPENCLI_CDP_TARGET when multiple inspectable targets exist', () => {
|
|
vi.stubEnv('OPENCLI_CDP_TARGET', 'codex');
|
|
|
|
const target = cdpTest.selectCDPTarget([
|
|
{
|
|
type: 'app',
|
|
title: 'Cursor',
|
|
url: 'http://localhost:3000/cursor',
|
|
webSocketDebuggerUrl: 'ws://127.0.0.1:9226/cursor',
|
|
},
|
|
{
|
|
type: 'app',
|
|
title: 'OpenAI Codex',
|
|
url: 'http://localhost:3000/codex',
|
|
webSocketDebuggerUrl: 'ws://127.0.0.1:9226/codex',
|
|
},
|
|
]);
|
|
|
|
expect(target?.webSocketDebuggerUrl).toBe('ws://127.0.0.1:9226/codex');
|
|
});
|
|
});
|
|
|
|
describe('BrowserBridge state', () => {
|
|
it('transitions to closed after close()', async () => {
|
|
const bridge = new BrowserBridge();
|
|
|
|
expect(bridge.state).toBe('idle');
|
|
|
|
await bridge.close();
|
|
|
|
expect(bridge.state).toBe('closed');
|
|
});
|
|
|
|
it('rejects connect() after the session has been closed', async () => {
|
|
const bridge = new BrowserBridge();
|
|
await bridge.close();
|
|
|
|
await expect(bridge.connect()).rejects.toThrow('Session is closed');
|
|
});
|
|
|
|
it('rejects connect() while already connecting', async () => {
|
|
const bridge = new BrowserBridge();
|
|
(bridge as unknown as { _state: string })._state = 'connecting';
|
|
|
|
await expect(bridge.connect()).rejects.toThrow('Already connecting');
|
|
});
|
|
|
|
it('rejects connect() while closing', async () => {
|
|
const bridge = new BrowserBridge();
|
|
(bridge as unknown as { _state: string })._state = 'closing';
|
|
|
|
await expect(bridge.connect()).rejects.toThrow('Session is closing');
|
|
});
|
|
|
|
it('fails fast when daemon is running but extension is disconnected', async () => {
|
|
vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
|
|
state: 'no-extension',
|
|
status: {
|
|
ok: true,
|
|
pid: 1,
|
|
uptime: 0,
|
|
extensionConnected: false,
|
|
pending: 0,
|
|
memoryMB: 0,
|
|
port: 0,
|
|
},
|
|
});
|
|
|
|
const bridge = new BrowserBridge();
|
|
|
|
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected');
|
|
});
|
|
});
|
|
|
|
describe('stealth anti-detection', () => {
|
|
it('generates non-empty JS string', () => {
|
|
const js = generateStealthJs();
|
|
expect(typeof js).toBe('string');
|
|
expect(js.length).toBeGreaterThan(100);
|
|
});
|
|
|
|
it('contains all 7 anti-detection patches', () => {
|
|
const js = generateStealthJs();
|
|
// 1. webdriver
|
|
expect(js).toContain('navigator');
|
|
expect(js).toContain('webdriver');
|
|
// 2. chrome stub
|
|
expect(js).toContain('window.chrome');
|
|
// 3. plugins
|
|
expect(js).toContain('plugins');
|
|
expect(js).toContain('PDF Viewer');
|
|
// 4. languages
|
|
expect(js).toContain('languages');
|
|
// 5. permissions
|
|
expect(js).toContain('Permissions');
|
|
expect(js).toContain('notifications');
|
|
// 6. automation artifacts (dynamic cdc_ scan)
|
|
expect(js).toContain('__playwright');
|
|
expect(js).toContain('__puppeteer');
|
|
expect(js).toContain('getOwnPropertyNames');
|
|
expect(js).toContain('cdc_');
|
|
// 7. CDP stack trace cleanup
|
|
expect(js).toContain('Error.prototype');
|
|
expect(js).toContain('puppeteer_evaluation_script');
|
|
expect(js).toContain('getOwnPropertyDescriptor');
|
|
});
|
|
|
|
it('includes guard flag to prevent double-injection', () => {
|
|
const js = generateStealthJs();
|
|
// Guard uses a non-enumerable property on a built-in prototype
|
|
expect(js).toContain("EventTarget.prototype");
|
|
// Guard should check early and return 'skipped'
|
|
expect(js).toContain("return 'skipped'");
|
|
// Normal path returns 'applied'
|
|
expect(js).toContain("return 'applied'");
|
|
});
|
|
|
|
it('generates syntactically valid JS', () => {
|
|
const js = generateStealthJs();
|
|
// Should not throw when parsed
|
|
expect(() => new Function(js)).not.toThrow();
|
|
});
|
|
});
|