@jackwener/opencli 1.2.6 → 1.3.1

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.
@@ -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
- // Post-load settle: the extension already waits for tab.status === 'complete',
45
- // but SPA frameworks (React/Vue) need extra time to render after DOM load.
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 settleMs = options?.settleMs ?? 1000;
48
- await new Promise(resolve => setTimeout(resolve, settleMs));
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 / setup / completion ─────────────────────────────────
188
+ // ── Built-in: doctor / completion ──────────────────────────────────────────
189
189
  program
190
190
  .command('doctor')
191
191
  .description('Diagnose opencli browser bridge connectivity')
192
- .option('--live', 'Test browser connectivity (requires Chrome running)', false)
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
- req.on('data', (c) => chunks.push(c));
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', 'Access-Control-Allow-Origin': '*' });
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
- res.setHeader('Access-Control-Allow-Origin', '*');
52
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
53
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
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({ server: httpServer, path: '/ext' });
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
- // Run the live connectivity check first it may auto-start the daemon as a
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: not tested (use --live)`);
91
+ lines.push(`${chalk.dim('[SKIP]')} Connectivity: skipped (--no-live)`);
80
92
  }
81
93
  if (report.sessions) {
82
94
  lines.push('', chalk.bold('Sessions:'));
@@ -67,29 +67,25 @@ describe('doctor report rendering', () => {
67
67
  extensionConnected: true,
68
68
  issues: [],
69
69
  }));
70
- expect(text).toContain('[SKIP] Connectivity: not tested (use --live)');
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
- // With the reordered flow, checkDaemonStatus is called only ONCE after
74
- // the connectivity check that may auto-start the daemon.
75
- mockCheckDaemonStatus.mockResolvedValueOnce({ running: true, extensionConnected: false });
76
- mockConnect.mockRejectedValueOnce(new Error('Daemon is running but the Browser Extension is not connected.\n' +
77
- 'Please install and enable the opencli Browser Bridge extension in Chrome.'));
78
- const report = await runBrowserDoctor({ live: true });
79
- // Status reflects the post-connectivity state (daemon running)
80
- expect(report.daemonRunning).toBe(true);
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 should only be called once
83
- expect(mockCheckDaemonStatus).toHaveBeenCalledTimes(1);
84
- // Should NOT report "daemon not running" since it IS running after live check
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('Chrome extension is not connected'),
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, validate, verify, help)
29
- │ └── output-formats.test.ts # 输出格式(json/yaml/csv/md)
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
- ├── *.test.ts # 单元测试(已有 8 个)
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
- ### 单元测试(8 个文件)
46
+ ### 单元测试(31 个文件)
47
47
 
48
- | 文件 | 覆盖内容 |
48
+ | 领域 | 文件 |
49
49
  |---|---|
50
- | `browser.test.ts` | JSON-RPC、tab 管理、extension/standalone 模式切换 |
51
- | `engine.test.ts` | 命令发现与执行 |
52
- | `registry.test.ts` | 命令注册与策略分配 |
53
- | `output.test.ts` | 输出格式渲染 |
54
- | `doctor.test.ts` | Token 诊断 |
55
- | `coupang.test.ts` | 数据归一化 |
56
- | `pipeline/template.test.ts` | 模板表达式求值 |
57
- | `pipeline/transform.test.ts` | 数据变换步骤 |
58
-
59
- ### E2E 测试(~52 个用例)
60
-
61
- | 文件 | 覆盖站点/功能 | 测试数 |
62
- |---|---|---|
63
- | `public-commands.test.ts` | hackernews/top, v2ex/hot, v2ex/latest, v2ex/topic | 5 |
64
- | `browser-public.test.ts` | bbc, bilibili×3, weibo, zhihu×2, reddit×2, twitter, xueqiu×2, reuters, youtube, smzdm, boss, ctrip, coupang, xiaohongshu, yahoo-finance, v2ex/daily | 21 |
65
- | `browser-auth.test.ts` | bilibili/me,dynamic,favorite,history,following + twitter/bookmarks,timeline,notifications + v2ex/me,notifications + xueqiu/feed,watchlist + xiaohongshu/feed,notifications | 14 |
66
- | `management.test.ts` | list×5 格式, validate×3 级别, verify, --version, --help, unknown cmd | 12 |
67
- | `output-formats.test.ts` | json, yaml, csv, md 格式验证 | 5 |
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
- 公开 API 可用性(hackernews, v2ex×2, v2ex/topic)+ 全站点注册完整性检查。
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
- # 全部测试(单元 + E2E)
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
- - `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬导致空数据时 warn + pass
110
- - `browser-auth.test.ts` 验证 **graceful failure**(不 crash 不 hang 即通过)
111
- - 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,手动跑对应测试
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. **无需额外操作**:`validate` 测试会自动覆盖 YAML 结构验证
120
- 2. 根据 adapter 类型,在对应文件加一个 `it()` block
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
- `src/` 下对应位置创建 `*.test.ts`。
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
- | **build** | push/PR to main,dev | typecheck + build |
178
- | **unit-test** | push/PR to main,dev | 单元测试,2 shard 并行 |
179
- | **smoke-test** | 每周一 08:00 UTC / 手动 | xvfb + real Chrome,外部 API 健康检查 |
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(E2E 测试)
198
+ ### `e2e-headed.yml`
182
199
 
183
200
  | Job | 触发条件 | 内容 |
184
201
  |---|---|---|
185
- | **e2e-headed** | push/PR to main,dev | xvfb + real Chrome,全部 E2E 测试 |
202
+ | `e2e-headed` | push/PR `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
186
203
 
187
- E2E 使用 `browser-actions/setup-chrome` 安装真实 Chrome,配合 `xvfb-run` 提供虚拟显示器,以 headed 模式运行浏览器。
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
- | 扩展未安装 | CLI 报错提示安装 | 需要安装 Browser Bridge 扩展 |
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
- GitHub Actions 美国 runner 上,部分站点因地域限制或登录要求返回空数据。E2E 测试对这些站点使用 warn + pass 策略,不影响 CI 绿灯。
245
+ GitHub Actions 的美国 runner 上,部分站点会因为地域限制、登录要求或反爬而返回空数据。当前 E2E 对这些场景采用 warn + pass 策略,避免偶发站点限制把整条 CI 打红。
228
246
 
229
- | 站点 | CI 状态 | 限制原因 |
247
+ | 站点 | CI 表现 | 常见原因 |
230
248
  |---|---|---|
231
- | hackernews, bbc, v2ex | 返回数据 | 无限制 |
232
- | yahoo-finance | 返回数据 | 无限制 |
233
- | bilibili, zhihu, weibo, xiaohongshu | ⚠️ 空数据 | 地域限制(中国站点) |
234
- | reddit, twitter, youtube | ⚠️ 空数据 | 需登录或 cookie |
235
- | smzdm, boss, ctrip, coupang, xueqiu | ⚠️ 空数据 | 地域限制 / 需登录 |
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
- > 使用 self-hosted runner(国内服务器)可解决地域限制问题。
255
+ > 如果需要更稳定的浏览器 E2E 结果,优先使用具备目标站点网络可达性的 self-hosted runner
@@ -23,7 +23,6 @@ That's it! The daemon auto-starts when you run any browser command. No tokens, n
23
23
 
24
24
  ```bash
25
25
  opencli doctor # Check extension + daemon connectivity
26
- opencli doctor --live # Also test live browser commands
27
26
  ```
28
27
 
29
28
  ## How It Works
@@ -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 setup` verifies Browser Bridge connectivity; `opencli doctor` diagnoses daemon, extension, and live browser connectivity.
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
 
@@ -53,4 +53,4 @@ npx tsc --noEmit
53
53
  ## Getting Help
54
54
 
55
55
  - [GitHub Issues](https://github.com/jackwener/opencli/issues) — Bug reports and feature requests
56
- - Run `opencli doctor --live` for comprehensive diagnostics
56
+ - Run `opencli doctor` for comprehensive diagnostics
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 setup verifies Browser Bridge connectivity. opencli doctor diagnoses daemon, extension, and live browser."
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.
@@ -21,5 +21,4 @@ OpenCLI 通过轻量级 **Browser Bridge** Chrome 扩展 + 微守护进程连接
21
21
 
22
22
  ```bash
23
23
  opencli doctor # 检查扩展 + 守护进程连接
24
- opencli doctor --live # 同时测试实时浏览器命令
25
24
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.2.6",
3
+ "version": "1.3.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
- // Post-load settle: SPA frameworks need extra time to render after load event
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 settleMs = options?.settleMs ?? 1000;
183
- await new Promise(resolve => setTimeout(resolve, settleMs));
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`, { signal: controller.signal });
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`, { signal: controller.signal });
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
  });
@@ -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 {