@jackwener/opencli 1.5.1 → 1.5.3

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.
Files changed (145) hide show
  1. package/.github/workflows/ci.yml +6 -7
  2. package/README.md +21 -362
  3. package/dist/browser/cdp.js +20 -1
  4. package/dist/browser/daemon-client.js +3 -2
  5. package/dist/browser/discover.js +11 -7
  6. package/dist/browser/dom-helpers.d.ts +11 -0
  7. package/dist/browser/dom-helpers.js +42 -0
  8. package/dist/browser/dom-helpers.test.d.ts +1 -0
  9. package/dist/browser/dom-helpers.test.js +92 -0
  10. package/dist/browser/index.d.ts +0 -10
  11. package/dist/browser/index.js +0 -11
  12. package/dist/browser/mcp.js +4 -3
  13. package/dist/browser/page.d.ts +2 -0
  14. package/dist/browser/page.js +42 -3
  15. package/dist/browser.test.js +17 -8
  16. package/dist/cli-manifest.json +4 -5
  17. package/dist/clis/36kr/hot.js +1 -1
  18. package/dist/clis/36kr/search.js +1 -1
  19. package/dist/clis/_shared/common.d.ts +8 -0
  20. package/dist/clis/_shared/common.js +10 -0
  21. package/dist/clis/apple-podcasts/commands.test.js +26 -3
  22. package/dist/clis/apple-podcasts/top.js +4 -1
  23. package/dist/clis/bloomberg/news.js +1 -1
  24. package/dist/clis/douban/utils.js +3 -6
  25. package/dist/clis/medium/utils.js +1 -1
  26. package/dist/clis/producthunt/browse.js +1 -1
  27. package/dist/clis/producthunt/hot.js +1 -1
  28. package/dist/clis/sinablog/utils.js +6 -7
  29. package/dist/clis/substack/utils.js +2 -2
  30. package/dist/clis/twitter/block.js +1 -1
  31. package/dist/clis/twitter/bookmark.js +1 -1
  32. package/dist/clis/twitter/delete.js +1 -1
  33. package/dist/clis/twitter/follow.js +1 -1
  34. package/dist/clis/twitter/followers.js +2 -2
  35. package/dist/clis/twitter/following.js +2 -2
  36. package/dist/clis/twitter/hide-reply.js +1 -1
  37. package/dist/clis/twitter/like.js +1 -1
  38. package/dist/clis/twitter/notifications.js +1 -1
  39. package/dist/clis/twitter/profile.js +1 -1
  40. package/dist/clis/twitter/reply-dm.js +1 -1
  41. package/dist/clis/twitter/reply.js +1 -1
  42. package/dist/clis/twitter/search.js +1 -1
  43. package/dist/clis/twitter/unblock.js +1 -1
  44. package/dist/clis/twitter/unbookmark.js +1 -1
  45. package/dist/clis/twitter/unfollow.js +1 -1
  46. package/dist/clis/v2ex/hot.yaml +3 -17
  47. package/dist/clis/weread/shelf.js +132 -9
  48. package/dist/clis/weread/utils.js +5 -1
  49. package/dist/clis/xiaohongshu/comments.test.js +1 -0
  50. package/dist/clis/xiaohongshu/creator-note-detail.test.js +1 -0
  51. package/dist/clis/xiaohongshu/creator-notes.test.js +1 -0
  52. package/dist/clis/xiaohongshu/publish.test.js +1 -0
  53. package/dist/clis/xiaohongshu/search.test.js +1 -0
  54. package/dist/daemon.js +1 -0
  55. package/dist/doctor.js +7 -3
  56. package/dist/download/index.js +39 -33
  57. package/dist/download/index.test.js +15 -1
  58. package/dist/execution.js +3 -8
  59. package/dist/extension-manifest-regression.test.d.ts +1 -0
  60. package/dist/extension-manifest-regression.test.js +12 -0
  61. package/dist/main.js +2 -0
  62. package/dist/node-network.d.ts +10 -0
  63. package/dist/node-network.js +174 -0
  64. package/dist/node-network.test.d.ts +1 -0
  65. package/dist/node-network.test.js +55 -0
  66. package/dist/pipeline/executor.test.js +1 -0
  67. package/dist/pipeline/steps/download.test.js +1 -0
  68. package/dist/pipeline/steps/intercept.js +4 -5
  69. package/dist/types.d.ts +2 -0
  70. package/dist/utils.d.ts +2 -0
  71. package/dist/utils.js +4 -0
  72. package/dist/weread-private-api-regression.test.js +185 -0
  73. package/docs/superpowers/plans/2026-03-28-perf-smart-wait.md +1143 -0
  74. package/docs/superpowers/specs/2026-03-28-perf-smart-wait-design.md +170 -0
  75. package/extension/dist/background.js +4 -2
  76. package/extension/manifest.json +4 -1
  77. package/extension/package-lock.json +2 -2
  78. package/extension/package.json +1 -1
  79. package/extension/src/background.ts +2 -1
  80. package/package.json +2 -1
  81. package/src/browser/cdp.ts +21 -0
  82. package/src/browser/daemon-client.ts +3 -2
  83. package/src/browser/discover.ts +10 -7
  84. package/src/browser/dom-helpers.test.ts +100 -0
  85. package/src/browser/dom-helpers.ts +44 -0
  86. package/src/browser/index.ts +0 -13
  87. package/src/browser/mcp.ts +4 -3
  88. package/src/browser/page.ts +41 -2
  89. package/src/browser.test.ts +19 -9
  90. package/src/clis/36kr/hot.ts +1 -1
  91. package/src/clis/36kr/search.ts +1 -1
  92. package/src/clis/_shared/common.ts +11 -0
  93. package/src/clis/apple-podcasts/commands.test.ts +30 -2
  94. package/src/clis/apple-podcasts/top.ts +4 -1
  95. package/src/clis/bloomberg/news.ts +1 -1
  96. package/src/clis/douban/utils.ts +3 -7
  97. package/src/clis/medium/utils.ts +1 -1
  98. package/src/clis/producthunt/browse.ts +1 -1
  99. package/src/clis/producthunt/hot.ts +1 -1
  100. package/src/clis/sinablog/utils.ts +6 -7
  101. package/src/clis/substack/utils.ts +2 -2
  102. package/src/clis/twitter/block.ts +1 -1
  103. package/src/clis/twitter/bookmark.ts +1 -1
  104. package/src/clis/twitter/delete.ts +1 -1
  105. package/src/clis/twitter/follow.ts +1 -1
  106. package/src/clis/twitter/followers.ts +2 -2
  107. package/src/clis/twitter/following.ts +2 -2
  108. package/src/clis/twitter/hide-reply.ts +1 -1
  109. package/src/clis/twitter/like.ts +1 -1
  110. package/src/clis/twitter/notifications.ts +1 -1
  111. package/src/clis/twitter/profile.ts +1 -1
  112. package/src/clis/twitter/reply-dm.ts +1 -1
  113. package/src/clis/twitter/reply.ts +1 -1
  114. package/src/clis/twitter/search.ts +1 -1
  115. package/src/clis/twitter/unblock.ts +1 -1
  116. package/src/clis/twitter/unbookmark.ts +1 -1
  117. package/src/clis/twitter/unfollow.ts +1 -1
  118. package/src/clis/v2ex/hot.yaml +3 -17
  119. package/src/clis/weread/shelf.ts +169 -9
  120. package/src/clis/weread/utils.ts +6 -1
  121. package/src/clis/xiaohongshu/comments.test.ts +1 -0
  122. package/src/clis/xiaohongshu/creator-note-detail.test.ts +1 -0
  123. package/src/clis/xiaohongshu/creator-notes.test.ts +1 -0
  124. package/src/clis/xiaohongshu/publish.test.ts +1 -0
  125. package/src/clis/xiaohongshu/search.test.ts +1 -0
  126. package/src/daemon.ts +1 -0
  127. package/src/doctor.ts +9 -5
  128. package/src/download/index.test.ts +19 -1
  129. package/src/download/index.ts +50 -41
  130. package/src/execution.ts +3 -11
  131. package/src/extension-manifest-regression.test.ts +17 -0
  132. package/src/main.ts +3 -0
  133. package/src/node-network.test.ts +93 -0
  134. package/src/node-network.ts +213 -0
  135. package/src/pipeline/executor.test.ts +1 -0
  136. package/src/pipeline/steps/download.test.ts +1 -0
  137. package/src/pipeline/steps/intercept.ts +4 -5
  138. package/src/types.ts +2 -0
  139. package/src/utils.ts +5 -0
  140. package/src/weread-private-api-regression.test.ts +207 -0
  141. package/tests/e2e/browser-public.test.ts +1 -1
  142. package/tests/e2e/output-formats.test.ts +10 -14
  143. package/tests/e2e/plugin-management.test.ts +4 -1
  144. package/tests/e2e/public-commands.test.ts +12 -1
  145. package/vitest.config.ts +1 -15
@@ -0,0 +1,170 @@
1
+ # Performance: Smart Wait & INTERCEPT Fix
2
+
3
+ **Date**: 2026-03-28
4
+ **Status**: Approved
5
+
6
+ ## Problem
7
+
8
+ Three distinct performance/correctness issues:
9
+
10
+ 1. **INTERCEPT strategy semantic bug**: After `installInterceptor()` + `goto()`, adapters call `wait(N)` — which now uses `waitForDomStableJs` and returns early when the DOM settles. But DOM-settle != network capture. The API response may arrive *after* DOM is stable, causing `getInterceptedRequests()` to return an empty array.
11
+
12
+ 2. **Blind `wait(N)` in adapters**: ~30 high-traffic adapters (Twitter family, Medium, Substack, etc.) call `wait(5)` waiting for React/Vue to hydrate. These should wait for a specific DOM element to appear, not a fixed cap.
13
+
14
+ 3. **Daemon cold-start polling**: Fixed 300ms poll loop means ~600ms before first successful `isExtensionConnected()` check, even though the daemon is typically ready in 500–800ms.
15
+
16
+ ## Design
17
+
18
+ ### Layer 1 — `waitForCapture()` (correctness fix + perf)
19
+
20
+ Add `waitForCapture(timeout?: number): Promise<void>` to `IPage`.
21
+
22
+ Polls `window.__opencli_xhr.length > 0` every 100ms inside the browser tab. Resolves as soon as ≥1 capture arrives; rejects after `timeout` seconds.
23
+
24
+ ```typescript
25
+ // dom-helpers.ts
26
+ export function waitForCaptureJs(maxMs: number): string {
27
+ return `
28
+ new Promise((resolve, reject) => {
29
+ const deadline = Date.now() + ${maxMs};
30
+ const check = () => {
31
+ if ((window.__opencli_xhr || []).length > 0) return resolve('captured');
32
+ if (Date.now() > deadline) return reject(new Error('No capture within ${maxMs / 1000}s'));
33
+ setTimeout(check, 100);
34
+ };
35
+ check();
36
+ })
37
+ `;
38
+ }
39
+ ```
40
+
41
+ `page.ts` and `cdp.ts` implement `waitForCapture()` by calling `waitForCaptureJs`.
42
+
43
+ **All INTERCEPT adapters** replace `wait(N)` → `waitForCapture(N+2)` (slightly longer timeout as safety margin).
44
+
45
+ `stepIntercept` in `pipeline/steps/intercept.ts` replaces its internal `wait(timeout)` with `waitForCapture(timeout)`.
46
+
47
+ **Expected gain**: 36kr hot/search: 6s → ~1–2s. Twitter search/followers: 5–8s → ~1–3s.
48
+
49
+ ### Layer 2 — `wait({ selector })` (semantic precision)
50
+
51
+ Extend `WaitOptions` with `selector?: string`.
52
+
53
+ Add `waitForSelectorJs(selector, timeoutMs)` to `dom-helpers.ts` — polls `document.querySelector(selector)` every 100ms, resolves on first match, rejects on timeout.
54
+
55
+ ```typescript
56
+ // types.ts
57
+ export interface WaitOptions {
58
+ text?: string;
59
+ selector?: string; // NEW
60
+ time?: number;
61
+ timeout?: number;
62
+ }
63
+ ```
64
+
65
+ ```typescript
66
+ // dom-helpers.ts
67
+ export function waitForSelectorJs(selector: string, timeoutMs: number): string {
68
+ return `
69
+ new Promise((resolve, reject) => {
70
+ const deadline = Date.now() + ${timeoutMs};
71
+ const check = () => {
72
+ if (document.querySelector(${JSON.stringify(selector)})) return resolve('found');
73
+ if (Date.now() > deadline) return reject(new Error('Selector not found: ' + ${JSON.stringify(selector)}));
74
+ setTimeout(check, 100);
75
+ };
76
+ check();
77
+ })
78
+ `;
79
+ }
80
+ ```
81
+
82
+ `page.ts` and `cdp.ts` handle `selector` branch in `wait()`.
83
+
84
+ **High-impact adapter changes**:
85
+
86
+ | Adapter | Old | New |
87
+ |---------|-----|-----|
88
+ | `twitter/*` (15 adapters) | `wait(5)` | `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
89
+ | `twitter/reply.ts` | `wait(5)` | `wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 8 })` |
90
+ | `medium/utils.ts` | `wait(5)` + inline 3s setTimeout | `wait({ selector: 'article', timeout: 8 })` + remove inline sleep |
91
+ | `substack/utils.ts` | `wait(5)` × 2 | `wait({ selector: 'article', timeout: 8 })` |
92
+ | `bloomberg/news.ts` | `wait(5)` | `wait({ selector: 'article', timeout: 6 })` |
93
+ | `sinablog/utils.ts` | `wait(5)` | `wait({ selector: 'article, .article', timeout: 6 })` |
94
+ | `producthunt` (already covered by layer 1) | — | — |
95
+
96
+ **Expected gain**: Twitter commands: 5s → ~0.5–2s. Medium: 8s → ~1–3s.
97
+
98
+ ### Layer 3 — Daemon exponential backoff (cold-start)
99
+
100
+ Replace fixed 300ms poll in `_ensureDaemon()` (`browser/mcp.ts`) with exponential backoff:
101
+
102
+ ```typescript
103
+ // before
104
+ while (Date.now() < deadline) {
105
+ await new Promise(resolve => setTimeout(resolve, 300));
106
+ if (await isExtensionConnected()) return;
107
+ }
108
+
109
+ // after
110
+ const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
111
+ let i = 0;
112
+ while (Date.now() < deadline) {
113
+ await new Promise(resolve => setTimeout(resolve, backoffs[Math.min(i++, backoffs.length - 1)]));
114
+ if (await isExtensionConnected()) return;
115
+ }
116
+ ```
117
+
118
+ **Expected gain**: First cold-start check succeeds at ~150ms instead of ~600ms.
119
+
120
+ ## Files Changed
121
+
122
+ ### New / Modified (framework)
123
+ - `src/types.ts` — `WaitOptions.selector`, `IPage.waitForCapture()`
124
+ - `src/browser/dom-helpers.ts` — `waitForCaptureJs()`, `waitForSelectorJs()`
125
+ - `src/browser/page.ts` — `waitForCapture()`, `wait()` selector branch
126
+ - `src/browser/cdp.ts` — `waitForCapture()`, `wait()` selector branch
127
+ - `src/browser/mcp.ts` — exponential backoff in `_ensureDaemon()`
128
+ - `src/pipeline/steps/intercept.ts` — use `waitForCapture()`
129
+
130
+ ### Modified (adapters — Layer 1, INTERCEPT)
131
+ - `src/clis/36kr/hot.ts`
132
+ - `src/clis/36kr/search.ts`
133
+ - `src/clis/twitter/search.ts`
134
+ - `src/clis/twitter/followers.ts`
135
+ - `src/clis/twitter/following.ts`
136
+ - `src/clis/producthunt/hot.ts`
137
+ - `src/clis/producthunt/browse.ts`
138
+
139
+ ### Modified (adapters — Layer 2, selector)
140
+ - `src/clis/twitter/reply.ts`
141
+ - `src/clis/twitter/follow.ts`
142
+ - `src/clis/twitter/unfollow.ts`
143
+ - `src/clis/twitter/like.ts`
144
+ - `src/clis/twitter/bookmark.ts`
145
+ - `src/clis/twitter/unbookmark.ts`
146
+ - `src/clis/twitter/block.ts`
147
+ - `src/clis/twitter/unblock.ts`
148
+ - `src/clis/twitter/hide-reply.ts`
149
+ - `src/clis/twitter/notifications.ts`
150
+ - `src/clis/twitter/profile.ts`
151
+ - `src/clis/twitter/thread.ts`
152
+ - `src/clis/twitter/timeline.ts`
153
+ - `src/clis/twitter/delete.ts`
154
+ - `src/clis/twitter/reply-dm.ts`
155
+ - `src/clis/medium/utils.ts`
156
+ - `src/clis/substack/utils.ts`
157
+ - `src/clis/bloomberg/news.ts`
158
+ - `src/clis/sinablog/utils.ts`
159
+
160
+ ## Delivery Order
161
+
162
+ 1. Layer 1 (`waitForCapture`) — correctness fix, highest ROI
163
+ 2. Layer 3 (backoff) — 3-line change, zero risk
164
+ 3. Layer 2 (`wait({ selector })`) — largest adapter surface, can be done per-site
165
+
166
+ ## Testing
167
+
168
+ - Unit tests: `waitForCaptureJs`, `waitForSelectorJs` exported and tested in `dom-helpers.test.ts` (if exists) or new test file
169
+ - Adapter tests: existing tests must continue to pass (mock `page.wait` / `page.waitForCapture`)
170
+ - Run: `npx vitest run --project unit --project adapter`
@@ -164,6 +164,7 @@ function connect() {
164
164
  clearTimeout(reconnectTimer);
165
165
  reconnectTimer = null;
166
166
  }
167
+ ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version }));
167
168
  };
168
169
  ws.onmessage = async (event) => {
169
170
  try {
@@ -195,7 +196,7 @@ function scheduleReconnect() {
195
196
  }, delay);
196
197
  }
197
198
  const automationSessions = /* @__PURE__ */ new Map();
198
- const WINDOW_IDLE_TIMEOUT = 12e4;
199
+ const WINDOW_IDLE_TIMEOUT = 3e4;
199
200
  function getWorkspaceKey(workspace) {
200
201
  return workspace?.trim() || "default";
201
202
  }
@@ -230,7 +231,8 @@ async function getAutomationWindow(workspace) {
230
231
  focused: false,
231
232
  width: 1280,
232
233
  height: 900,
233
- type: "normal"
234
+ type: "normal",
235
+ state: "normal"
234
236
  });
235
237
  const session = {
236
238
  windowId: win.id,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCLI",
4
- "version": "1.4.1",
4
+ "version": "1.5.3",
5
5
  "description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.",
6
6
  "permissions": [
7
7
  "debugger",
@@ -10,6 +10,9 @@
10
10
  "activeTab",
11
11
  "alarms"
12
12
  ],
13
+ "host_permissions": [
14
+ "<all_urls>"
15
+ ],
13
16
  "background": {
14
17
  "service_worker": "dist/background.js",
15
18
  "type": "module"
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "opencli-extension",
3
- "version": "0.2.0",
3
+ "version": "1.5.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "opencli-extension",
9
- "version": "0.2.0",
9
+ "version": "1.5.3",
10
10
  "devDependencies": {
11
11
  "@types/chrome": "^0.0.287",
12
12
  "typescript": "^5.7.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencli-extension",
3
- "version": "1.4.1",
3
+ "version": "1.5.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -106,7 +106,7 @@ type AutomationSession = {
106
106
  };
107
107
 
108
108
  const automationSessions = new Map<string, AutomationSession>();
109
- const WINDOW_IDLE_TIMEOUT = 120000; // 120slonger to survive slow pipelines
109
+ const WINDOW_IDLE_TIMEOUT = 30000; // 30squick cleanup after command finishes
110
110
 
111
111
  function getWorkspaceKey(workspace?: string): string {
112
112
  return workspace?.trim() || 'default';
@@ -152,6 +152,7 @@ async function getAutomationWindow(workspace: string): Promise<number> {
152
152
  width: 1280,
153
153
  height: 900,
154
154
  type: 'normal',
155
+ state: 'normal',
155
156
  });
156
157
  const session: AutomationSession = {
157
158
  windowId: win.id!,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -58,6 +58,7 @@
58
58
  "commander": "^14.0.3",
59
59
  "js-yaml": "^4.1.0",
60
60
  "turndown": "^7.2.2",
61
+ "undici": "^7.24.6",
61
62
  "ws": "^8.18.0"
62
63
  },
63
64
  "devDependencies": {
@@ -25,6 +25,8 @@ import {
25
25
  autoScrollJs,
26
26
  networkRequestsJs,
27
27
  waitForDomStableJs,
28
+ waitForCaptureJs,
29
+ waitForSelectorJs,
28
30
  } from './dom-helpers.js';
29
31
  import { isRecord, saveBase64ToFile } from '../utils.js';
30
32
 
@@ -247,6 +249,15 @@ class CDPPage implements IPage {
247
249
 
248
250
  async wait(options: number | WaitOptions): Promise<void> {
249
251
  if (typeof options === 'number') {
252
+ if (options >= 1) {
253
+ try {
254
+ const maxMs = options * 1000;
255
+ await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
256
+ return;
257
+ } catch {
258
+ // Fallback: fixed sleep
259
+ }
260
+ }
250
261
  await new Promise((resolve) => setTimeout(resolve, options * 1000));
251
262
  return;
252
263
  }
@@ -255,6 +266,11 @@ class CDPPage implements IPage {
255
266
  await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
256
267
  return;
257
268
  }
269
+ if (options.selector) {
270
+ const timeout = (options.timeout ?? 10) * 1000;
271
+ await this.evaluate(waitForSelectorJs(options.selector, timeout));
272
+ return;
273
+ }
258
274
  if (options.text) {
259
275
  const timeout = (options.timeout ?? 30) * 1000;
260
276
  await this.evaluate(waitForTextJs(options.text, timeout));
@@ -326,6 +342,11 @@ class CDPPage implements IPage {
326
342
  const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
327
343
  return Array.isArray(result) ? result : [];
328
344
  }
345
+
346
+ async waitForCapture(timeout: number = 10): Promise<void> {
347
+ const maxMs = timeout * 1000;
348
+ await this.evaluate(waitForCaptureJs(maxMs));
349
+ }
329
350
  }
330
351
 
331
352
  function isCookie(value: unknown): value is BrowserCookie {
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { DEFAULT_DAEMON_PORT } from '../constants.js';
8
8
  import type { BrowserSessionInfo } from '../types.js';
9
+ import { sleep } from '../utils.js';
9
10
 
10
11
  const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
11
12
  const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
@@ -114,7 +115,7 @@ export async function sendCommand(
114
115
  || errMsg.includes('no longer exists');
115
116
  if (isTransient && attempt < maxRetries) {
116
117
  // Longer delay for extension recovery (service worker restart)
117
- await new Promise(r => setTimeout(r, 1500));
118
+ await sleep(1500);
118
119
  continue;
119
120
  }
120
121
  throw new Error(result.error ?? 'Daemon command failed');
@@ -125,7 +126,7 @@ export async function sendCommand(
125
126
  const isRetryable = err instanceof TypeError // fetch network error
126
127
  || (err instanceof Error && err.name === 'AbortError');
127
128
  if (isRetryable && attempt < maxRetries) {
128
- await new Promise(r => setTimeout(r, 500));
129
+ await sleep(500);
129
130
  continue;
130
131
  }
131
132
  throw err;
@@ -22,13 +22,16 @@ export async function checkDaemonStatus(opts?: { timeout?: number }): Promise<{
22
22
  const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
23
23
  const controller = new AbortController();
24
24
  const timer = setTimeout(() => controller.abort(), opts?.timeout ?? 2000);
25
- const res = await fetch(`http://127.0.0.1:${port}/status`, {
26
- headers: { 'X-OpenCLI': '1' },
27
- signal: controller.signal,
28
- });
29
- const data = await res.json() as { ok: boolean; extensionConnected: boolean; extensionVersion?: string };
30
- clearTimeout(timer);
31
- return { running: true, extensionConnected: data.extensionConnected, extensionVersion: data.extensionVersion };
25
+ try {
26
+ const res = await fetch(`http://127.0.0.1:${port}/status`, {
27
+ headers: { 'X-OpenCLI': '1' },
28
+ signal: controller.signal,
29
+ });
30
+ const data = await res.json() as { ok: boolean; extensionConnected: boolean; extensionVersion?: string };
31
+ return { running: true, extensionConnected: data.extensionConnected, extensionVersion: data.extensionVersion };
32
+ } finally {
33
+ clearTimeout(timer);
34
+ }
32
35
  } catch {
33
36
  return { running: false, extensionConnected: false };
34
37
  }
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js';
3
+
4
+ describe('waitForCaptureJs', () => {
5
+ it('returns a non-empty string', () => {
6
+ const code = waitForCaptureJs(1000);
7
+ expect(typeof code).toBe('string');
8
+ expect(code.length).toBeGreaterThan(0);
9
+ expect(code).toContain('__opencli_xhr');
10
+ expect(code).toContain('resolve');
11
+ expect(code).toContain('reject');
12
+ });
13
+
14
+ it('resolves "captured" when __opencli_xhr is populated before deadline', async () => {
15
+ const g = globalThis as any;
16
+ g.__opencli_xhr = [];
17
+ g.window = g; // stub window for Node eval
18
+ const code = waitForCaptureJs(1000);
19
+ const promise = eval(code) as Promise<string>;
20
+ g.__opencli_xhr.push({ data: 'test' });
21
+ await expect(promise).resolves.toBe('captured');
22
+ delete g.__opencli_xhr;
23
+ delete g.window;
24
+ });
25
+
26
+ it('rejects when __opencli_xhr stays empty past deadline', async () => {
27
+ const g = globalThis as any;
28
+ g.__opencli_xhr = [];
29
+ g.window = g;
30
+ const code = waitForCaptureJs(50); // 50ms timeout
31
+ const promise = eval(code) as Promise<string>;
32
+ await expect(promise).rejects.toThrow('No network capture within 0.05s');
33
+ delete g.__opencli_xhr;
34
+ delete g.window;
35
+ });
36
+
37
+ it('resolves immediately when __opencli_xhr already has data', async () => {
38
+ const g = globalThis as any;
39
+ g.__opencli_xhr = [{ data: 'already here' }];
40
+ g.window = g;
41
+ const code = waitForCaptureJs(1000);
42
+ await expect(eval(code) as Promise<string>).resolves.toBe('captured');
43
+ delete g.__opencli_xhr;
44
+ delete g.window;
45
+ });
46
+ });
47
+
48
+ describe('waitForSelectorJs', () => {
49
+ it('returns a non-empty string', () => {
50
+ const code = waitForSelectorJs('#app', 1000);
51
+ expect(typeof code).toBe('string');
52
+ expect(code).toContain('#app');
53
+ expect(code).toContain('querySelector');
54
+ expect(code).toContain('MutationObserver');
55
+ });
56
+
57
+ it('resolves "found" immediately when selector already present', async () => {
58
+ const g = globalThis as any;
59
+ const fakeEl = { tagName: 'DIV' };
60
+ g.document = { querySelector: (_: string) => fakeEl };
61
+ const code = waitForSelectorJs('[data-testid="primaryColumn"]', 1000);
62
+ await expect(eval(code) as Promise<string>).resolves.toBe('found');
63
+ delete g.document;
64
+ });
65
+
66
+ it('resolves "found" when selector appears after DOM mutation', async () => {
67
+ const g = globalThis as any;
68
+ let mutationCallback!: () => void;
69
+ g.MutationObserver = class {
70
+ constructor(cb: () => void) { mutationCallback = cb; }
71
+ observe() {}
72
+ disconnect() {}
73
+ };
74
+ let calls = 0;
75
+ g.document = {
76
+ querySelector: (_: string) => (calls++ > 0 ? { tagName: 'DIV' } : null),
77
+ body: {},
78
+ };
79
+ const code = waitForSelectorJs('#app', 1000);
80
+ const promise = eval(code) as Promise<string>;
81
+ mutationCallback(); // simulate DOM mutation
82
+ await expect(promise).resolves.toBe('found');
83
+ delete g.document;
84
+ delete g.MutationObserver;
85
+ });
86
+
87
+ it('rejects when selector never appears within timeout', async () => {
88
+ const g = globalThis as any;
89
+ g.MutationObserver = class {
90
+ constructor(_cb: () => void) {}
91
+ observe() {}
92
+ disconnect() {}
93
+ };
94
+ g.document = { querySelector: (_: string) => null, body: {} };
95
+ const code = waitForSelectorJs('#missing', 50);
96
+ await expect(eval(code) as Promise<string>).rejects.toThrow('Selector not found: #missing');
97
+ delete g.document;
98
+ delete g.MutationObserver;
99
+ });
100
+ });
@@ -179,3 +179,47 @@ export function waitForDomStableJs(maxMs: number, quietMs: number): string {
179
179
  })
180
180
  `;
181
181
  }
182
+
183
+ /**
184
+ * Generate JS to wait until window.__opencli_xhr has ≥1 captured response.
185
+ * Polls every 100ms. Resolves 'captured' on success; rejects after maxMs.
186
+ * Used after installInterceptor() + goto() instead of a fixed sleep.
187
+ */
188
+ export function waitForCaptureJs(maxMs: number): string {
189
+ return `
190
+ new Promise((resolve, reject) => {
191
+ const deadline = Date.now() + ${maxMs};
192
+ const check = () => {
193
+ if ((window.__opencli_xhr || []).length > 0) return resolve('captured');
194
+ if (Date.now() > deadline) return reject(new Error('No network capture within ${maxMs / 1000}s'));
195
+ setTimeout(check, 100);
196
+ };
197
+ check();
198
+ })
199
+ `;
200
+ }
201
+
202
+ /**
203
+ * Generate JS to wait until document.querySelector(selector) returns a match.
204
+ * Uses MutationObserver for near-instant resolution; falls back to reject after timeoutMs.
205
+ */
206
+ export function waitForSelectorJs(selector: string, timeoutMs: number): string {
207
+ return `
208
+ new Promise((resolve, reject) => {
209
+ const sel = ${JSON.stringify(selector)};
210
+ if (document.querySelector(sel)) return resolve('found');
211
+ const cap = setTimeout(() => {
212
+ obs.disconnect();
213
+ reject(new Error('Selector not found: ' + sel));
214
+ }, ${timeoutMs});
215
+ const obs = new MutationObserver(() => {
216
+ if (document.querySelector(sel)) {
217
+ clearTimeout(cap);
218
+ obs.disconnect();
219
+ resolve('found');
220
+ }
221
+ });
222
+ obs.observe(document.body || document.documentElement, { childList: true, subtree: true });
223
+ })
224
+ `;
225
+ }
@@ -12,16 +12,3 @@ export { isDaemonRunning } from './daemon-client.js';
12
12
  export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
13
13
  export { generateStealthJs } from './stealth.js';
14
14
  export type { DomSnapshotOptions } from './dom-snapshot.js';
15
-
16
- import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
17
- import { __test__ as cdpTest } from './cdp.js';
18
- import { withTimeoutMs } from '../runtime.js';
19
-
20
- export const __test__ = {
21
- extractTabEntries,
22
- diffTabIndexes,
23
- appendLimited,
24
- withTimeoutMs,
25
- selectCDPTarget: cdpTest.selectCDPTarget,
26
- scoreCDPTarget: cdpTest.scoreCDPTarget,
27
- };
@@ -96,10 +96,11 @@ export class BrowserBridge implements IBrowserFactory {
96
96
  });
97
97
  this._daemonProc.unref();
98
98
 
99
- // Wait for daemon to be ready AND extension to connect
99
+ // Wait for daemon to be ready AND extension to connect (exponential backoff)
100
+ const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
100
101
  const deadline = Date.now() + timeoutMs;
101
- while (Date.now() < deadline) {
102
- await new Promise(resolve => setTimeout(resolve, 300));
102
+ for (let i = 0; Date.now() < deadline; i++) {
103
+ await new Promise(resolve => setTimeout(resolve, backoffs[Math.min(i, backoffs.length - 1)]));
103
104
  if (await isExtensionConnected()) return;
104
105
  }
105
106
 
@@ -22,12 +22,20 @@ import {
22
22
  typeTextJs,
23
23
  pressKeyJs,
24
24
  waitForTextJs,
25
+ waitForCaptureJs,
26
+ waitForSelectorJs,
25
27
  scrollJs,
26
28
  autoScrollJs,
27
29
  networkRequestsJs,
28
30
  waitForDomStableJs,
29
31
  } from './dom-helpers.js';
30
32
 
33
+ export function isRetryableSettleError(err: unknown): boolean {
34
+ const message = err instanceof Error ? err.message : String(err);
35
+ return message.includes('Inspected target navigated or closed')
36
+ || (message.includes('-32000') && message.toLowerCase().includes('target'));
37
+ }
38
+
31
39
  /**
32
40
  * Page — implements IPage by talking to the daemon via HTTP.
33
41
  */
@@ -75,10 +83,27 @@ export class Page implements IPage {
75
83
  // settleMs is now a timeout cap (default 1000ms), not a fixed wait.
76
84
  if (options?.waitUntil !== 'none') {
77
85
  const maxMs = options?.settleMs ?? 1000;
78
- await sendCommand('exec', {
86
+ const settleOpts = {
79
87
  code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
80
88
  ...this._cmdOpts(),
81
- });
89
+ };
90
+ try {
91
+ await sendCommand('exec', settleOpts);
92
+ } catch (err) {
93
+ if (!isRetryableSettleError(err)) throw err;
94
+ // SPA client-side redirects can invalidate the CDP target after
95
+ // chrome.tabs reports 'complete'. Wait briefly for the new document
96
+ // to load, then retry the settle probe once.
97
+ try {
98
+ await new Promise((r) => setTimeout(r, 200));
99
+ await sendCommand('exec', settleOpts);
100
+ } catch (retryErr) {
101
+ if (!isRetryableSettleError(retryErr)) throw retryErr;
102
+ // Retry also failed — give up silently. Settle is best-effort
103
+ // after successful navigation; the next real command will surface
104
+ // any persistent target error immediately.
105
+ }
106
+ }
82
107
  }
83
108
  }
84
109
 
@@ -213,6 +238,12 @@ export class Page implements IPage {
213
238
  await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
214
239
  return;
215
240
  }
241
+ if (options.selector) {
242
+ const timeout = (options.timeout ?? 10) * 1000;
243
+ const code = waitForSelectorJs(options.selector, timeout);
244
+ await sendCommand('exec', { code, ...this._cmdOpts() });
245
+ return;
246
+ }
216
247
  if (options.text) {
217
248
  const timeout = (options.timeout ?? 30) * 1000;
218
249
  const code = waitForTextJs(options.text, timeout);
@@ -307,6 +338,14 @@ export class Page implements IPage {
307
338
  const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
308
339
  return Array.isArray(result) ? result : [];
309
340
  }
341
+
342
+ async waitForCapture(timeout: number = 10): Promise<void> {
343
+ const maxMs = timeout * 1000;
344
+ await sendCommand('exec', {
345
+ code: waitForCaptureJs(maxMs),
346
+ ...this._cmdOpts(),
347
+ });
348
+ }
310
349
  }
311
350
 
312
351
  // (End of file)