@jackwener/opencli 1.5.2 → 1.5.4
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/.agents/skills/cross-project-adapter-migration/SKILL.md +3 -3
- package/.github/workflows/ci.yml +6 -7
- package/README.md +89 -235
- package/dist/browser/cdp.js +20 -1
- package/dist/browser/daemon-client.js +3 -2
- package/dist/browser/dom-helpers.d.ts +11 -0
- package/dist/browser/dom-helpers.js +42 -0
- package/dist/browser/dom-helpers.test.d.ts +1 -0
- package/dist/browser/dom-helpers.test.js +92 -0
- package/dist/browser/index.d.ts +0 -12
- package/dist/browser/index.js +0 -13
- package/dist/browser/mcp.js +4 -3
- package/dist/browser/page.d.ts +1 -0
- package/dist/browser/page.js +14 -1
- package/dist/browser.test.js +15 -11
- package/dist/build-manifest.d.ts +2 -3
- package/dist/build-manifest.js +75 -170
- package/dist/build-manifest.test.js +113 -88
- package/dist/cli-manifest.json +1199 -1106
- package/dist/clis/36kr/hot.js +1 -1
- package/dist/clis/36kr/search.js +1 -1
- package/dist/clis/_shared/common.d.ts +8 -0
- package/dist/clis/_shared/common.js +10 -0
- package/dist/clis/bloomberg/news.js +1 -1
- package/dist/clis/douban/utils.js +3 -6
- package/dist/clis/medium/utils.js +1 -1
- package/dist/clis/producthunt/browse.js +1 -1
- package/dist/clis/producthunt/hot.js +1 -1
- package/dist/clis/sinablog/utils.js +6 -7
- package/dist/clis/substack/utils.js +2 -2
- package/dist/clis/twitter/block.js +1 -1
- package/dist/clis/twitter/bookmark.js +1 -1
- package/dist/clis/twitter/delete.js +1 -1
- package/dist/clis/twitter/follow.js +1 -1
- package/dist/clis/twitter/followers.js +2 -2
- package/dist/clis/twitter/following.js +2 -2
- package/dist/clis/twitter/hide-reply.js +1 -1
- package/dist/clis/twitter/like.js +1 -1
- package/dist/clis/twitter/notifications.js +1 -1
- package/dist/clis/twitter/profile.js +1 -1
- package/dist/clis/twitter/reply-dm.js +1 -1
- package/dist/clis/twitter/reply.js +1 -1
- package/dist/clis/twitter/search.js +1 -1
- package/dist/clis/twitter/unblock.js +1 -1
- package/dist/clis/twitter/unbookmark.js +1 -1
- package/dist/clis/twitter/unfollow.js +1 -1
- package/dist/clis/xiaohongshu/comments.test.js +1 -0
- package/dist/clis/xiaohongshu/creator-note-detail.test.js +1 -0
- package/dist/clis/xiaohongshu/creator-notes.test.js +1 -0
- package/dist/clis/xiaohongshu/publish.test.js +1 -0
- package/dist/clis/xiaohongshu/search.test.js +1 -0
- package/dist/daemon.js +14 -3
- package/dist/download/index.js +39 -33
- package/dist/download/index.test.js +15 -1
- package/dist/execution.js +3 -2
- package/dist/external-clis.yaml +16 -0
- package/dist/main.js +2 -0
- package/dist/node-network.d.ts +10 -0
- package/dist/node-network.js +174 -0
- package/dist/node-network.test.d.ts +1 -0
- package/dist/node-network.test.js +55 -0
- package/dist/pipeline/executor.test.js +1 -0
- package/dist/pipeline/steps/download.test.js +1 -0
- package/dist/pipeline/steps/intercept.js +4 -5
- package/dist/serialization.js +6 -1
- package/dist/serialization.test.d.ts +1 -0
- package/dist/serialization.test.js +23 -0
- package/dist/types.d.ts +2 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +4 -0
- package/docs/superpowers/plans/2026-03-28-perf-smart-wait.md +1143 -0
- package/docs/superpowers/specs/2026-03-28-perf-smart-wait-design.md +170 -0
- package/extension/dist/background.js +12 -5
- package/extension/manifest.json +2 -2
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +20 -6
- package/extension/src/protocol.ts +2 -1
- package/package.json +2 -1
- package/src/browser/cdp.ts +21 -0
- package/src/browser/daemon-client.ts +3 -2
- package/src/browser/dom-helpers.test.ts +100 -0
- package/src/browser/dom-helpers.ts +44 -0
- package/src/browser/index.ts +0 -15
- package/src/browser/mcp.ts +4 -3
- package/src/browser/page.ts +16 -0
- package/src/browser.test.ts +16 -12
- package/src/build-manifest.test.ts +117 -88
- package/src/build-manifest.ts +81 -180
- package/src/clis/36kr/hot.ts +1 -1
- package/src/clis/36kr/search.ts +1 -1
- package/src/clis/_shared/common.ts +11 -0
- package/src/clis/bloomberg/news.ts +1 -1
- package/src/clis/douban/utils.ts +3 -7
- package/src/clis/medium/utils.ts +1 -1
- package/src/clis/producthunt/browse.ts +1 -1
- package/src/clis/producthunt/hot.ts +1 -1
- package/src/clis/sinablog/utils.ts +6 -7
- package/src/clis/substack/utils.ts +2 -2
- package/src/clis/twitter/block.ts +1 -1
- package/src/clis/twitter/bookmark.ts +1 -1
- package/src/clis/twitter/delete.ts +1 -1
- package/src/clis/twitter/follow.ts +1 -1
- package/src/clis/twitter/followers.ts +2 -2
- package/src/clis/twitter/following.ts +2 -2
- package/src/clis/twitter/hide-reply.ts +1 -1
- package/src/clis/twitter/like.ts +1 -1
- package/src/clis/twitter/notifications.ts +1 -1
- package/src/clis/twitter/profile.ts +1 -1
- package/src/clis/twitter/reply-dm.ts +1 -1
- package/src/clis/twitter/reply.ts +1 -1
- package/src/clis/twitter/search.ts +1 -1
- package/src/clis/twitter/unblock.ts +1 -1
- package/src/clis/twitter/unbookmark.ts +1 -1
- package/src/clis/twitter/unfollow.ts +1 -1
- package/src/clis/xiaohongshu/comments.test.ts +1 -0
- package/src/clis/xiaohongshu/creator-note-detail.test.ts +1 -0
- package/src/clis/xiaohongshu/creator-notes.test.ts +1 -0
- package/src/clis/xiaohongshu/publish.test.ts +1 -0
- package/src/clis/xiaohongshu/search.test.ts +1 -0
- package/src/daemon.ts +16 -4
- package/src/download/index.test.ts +19 -1
- package/src/download/index.ts +50 -41
- package/src/execution.ts +3 -2
- package/src/external-clis.yaml +16 -0
- package/src/main.ts +3 -0
- package/src/node-network.test.ts +93 -0
- package/src/node-network.ts +213 -0
- package/src/pipeline/executor.test.ts +1 -0
- package/src/pipeline/steps/download.test.ts +1 -0
- package/src/pipeline/steps/intercept.ts +4 -5
- package/src/serialization.test.ts +26 -0
- package/src/serialization.ts +6 -1
- package/src/types.ts +2 -0
- package/src/utils.ts +5 -0
|
@@ -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`
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const DAEMON_PORT = 19825;
|
|
2
2
|
const DAEMON_HOST = "localhost";
|
|
3
3
|
const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
|
|
4
|
+
const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
|
|
4
5
|
const WS_RECONNECT_BASE_DELAY = 2e3;
|
|
5
6
|
const WS_RECONNECT_MAX_DELAY = 6e4;
|
|
6
7
|
|
|
@@ -149,8 +150,14 @@ console.error = (...args) => {
|
|
|
149
150
|
_origError(...args);
|
|
150
151
|
forwardLog("error", args);
|
|
151
152
|
};
|
|
152
|
-
function connect() {
|
|
153
|
+
async function connect() {
|
|
153
154
|
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) });
|
|
157
|
+
if (!res.ok) return;
|
|
158
|
+
} catch {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
154
161
|
try {
|
|
155
162
|
ws = new WebSocket(DAEMON_WS_URL);
|
|
156
163
|
} catch {
|
|
@@ -192,7 +199,7 @@ function scheduleReconnect() {
|
|
|
192
199
|
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
|
|
193
200
|
reconnectTimer = setTimeout(() => {
|
|
194
201
|
reconnectTimer = null;
|
|
195
|
-
connect();
|
|
202
|
+
void connect();
|
|
196
203
|
}, delay);
|
|
197
204
|
}
|
|
198
205
|
const automationSessions = /* @__PURE__ */ new Map();
|
|
@@ -232,7 +239,7 @@ async function getAutomationWindow(workspace) {
|
|
|
232
239
|
width: 1280,
|
|
233
240
|
height: 900,
|
|
234
241
|
type: "normal",
|
|
235
|
-
state: "
|
|
242
|
+
state: "normal"
|
|
236
243
|
});
|
|
237
244
|
const session = {
|
|
238
245
|
windowId: win.id,
|
|
@@ -260,7 +267,7 @@ function initialize() {
|
|
|
260
267
|
initialized = true;
|
|
261
268
|
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
|
|
262
269
|
registerListeners();
|
|
263
|
-
connect();
|
|
270
|
+
void connect();
|
|
264
271
|
console.log("[opencli] OpenCLI extension initialized");
|
|
265
272
|
}
|
|
266
273
|
chrome.runtime.onInstalled.addListener(() => {
|
|
@@ -270,7 +277,7 @@ chrome.runtime.onStartup.addListener(() => {
|
|
|
270
277
|
initialize();
|
|
271
278
|
});
|
|
272
279
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
273
|
-
if (alarm.name === "keepalive") connect();
|
|
280
|
+
if (alarm.name === "keepalive") void connect();
|
|
274
281
|
});
|
|
275
282
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
276
283
|
if (msg?.type === "getStatus") {
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "OpenCLI",
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.4",
|
|
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",
|
|
@@ -35,4 +35,4 @@
|
|
|
35
35
|
"extension_pages": "script-src 'self'; object-src 'self'"
|
|
36
36
|
},
|
|
37
37
|
"homepage_url": "https://github.com/jackwener/opencli"
|
|
38
|
-
}
|
|
38
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencli-extension",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.4",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "opencli-extension",
|
|
9
|
-
"version": "1.5.
|
|
9
|
+
"version": "1.5.4",
|
|
10
10
|
"devDependencies": {
|
|
11
11
|
"@types/chrome": "^0.0.287",
|
|
12
12
|
"typescript": "^5.7.0",
|
package/extension/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Command, Result } from './protocol';
|
|
9
|
-
import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
|
|
9
|
+
import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
|
|
10
10
|
import * as executor from './cdp';
|
|
11
11
|
|
|
12
12
|
let ws: WebSocket | null = null;
|
|
@@ -34,9 +34,23 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error
|
|
|
34
34
|
|
|
35
35
|
// ─── WebSocket connection ────────────────────────────────────────────
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket
|
|
39
|
+
* connection. fetch() failures are silently catchable; new WebSocket() is not
|
|
40
|
+
* — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any
|
|
41
|
+
* JS handler can intercept it. By keeping the probe inside connect() every
|
|
42
|
+
* call site remains unchanged and the guard can never be accidentally skipped.
|
|
43
|
+
*/
|
|
44
|
+
async function connect(): Promise<void> {
|
|
38
45
|
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
|
39
46
|
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) });
|
|
49
|
+
if (!res.ok) return; // unexpected response — not our daemon
|
|
50
|
+
} catch {
|
|
51
|
+
return; // daemon not running — skip WebSocket to avoid console noise
|
|
52
|
+
}
|
|
53
|
+
|
|
40
54
|
try {
|
|
41
55
|
ws = new WebSocket(DAEMON_WS_URL);
|
|
42
56
|
} catch {
|
|
@@ -90,7 +104,7 @@ function scheduleReconnect(): void {
|
|
|
90
104
|
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
|
|
91
105
|
reconnectTimer = setTimeout(() => {
|
|
92
106
|
reconnectTimer = null;
|
|
93
|
-
connect();
|
|
107
|
+
void connect();
|
|
94
108
|
}, delay);
|
|
95
109
|
}
|
|
96
110
|
|
|
@@ -152,7 +166,7 @@ async function getAutomationWindow(workspace: string): Promise<number> {
|
|
|
152
166
|
width: 1280,
|
|
153
167
|
height: 900,
|
|
154
168
|
type: 'normal',
|
|
155
|
-
state: '
|
|
169
|
+
state: 'normal',
|
|
156
170
|
});
|
|
157
171
|
const session: AutomationSession = {
|
|
158
172
|
windowId: win.id!,
|
|
@@ -187,7 +201,7 @@ function initialize(): void {
|
|
|
187
201
|
initialized = true;
|
|
188
202
|
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
|
|
189
203
|
executor.registerListeners();
|
|
190
|
-
connect();
|
|
204
|
+
void connect();
|
|
191
205
|
console.log('[opencli] OpenCLI extension initialized');
|
|
192
206
|
}
|
|
193
207
|
|
|
@@ -200,7 +214,7 @@ chrome.runtime.onStartup.addListener(() => {
|
|
|
200
214
|
});
|
|
201
215
|
|
|
202
216
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
203
|
-
if (alarm.name === 'keepalive') connect();
|
|
217
|
+
if (alarm.name === 'keepalive') void connect();
|
|
204
218
|
});
|
|
205
219
|
|
|
206
220
|
// ─── Popup status API ───────────────────────────────────────────────
|
|
@@ -49,7 +49,8 @@ export interface Result {
|
|
|
49
49
|
export const DAEMON_PORT = 19825;
|
|
50
50
|
export const DAEMON_HOST = 'localhost';
|
|
51
51
|
export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
|
|
52
|
-
|
|
52
|
+
/** Lightweight health-check endpoint — probed before each WebSocket attempt. */
|
|
53
|
+
export const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
|
|
53
54
|
|
|
54
55
|
/** Base reconnect delay for extension WebSocket (ms) */
|
|
55
56
|
export const WS_RECONNECT_BASE_DELAY = 2000;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jackwener/opencli",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.4",
|
|
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": {
|
package/src/browser/cdp.ts
CHANGED
|
@@ -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
|
|
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
|
|
129
|
+
await sleep(500);
|
|
129
130
|
continue;
|
|
130
131
|
}
|
|
131
132
|
throw err;
|
|
@@ -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
|
+
}
|
package/src/browser/index.ts
CHANGED
|
@@ -12,18 +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 { isRetryableSettleError } from './page.js';
|
|
19
|
-
import { withTimeoutMs } from '../runtime.js';
|
|
20
|
-
|
|
21
|
-
export const __test__ = {
|
|
22
|
-
extractTabEntries,
|
|
23
|
-
diffTabIndexes,
|
|
24
|
-
appendLimited,
|
|
25
|
-
withTimeoutMs,
|
|
26
|
-
selectCDPTarget: cdpTest.selectCDPTarget,
|
|
27
|
-
scoreCDPTarget: cdpTest.scoreCDPTarget,
|
|
28
|
-
isRetryableSettleError,
|
|
29
|
-
};
|
package/src/browser/mcp.ts
CHANGED
|
@@ -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
|
-
|
|
102
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
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
|
|
package/src/browser/page.ts
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
typeTextJs,
|
|
23
23
|
pressKeyJs,
|
|
24
24
|
waitForTextJs,
|
|
25
|
+
waitForCaptureJs,
|
|
26
|
+
waitForSelectorJs,
|
|
25
27
|
scrollJs,
|
|
26
28
|
autoScrollJs,
|
|
27
29
|
networkRequestsJs,
|
|
@@ -236,6 +238,12 @@ export class Page implements IPage {
|
|
|
236
238
|
await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
|
|
237
239
|
return;
|
|
238
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
|
+
}
|
|
239
247
|
if (options.text) {
|
|
240
248
|
const timeout = (options.timeout ?? 30) * 1000;
|
|
241
249
|
const code = waitForTextJs(options.text, timeout);
|
|
@@ -330,6 +338,14 @@ export class Page implements IPage {
|
|
|
330
338
|
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
|
331
339
|
return Array.isArray(result) ? result : [];
|
|
332
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
|
+
}
|
|
333
349
|
}
|
|
334
350
|
|
|
335
351
|
// (End of file)
|