@jackwener/opencli 1.2.6 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +7 -0
- package/README.md +3 -64
- package/README.zh-CN.md +2 -48
- package/SKILL.md +1 -3
- package/TESTING.md +87 -69
- package/dist/browser/cdp.js +5 -4
- package/dist/browser/daemon-client.js +9 -3
- package/dist/browser/discover.js +3 -1
- package/dist/browser/dom-helpers.d.ts +8 -0
- package/dist/browser/dom-helpers.js +33 -0
- package/dist/browser/page.js +9 -5
- package/dist/cli.js +2 -9
- package/dist/daemon.d.ts +8 -0
- package/dist/daemon.js +53 -6
- package/dist/doctor.js +14 -2
- package/dist/doctor.test.js +15 -19
- package/docs/developer/testing.md +87 -69
- package/docs/guide/browser-bridge.md +0 -1
- package/docs/guide/getting-started.md +1 -1
- package/docs/guide/troubleshooting.md +1 -1
- package/docs/index.md +1 -1
- package/docs/zh/guide/browser-bridge.md +0 -1
- package/package.json +1 -1
- package/src/browser/cdp.ts +5 -3
- package/src/browser/daemon-client.ts +9 -3
- package/src/browser/discover.ts +3 -1
- package/src/browser/dom-helpers.ts +34 -0
- package/src/browser/page.ts +9 -4
- package/src/cli.ts +2 -10
- package/src/daemon.ts +57 -7
- package/src/doctor.test.ts +17 -25
- package/src/doctor.ts +14 -2
- package/dist/setup.d.ts +0 -10
- package/dist/setup.js +0 -66
- package/src/setup.ts +0 -69
package/dist/browser/page.js
CHANGED
|
@@ -13,7 +13,7 @@ import { formatSnapshot } from '../snapshotFormatter.js';
|
|
|
13
13
|
import { sendCommand } from './daemon-client.js';
|
|
14
14
|
import { wrapForEval } from './utils.js';
|
|
15
15
|
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
16
|
-
import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, scrollJs, autoScrollJs, networkRequestsJs, } from './dom-helpers.js';
|
|
16
|
+
import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
|
|
17
17
|
/**
|
|
18
18
|
* Page — implements IPage by talking to the daemon via HTTP.
|
|
19
19
|
*/
|
|
@@ -41,11 +41,15 @@ export class Page {
|
|
|
41
41
|
if (result?.tabId) {
|
|
42
42
|
this._tabId = result.tabId;
|
|
43
43
|
}
|
|
44
|
-
//
|
|
45
|
-
//
|
|
44
|
+
// Smart settle: use DOM stability detection instead of fixed sleep.
|
|
45
|
+
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
|
46
46
|
if (options?.waitUntil !== 'none') {
|
|
47
|
-
const
|
|
48
|
-
await
|
|
47
|
+
const maxMs = options?.settleMs ?? 1000;
|
|
48
|
+
await sendCommand('exec', {
|
|
49
|
+
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
|
|
50
|
+
...this._workspaceOpt(),
|
|
51
|
+
...this._tabOpt(),
|
|
52
|
+
});
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
55
|
/** Close the automation window in the extension */
|
package/dist/cli.js
CHANGED
|
@@ -185,24 +185,17 @@ export function runCli(BUILTIN_CLIS, USER_CLIS) {
|
|
|
185
185
|
}, { workspace });
|
|
186
186
|
console.log(renderCascadeResult(result));
|
|
187
187
|
});
|
|
188
|
-
// ── Built-in: doctor /
|
|
188
|
+
// ── Built-in: doctor / completion ──────────────────────────────────────────
|
|
189
189
|
program
|
|
190
190
|
.command('doctor')
|
|
191
191
|
.description('Diagnose opencli browser bridge connectivity')
|
|
192
|
-
.option('--live', '
|
|
192
|
+
.option('--no-live', 'Skip live browser connectivity test')
|
|
193
193
|
.option('--sessions', 'Show active automation sessions', false)
|
|
194
194
|
.action(async (opts) => {
|
|
195
195
|
const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
|
|
196
196
|
const report = await runBrowserDoctor({ live: opts.live, sessions: opts.sessions, cliVersion: PKG_VERSION });
|
|
197
197
|
console.log(renderBrowserDoctorReport(report));
|
|
198
198
|
});
|
|
199
|
-
program
|
|
200
|
-
.command('setup')
|
|
201
|
-
.description('Interactive setup: verify browser bridge connectivity')
|
|
202
|
-
.action(async () => {
|
|
203
|
-
const { runSetup } = await import('./setup.js');
|
|
204
|
-
await runSetup({ cliVersion: PKG_VERSION });
|
|
205
|
-
});
|
|
206
199
|
program
|
|
207
200
|
.command('completion')
|
|
208
201
|
.description('Output shell completion script')
|
package/dist/daemon.d.ts
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
* CLI → HTTP POST /command → daemon → WebSocket → Extension
|
|
6
6
|
* Extension → WebSocket result → daemon → HTTP response → CLI
|
|
7
7
|
*
|
|
8
|
+
* Security (defense-in-depth against browser-based CSRF):
|
|
9
|
+
* 1. Origin check — reject HTTP/WS from non chrome-extension:// origins
|
|
10
|
+
* 2. Custom header — require X-OpenCLI header (browsers can't send it
|
|
11
|
+
* without CORS preflight, which we deny)
|
|
12
|
+
* 3. No CORS headers — responses never include Access-Control-Allow-Origin
|
|
13
|
+
* 4. Body size limit — 1 MB max to prevent OOM
|
|
14
|
+
* 5. WebSocket verifyClient — reject upgrade before connection is established
|
|
15
|
+
*
|
|
8
16
|
* Lifecycle:
|
|
9
17
|
* - Auto-spawned by opencli on first browser command
|
|
10
18
|
* - Auto-exits after 5 minutes of idle
|
package/dist/daemon.js
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
* CLI → HTTP POST /command → daemon → WebSocket → Extension
|
|
6
6
|
* Extension → WebSocket result → daemon → HTTP response → CLI
|
|
7
7
|
*
|
|
8
|
+
* Security (defense-in-depth against browser-based CSRF):
|
|
9
|
+
* 1. Origin check — reject HTTP/WS from non chrome-extension:// origins
|
|
10
|
+
* 2. Custom header — require X-OpenCLI header (browsers can't send it
|
|
11
|
+
* without CORS preflight, which we deny)
|
|
12
|
+
* 3. No CORS headers — responses never include Access-Control-Allow-Origin
|
|
13
|
+
* 4. Body size limit — 1 MB max to prevent OOM
|
|
14
|
+
* 5. WebSocket verifyClient — reject upgrade before connection is established
|
|
15
|
+
*
|
|
8
16
|
* Lifecycle:
|
|
9
17
|
* - Auto-spawned by opencli on first browser command
|
|
10
18
|
* - Auto-exits after 5 minutes of idle
|
|
@@ -35,27 +43,55 @@ function resetIdleTimer() {
|
|
|
35
43
|
}, IDLE_TIMEOUT);
|
|
36
44
|
}
|
|
37
45
|
// ─── HTTP Server ─────────────────────────────────────────────────────
|
|
46
|
+
const MAX_BODY = 1024 * 1024; // 1 MB — commands are tiny; this prevents OOM
|
|
38
47
|
function readBody(req) {
|
|
39
48
|
return new Promise((resolve, reject) => {
|
|
40
49
|
const chunks = [];
|
|
41
|
-
|
|
50
|
+
let size = 0;
|
|
51
|
+
req.on('data', (c) => {
|
|
52
|
+
size += c.length;
|
|
53
|
+
if (size > MAX_BODY) {
|
|
54
|
+
req.destroy();
|
|
55
|
+
reject(new Error('Body too large'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
chunks.push(c);
|
|
59
|
+
});
|
|
42
60
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
43
61
|
req.on('error', reject);
|
|
44
62
|
});
|
|
45
63
|
}
|
|
46
64
|
function jsonResponse(res, status, data) {
|
|
47
|
-
res.writeHead(status, { 'Content-Type': 'application/json'
|
|
65
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
48
66
|
res.end(JSON.stringify(data));
|
|
49
67
|
}
|
|
50
68
|
async function handleRequest(req, res) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
// ─── Security: Origin & custom-header check ──────────────────────
|
|
70
|
+
// Block browser-based CSRF: browsers always send an Origin header on
|
|
71
|
+
// cross-origin requests. Node.js CLI fetch does NOT send Origin, so
|
|
72
|
+
// legitimate CLI requests pass through. Chrome Extension connects via
|
|
73
|
+
// WebSocket (which bypasses this HTTP handler entirely).
|
|
74
|
+
const origin = req.headers['origin'];
|
|
75
|
+
if (origin && !origin.startsWith('chrome-extension://')) {
|
|
76
|
+
jsonResponse(res, 403, { ok: false, error: 'Forbidden: cross-origin request blocked' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// CORS: do NOT send Access-Control-Allow-Origin for normal requests.
|
|
80
|
+
// Only handle preflight so browsers get a definitive "no" answer.
|
|
54
81
|
if (req.method === 'OPTIONS') {
|
|
82
|
+
// No ACAO header → browser will block the actual request.
|
|
55
83
|
res.writeHead(204);
|
|
56
84
|
res.end();
|
|
57
85
|
return;
|
|
58
86
|
}
|
|
87
|
+
// Require custom header on all HTTP requests. Browsers cannot attach
|
|
88
|
+
// custom headers in "simple" requests, and our preflight returns no
|
|
89
|
+
// Access-Control-Allow-Headers, so scripted fetch() from web pages is
|
|
90
|
+
// blocked even if Origin check is somehow bypassed.
|
|
91
|
+
if (!req.headers['x-opencli']) {
|
|
92
|
+
jsonResponse(res, 403, { ok: false, error: 'Forbidden: missing X-OpenCLI header' });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
59
95
|
const url = req.url ?? '/';
|
|
60
96
|
const pathname = url.split('?')[0];
|
|
61
97
|
if (req.method === 'GET' && pathname === '/status') {
|
|
@@ -114,7 +150,18 @@ async function handleRequest(req, res) {
|
|
|
114
150
|
}
|
|
115
151
|
// ─── WebSocket for Extension ─────────────────────────────────────────
|
|
116
152
|
const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); });
|
|
117
|
-
const wss = new WebSocketServer({
|
|
153
|
+
const wss = new WebSocketServer({
|
|
154
|
+
server: httpServer,
|
|
155
|
+
path: '/ext',
|
|
156
|
+
verifyClient: ({ req }) => {
|
|
157
|
+
// Block browser-originated WebSocket connections. Browsers don't
|
|
158
|
+
// enforce CORS on WebSocket, so a malicious webpage could connect to
|
|
159
|
+
// ws://localhost:19825/ext and impersonate the Extension. Real Chrome
|
|
160
|
+
// Extensions send origin chrome-extension://<id>.
|
|
161
|
+
const origin = req.headers['origin'];
|
|
162
|
+
return !origin || origin.startsWith('chrome-extension://');
|
|
163
|
+
},
|
|
164
|
+
});
|
|
118
165
|
wss.on('connection', (ws) => {
|
|
119
166
|
console.error('[daemon] Extension connected');
|
|
120
167
|
extensionWs = ws;
|
package/dist/doctor.js
CHANGED
|
@@ -26,7 +26,19 @@ export async function checkConnectivity(opts) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
export async function runBrowserDoctor(opts = {}) {
|
|
29
|
-
//
|
|
29
|
+
// Try to auto-start daemon if it's not running, so we show accurate status.
|
|
30
|
+
let initialStatus = await checkDaemonStatus();
|
|
31
|
+
if (!initialStatus.running) {
|
|
32
|
+
try {
|
|
33
|
+
const mcp = new BrowserBridge();
|
|
34
|
+
await mcp.connect({ timeout: 5 });
|
|
35
|
+
await mcp.close();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Auto-start failed; we'll report it below.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Run the live connectivity check — it may also auto-start the daemon as a
|
|
30
42
|
// side-effect, so we read daemon status only *after* all side-effects settle.
|
|
31
43
|
let connectivity;
|
|
32
44
|
if (opts.live) {
|
|
@@ -76,7 +88,7 @@ export function renderBrowserDoctorReport(report) {
|
|
|
76
88
|
lines.push(`${connIcon} Connectivity: ${detail}`);
|
|
77
89
|
}
|
|
78
90
|
else {
|
|
79
|
-
lines.push(`${chalk.dim('[SKIP]')} Connectivity:
|
|
91
|
+
lines.push(`${chalk.dim('[SKIP]')} Connectivity: skipped (--no-live)`);
|
|
80
92
|
}
|
|
81
93
|
if (report.sessions) {
|
|
82
94
|
lines.push('', chalk.bold('Sessions:'));
|
package/dist/doctor.test.js
CHANGED
|
@@ -67,29 +67,25 @@ describe('doctor report rendering', () => {
|
|
|
67
67
|
extensionConnected: true,
|
|
68
68
|
issues: [],
|
|
69
69
|
}));
|
|
70
|
-
expect(text).toContain('[SKIP] Connectivity:
|
|
70
|
+
expect(text).toContain('[SKIP] Connectivity: skipped (--no-live)');
|
|
71
71
|
});
|
|
72
72
|
it('reports consistent status when live check auto-starts the daemon', async () => {
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
mockCheckDaemonStatus.mockResolvedValueOnce({ running:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
73
|
+
// checkDaemonStatus is called twice: once for auto-start check, once for final status.
|
|
74
|
+
// First call: daemon not running (triggers auto-start attempt)
|
|
75
|
+
mockCheckDaemonStatus.mockResolvedValueOnce({ running: false, extensionConnected: false });
|
|
76
|
+
// Auto-start attempt via BrowserBridge.connect fails
|
|
77
|
+
mockConnect.mockRejectedValueOnce(new Error('Could not start daemon'));
|
|
78
|
+
// Second call: daemon still not running after failed auto-start
|
|
79
|
+
mockCheckDaemonStatus.mockResolvedValueOnce({ running: false, extensionConnected: false });
|
|
80
|
+
const report = await runBrowserDoctor({ live: false });
|
|
81
|
+
// Status reflects daemon not running
|
|
82
|
+
expect(report.daemonRunning).toBe(false);
|
|
81
83
|
expect(report.extensionConnected).toBe(false);
|
|
82
|
-
// checkDaemonStatus
|
|
83
|
-
expect(mockCheckDaemonStatus).toHaveBeenCalledTimes(
|
|
84
|
-
// Should
|
|
85
|
-
expect(report.issues).not.toContain(expect.stringContaining('Daemon is not running'));
|
|
86
|
-
// Should report extension not connected
|
|
84
|
+
// checkDaemonStatus called twice (initial + final)
|
|
85
|
+
expect(mockCheckDaemonStatus).toHaveBeenCalledTimes(2);
|
|
86
|
+
// Should report daemon not running
|
|
87
87
|
expect(report.issues).toEqual(expect.arrayContaining([
|
|
88
|
-
expect.stringContaining('
|
|
89
|
-
]));
|
|
90
|
-
// Should report connectivity failure
|
|
91
|
-
expect(report.issues).toEqual(expect.arrayContaining([
|
|
92
|
-
expect.stringContaining('Browser connectivity test failed'),
|
|
88
|
+
expect.stringContaining('Daemon is not running'),
|
|
93
89
|
]));
|
|
94
90
|
});
|
|
95
91
|
});
|
|
@@ -18,57 +18,72 @@
|
|
|
18
18
|
|
|
19
19
|
测试分为三层,全部使用 **vitest** 运行:
|
|
20
20
|
|
|
21
|
-
```
|
|
21
|
+
```text
|
|
22
22
|
tests/
|
|
23
23
|
├── e2e/ # E2E 集成测试(子进程运行真实 CLI)
|
|
24
|
-
│ ├── helpers.ts # runCli() 共享工具
|
|
25
|
-
│ ├── public-commands.test.ts # 公开 API
|
|
24
|
+
│ ├── helpers.ts # runCli() / parseJsonOutput() 共享工具
|
|
25
|
+
│ ├── public-commands.test.ts # 公开 API 命令
|
|
26
26
|
│ ├── browser-public.test.ts # 浏览器命令(公开数据)
|
|
27
|
-
│ ├── browser-auth.test.ts # 需登录命令(graceful failure
|
|
28
|
-
│ ├── management.test.ts # 管理命令(list
|
|
29
|
-
│ └── output-formats.test.ts #
|
|
30
|
-
├── smoke/
|
|
31
|
-
│ └── api-health.test.ts # 外部 API
|
|
27
|
+
│ ├── browser-auth.test.ts # 需登录命令(graceful failure)
|
|
28
|
+
│ ├── management.test.ts # 管理命令(list / validate / verify / help)
|
|
29
|
+
│ └── output-formats.test.ts # 输出格式校验
|
|
30
|
+
├── smoke/
|
|
31
|
+
│ └── api-health.test.ts # 外部 API、adapter 定义、命令注册健康检查
|
|
32
32
|
src/
|
|
33
|
-
|
|
33
|
+
└── **/*.test.ts # 单元测试(当前 31 个文件)
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
| 层 | 位置 | 运行方式 | 用途 |
|
|
37
|
-
|
|
38
|
-
| 单元测试 | `src/**/*.test.ts` | `npx vitest run src/` |
|
|
39
|
-
| E2E 测试 | `tests/e2e/*.test.ts` | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
|
|
40
|
-
| 烟雾测试 | `tests/smoke/*.test.ts` | `npx vitest run tests/smoke/` | 外部 API
|
|
36
|
+
| 层 | 位置 | 当前文件数 | 运行方式 | 用途 |
|
|
37
|
+
|---|---|---:|---|---|
|
|
38
|
+
| 单元测试 | `src/**/*.test.ts` | 31 | `npx vitest run src/` | 内部模块、pipeline、adapter 工具函数 |
|
|
39
|
+
| E2E 测试 | `tests/e2e/*.test.ts` | 5 | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
|
|
40
|
+
| 烟雾测试 | `tests/smoke/*.test.ts` | 1 | `npx vitest run tests/smoke/` | 外部 API 与注册完整性 |
|
|
41
41
|
|
|
42
42
|
---
|
|
43
43
|
|
|
44
44
|
## 当前覆盖范围
|
|
45
45
|
|
|
46
|
-
### 单元测试(
|
|
46
|
+
### 单元测试(31 个文件)
|
|
47
47
|
|
|
48
|
-
|
|
|
48
|
+
| 领域 | 文件 |
|
|
49
49
|
|---|---|
|
|
50
|
-
| `browser.test.ts`
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
|
65
|
-
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
50
|
+
| 核心运行时与输出 | `src/browser.test.ts`, `src/browser/dom-snapshot.test.ts`, `src/build-manifest.test.ts`, `src/capabilityRouting.test.ts`, `src/doctor.test.ts`, `src/engine.test.ts`, `src/interceptor.test.ts`, `src/output.test.ts`, `src/plugin.test.ts`, `src/registry.test.ts`, `src/snapshotFormatter.test.ts` |
|
|
51
|
+
| pipeline 与下载 | `src/download/index.test.ts`, `src/pipeline/executor.test.ts`, `src/pipeline/template.test.ts`, `src/pipeline/transform.test.ts` |
|
|
52
|
+
| 站点 / adapter 逻辑 | `src/clis/apple-podcasts/commands.test.ts`, `src/clis/apple-podcasts/utils.test.ts`, `src/clis/bloomberg/utils.test.ts`, `src/clis/chaoxing/utils.test.ts`, `src/clis/coupang/utils.test.ts`, `src/clis/google/utils.test.ts`, `src/clis/grok/ask.test.ts`, `src/clis/twitter/timeline.test.ts`, `src/clis/weread/utils.test.ts`, `src/clis/xiaohongshu/creator-note-detail.test.ts`, `src/clis/xiaohongshu/creator-notes-summary.test.ts`, `src/clis/xiaohongshu/creator-notes.test.ts`, `src/clis/xiaohongshu/user-helpers.test.ts`, `src/clis/xiaoyuzhou/utils.test.ts`, `src/clis/youtube/transcript-group.test.ts`, `src/clis/zhihu/download.test.ts` |
|
|
53
|
+
|
|
54
|
+
这些测试覆盖的重点包括:
|
|
55
|
+
|
|
56
|
+
- Browser Bridge、DOM snapshot、interceptor、capability routing
|
|
57
|
+
- manifest 生成、命令发现、插件安装与注册表
|
|
58
|
+
- 输出格式渲染与 snapshot formatting
|
|
59
|
+
- pipeline 模板求值、执行器与变换步骤
|
|
60
|
+
- 各站点 adapter 的数据归一化、参数处理与容错逻辑
|
|
61
|
+
|
|
62
|
+
### E2E 测试(5 个文件)
|
|
63
|
+
|
|
64
|
+
| 文件 | 当前覆盖范围 |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `tests/e2e/public-commands.test.ts` | `bloomberg`、`apple-podcasts`、`hackernews`、`v2ex`、`xiaoyuzhou`、`google suggest` 等公开命令 |
|
|
67
|
+
| `tests/e2e/browser-public.test.ts` | `bbc`、`bloomberg`、`bilibili`、`weibo`、`zhihu`、`reddit`、`twitter`、`xueqiu`、`reuters`、`youtube`、`smzdm`、`boss`、`ctrip`、`coupang`、`xiaohongshu`、`google`、`yahoo-finance`、`v2ex daily` |
|
|
68
|
+
| `tests/e2e/browser-auth.test.ts` | `bilibili`、`twitter`、`v2ex`、`xueqiu`、`linux-do`、`xiaohongshu` 的需登录命令 graceful failure |
|
|
69
|
+
| `tests/e2e/management.test.ts` | `list`、`validate`、`verify`、`--version`、`--help`、unknown command |
|
|
70
|
+
| `tests/e2e/output-formats.test.ts` | `json` / `yaml` / `csv` / `md` 输出格式校验 |
|
|
71
|
+
|
|
72
|
+
### 烟雾测试(1 个文件)
|
|
73
|
+
|
|
74
|
+
| 文件 | 当前覆盖范围 |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `tests/smoke/api-health.test.ts` | `hackernews`、`v2ex` 公开 API 可用性,`validate` 全量 adapter 校验,以及命令注册表基础完整性 |
|
|
68
77
|
|
|
69
|
-
###
|
|
78
|
+
### 快速核对命令
|
|
70
79
|
|
|
71
|
-
|
|
80
|
+
需要刷新测试清单时,直接以仓库文件为准:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
find src -name '*.test.ts' | sort
|
|
84
|
+
find tests/e2e -name '*.test.ts' | sort
|
|
85
|
+
find tests/smoke -name '*.test.ts' | sort
|
|
86
|
+
```
|
|
72
87
|
|
|
73
88
|
---
|
|
74
89
|
|
|
@@ -78,7 +93,7 @@ src/
|
|
|
78
93
|
|
|
79
94
|
```bash
|
|
80
95
|
npm ci # 安装依赖
|
|
81
|
-
npm run build # 编译(E2E 测试需要 dist/main.js)
|
|
96
|
+
npm run build # 编译(E2E / smoke 测试需要 dist/main.js)
|
|
82
97
|
```
|
|
83
98
|
|
|
84
99
|
### 运行命令
|
|
@@ -87,18 +102,19 @@ npm run build # 编译(E2E 测试需要 dist/main.js)
|
|
|
87
102
|
# 全部单元测试
|
|
88
103
|
npx vitest run src/
|
|
89
104
|
|
|
90
|
-
# 全部 E2E 测试(会真实调用外部 API
|
|
105
|
+
# 全部 E2E 测试(会真实调用外部 API / 浏览器)
|
|
91
106
|
npx vitest run tests/e2e/
|
|
92
107
|
|
|
108
|
+
# 全部 smoke 测试
|
|
109
|
+
npx vitest run tests/smoke/
|
|
110
|
+
|
|
93
111
|
# 单个测试文件
|
|
112
|
+
npx vitest run src/clis/apple-podcasts/commands.test.ts
|
|
94
113
|
npx vitest run tests/e2e/management.test.ts
|
|
95
114
|
|
|
96
|
-
#
|
|
115
|
+
# 全部测试
|
|
97
116
|
npx vitest run
|
|
98
117
|
|
|
99
|
-
# 烟雾测试
|
|
100
|
-
npx vitest run tests/smoke/
|
|
101
|
-
|
|
102
118
|
# watch 模式(开发时推荐)
|
|
103
119
|
npx vitest src/
|
|
104
120
|
```
|
|
@@ -106,9 +122,10 @@ npx vitest src/
|
|
|
106
122
|
### 浏览器命令本地测试须知
|
|
107
123
|
|
|
108
124
|
- opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器
|
|
109
|
-
- `
|
|
110
|
-
- `browser-
|
|
111
|
-
-
|
|
125
|
+
- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/main.js`
|
|
126
|
+
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬或地域限制导致空数据时会 warn + pass
|
|
127
|
+
- `browser-auth.test.ts` 验证 **graceful failure**,重点是不 crash、不 hang、错误信息可控
|
|
128
|
+
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,再手动运行对应测试
|
|
112
129
|
|
|
113
130
|
---
|
|
114
131
|
|
|
@@ -116,8 +133,8 @@ npx vitest src/
|
|
|
116
133
|
|
|
117
134
|
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`)
|
|
118
135
|
|
|
119
|
-
1.
|
|
120
|
-
2. 根据 adapter
|
|
136
|
+
1. `opencli validate` 的 E2E / smoke 测试会覆盖 adapter 结构校验
|
|
137
|
+
2. 根据 adapter 类型,在对应测试文件补一个 `it()` block
|
|
121
138
|
|
|
122
139
|
```typescript
|
|
123
140
|
// 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts
|
|
@@ -148,15 +165,15 @@ it('producthunt me fails gracefully without login', async () => {
|
|
|
148
165
|
|
|
149
166
|
### 新增管理命令(如 `opencli export`)
|
|
150
167
|
|
|
151
|
-
在 `tests/e2e/management.test.ts`
|
|
168
|
+
在 `tests/e2e/management.test.ts` 添加测试;如果新命令会影响输出格式,也同步补 `tests/e2e/output-formats.test.ts`。
|
|
152
169
|
|
|
153
170
|
### 新增内部模块
|
|
154
171
|
|
|
155
|
-
|
|
172
|
+
在对应源码旁创建 `*.test.ts`,优先和被测模块放在同一目录下,便于发现与维护。
|
|
156
173
|
|
|
157
174
|
### 决策流程图
|
|
158
175
|
|
|
159
|
-
```
|
|
176
|
+
```text
|
|
160
177
|
新增功能 → 是内部模块? → 是 → src/ 下加 *.test.ts
|
|
161
178
|
↓ 否
|
|
162
179
|
是 CLI 命令? → browser: false? → tests/e2e/public-commands.test.ts
|
|
@@ -170,33 +187,34 @@ it('producthunt me fails gracefully without login', async () => {
|
|
|
170
187
|
|
|
171
188
|
## CI/CD 流水线
|
|
172
189
|
|
|
173
|
-
### ci.yml
|
|
190
|
+
### `ci.yml`
|
|
174
191
|
|
|
175
192
|
| Job | 触发条件 | 内容 |
|
|
176
193
|
|---|---|---|
|
|
177
|
-
|
|
|
178
|
-
|
|
|
179
|
-
|
|
|
194
|
+
| `build` | push/PR 到 `main`,`dev` | `tsc --noEmit` + `npm run build` |
|
|
195
|
+
| `unit-test` | push/PR 到 `main`,`dev` | Node `20` 与 `22` 双版本运行 `src/` 单元测试,按 `2` shard 并行 |
|
|
196
|
+
| `smoke-test` | `schedule` 或 `workflow_dispatch` | 安装真实 Chrome,`xvfb-run` 执行 `tests/smoke/` |
|
|
180
197
|
|
|
181
|
-
### e2e-headed.yml
|
|
198
|
+
### `e2e-headed.yml`
|
|
182
199
|
|
|
183
200
|
| Job | 触发条件 | 内容 |
|
|
184
201
|
|---|---|---|
|
|
185
|
-
|
|
|
202
|
+
| `e2e-headed` | push/PR 到 `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
|
|
186
203
|
|
|
187
|
-
E2E
|
|
204
|
+
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome,并通过 `OPENCLI_BROWSER_EXECUTABLE_PATH` 注入浏览器路径。
|
|
188
205
|
|
|
189
206
|
### Sharding
|
|
190
207
|
|
|
191
|
-
单元测试使用 vitest 内置 shard
|
|
208
|
+
单元测试使用 vitest 内置 shard,并在 Node `20` / `22` 两个版本上运行:
|
|
192
209
|
|
|
193
210
|
::: v-pre
|
|
194
211
|
```yaml
|
|
195
212
|
strategy:
|
|
196
213
|
matrix:
|
|
214
|
+
node-version: ['20', '22']
|
|
197
215
|
shard: [1, 2]
|
|
198
216
|
steps:
|
|
199
|
-
- run: npx vitest run src/ --shard=${{ matrix.shard }}/2
|
|
217
|
+
- run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
|
|
200
218
|
```
|
|
201
219
|
:::
|
|
202
220
|
|
|
@@ -208,8 +226,8 @@ opencli 通过 Browser Bridge 扩展连接浏览器:
|
|
|
208
226
|
|
|
209
227
|
| 条件 | 模式 | 使用场景 |
|
|
210
228
|
|---|---|---|
|
|
211
|
-
| 扩展已安装 | Extension 模式 | 本地用户,连接已登录的 Chrome |
|
|
212
|
-
|
|
|
229
|
+
| 扩展已安装 / 已连接 | Extension 模式 | 本地用户,连接已登录的 Chrome |
|
|
230
|
+
| 无扩展 token | CLI 自行拉起浏览器 | CI、无登录态或纯自动化场景 |
|
|
213
231
|
|
|
214
232
|
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
|
|
215
233
|
|
|
@@ -224,14 +242,14 @@ env:
|
|
|
224
242
|
|
|
225
243
|
## 站点兼容性
|
|
226
244
|
|
|
227
|
-
|
|
245
|
+
GitHub Actions 的美国 runner 上,部分站点会因为地域限制、登录要求或反爬而返回空数据。当前 E2E 对这些场景采用 warn + pass 策略,避免偶发站点限制把整条 CI 打红。
|
|
228
246
|
|
|
229
|
-
| 站点 | CI
|
|
247
|
+
| 站点 | CI 表现 | 常见原因 |
|
|
230
248
|
|---|---|---|
|
|
231
|
-
| hackernews
|
|
232
|
-
| yahoo-finance |
|
|
233
|
-
| bilibili
|
|
234
|
-
| reddit
|
|
235
|
-
| smzdm
|
|
249
|
+
| `hackernews`、`bbc`、`v2ex`、`bloomberg` | 通常返回数据 | 公开接口或公开页面 |
|
|
250
|
+
| `yahoo-finance`、`google` | 通常返回数据 | 页面公开,但仍可能受限流影响 |
|
|
251
|
+
| `bilibili`、`zhihu`、`weibo`、`xiaohongshu`、`xueqiu` | 容易空数据 | 地域限制、反爬、登录要求 |
|
|
252
|
+
| `reddit`、`twitter`、`youtube` | 容易空数据 | 登录态、cookie、机器人检测 |
|
|
253
|
+
| `smzdm`、`boss`、`ctrip`、`coupang`、`linux-do` | 结果波动较大 | 地域限制、风控或页面结构变动 |
|
|
236
254
|
|
|
237
|
-
>
|
|
255
|
+
> 如果需要更稳定的浏览器 E2E 结果,优先使用具备目标站点网络可达性的 self-hosted runner。
|
|
@@ -14,7 +14,7 @@ OpenCLI turns **any website** or **Electron app** into a command-line interface
|
|
|
14
14
|
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
|
|
15
15
|
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
|
|
16
16
|
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
|
|
17
|
-
- **Self-healing setup** — `opencli
|
|
17
|
+
- **Self-healing setup** — `opencli doctor` auto-starts the daemon and diagnoses extension + live browser connectivity.
|
|
18
18
|
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
|
|
19
19
|
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
|
|
20
20
|
|
package/docs/index.md
CHANGED
|
@@ -28,7 +28,7 @@ features:
|
|
|
28
28
|
details: Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections for maximum flexibility.
|
|
29
29
|
- icon: 🔧
|
|
30
30
|
title: Self-Healing Setup
|
|
31
|
-
details: "opencli
|
|
31
|
+
details: "opencli doctor auto-starts the daemon and diagnoses extension + live browser connectivity."
|
|
32
32
|
- icon: 📦
|
|
33
33
|
title: Dynamic Loader
|
|
34
34
|
details: Simply drop .ts or .yaml adapters into the clis/ folder for auto-registration. Zero boilerplate.
|
package/package.json
CHANGED
package/src/browser/cdp.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
scrollJs,
|
|
21
21
|
autoScrollJs,
|
|
22
22
|
networkRequestsJs,
|
|
23
|
+
waitForDomStableJs,
|
|
23
24
|
} from './dom-helpers.js';
|
|
24
25
|
|
|
25
26
|
export interface CDPTarget {
|
|
@@ -177,10 +178,11 @@ class CDPPage implements IPage {
|
|
|
177
178
|
.catch(() => {}); // Don't fail if event times out
|
|
178
179
|
await this.bridge.send('Page.navigate', { url });
|
|
179
180
|
await loadPromise;
|
|
180
|
-
//
|
|
181
|
+
// Smart settle: use DOM stability detection instead of fixed sleep.
|
|
182
|
+
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
|
181
183
|
if (options?.waitUntil !== 'none') {
|
|
182
|
-
const
|
|
183
|
-
await
|
|
184
|
+
const maxMs = options?.settleMs ?? 1000;
|
|
185
|
+
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
|
|
184
186
|
}
|
|
185
187
|
}
|
|
186
188
|
|
|
@@ -44,7 +44,10 @@ export async function isDaemonRunning(): Promise<boolean> {
|
|
|
44
44
|
try {
|
|
45
45
|
const controller = new AbortController();
|
|
46
46
|
const timer = setTimeout(() => controller.abort(), 2000);
|
|
47
|
-
const res = await fetch(`${DAEMON_URL}/status`, {
|
|
47
|
+
const res = await fetch(`${DAEMON_URL}/status`, {
|
|
48
|
+
headers: { 'X-OpenCLI': '1' },
|
|
49
|
+
signal: controller.signal,
|
|
50
|
+
});
|
|
48
51
|
clearTimeout(timer);
|
|
49
52
|
return res.ok;
|
|
50
53
|
} catch {
|
|
@@ -59,7 +62,10 @@ export async function isExtensionConnected(): Promise<boolean> {
|
|
|
59
62
|
try {
|
|
60
63
|
const controller = new AbortController();
|
|
61
64
|
const timer = setTimeout(() => controller.abort(), 2000);
|
|
62
|
-
const res = await fetch(`${DAEMON_URL}/status`, {
|
|
65
|
+
const res = await fetch(`${DAEMON_URL}/status`, {
|
|
66
|
+
headers: { 'X-OpenCLI': '1' },
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
});
|
|
63
69
|
clearTimeout(timer);
|
|
64
70
|
if (!res.ok) return false;
|
|
65
71
|
const data = await res.json() as { extensionConnected?: boolean };
|
|
@@ -90,7 +96,7 @@ export async function sendCommand(
|
|
|
90
96
|
|
|
91
97
|
const res = await fetch(`${DAEMON_URL}/command`, {
|
|
92
98
|
method: 'POST',
|
|
93
|
-
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
headers: { 'Content-Type': 'application/json', 'X-OpenCLI': '1' },
|
|
94
100
|
body: JSON.stringify(command),
|
|
95
101
|
signal: controller.signal,
|
|
96
102
|
});
|
package/src/browser/discover.ts
CHANGED
|
@@ -18,7 +18,9 @@ export async function checkDaemonStatus(): Promise<{
|
|
|
18
18
|
}> {
|
|
19
19
|
try {
|
|
20
20
|
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
|
|
21
|
-
const res = await fetch(`http://127.0.0.1:${port}/status
|
|
21
|
+
const res = await fetch(`http://127.0.0.1:${port}/status`, {
|
|
22
|
+
headers: { 'X-OpenCLI': '1' },
|
|
23
|
+
});
|
|
22
24
|
const data = await res.json() as { ok: boolean; extensionConnected: boolean };
|
|
23
25
|
return { running: true, extensionConnected: data.extensionConnected };
|
|
24
26
|
} catch {
|