@jackwener/opencli 1.5.1 → 1.5.2

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 (43) hide show
  1. package/dist/browser/discover.js +11 -7
  2. package/dist/browser/index.d.ts +2 -0
  3. package/dist/browser/index.js +2 -0
  4. package/dist/browser/page.d.ts +1 -0
  5. package/dist/browser/page.js +28 -2
  6. package/dist/browser.test.js +5 -0
  7. package/dist/cli-manifest.json +4 -5
  8. package/dist/clis/apple-podcasts/commands.test.js +26 -3
  9. package/dist/clis/apple-podcasts/top.js +4 -1
  10. package/dist/clis/v2ex/hot.yaml +3 -17
  11. package/dist/clis/weread/shelf.js +132 -9
  12. package/dist/clis/weread/utils.js +5 -1
  13. package/dist/daemon.js +1 -0
  14. package/dist/doctor.js +7 -3
  15. package/dist/execution.js +0 -6
  16. package/dist/extension-manifest-regression.test.d.ts +1 -0
  17. package/dist/extension-manifest-regression.test.js +12 -0
  18. package/dist/weread-private-api-regression.test.js +185 -0
  19. package/extension/dist/background.js +4 -2
  20. package/extension/manifest.json +4 -1
  21. package/extension/package-lock.json +2 -2
  22. package/extension/package.json +1 -1
  23. package/extension/src/background.ts +2 -1
  24. package/package.json +1 -1
  25. package/src/browser/discover.ts +10 -7
  26. package/src/browser/index.ts +2 -0
  27. package/src/browser/page.ts +25 -2
  28. package/src/browser.test.ts +6 -0
  29. package/src/clis/apple-podcasts/commands.test.ts +30 -2
  30. package/src/clis/apple-podcasts/top.ts +4 -1
  31. package/src/clis/v2ex/hot.yaml +3 -17
  32. package/src/clis/weread/shelf.ts +169 -9
  33. package/src/clis/weread/utils.ts +6 -1
  34. package/src/daemon.ts +1 -0
  35. package/src/doctor.ts +9 -5
  36. package/src/execution.ts +0 -9
  37. package/src/extension-manifest-regression.test.ts +17 -0
  38. package/src/weread-private-api-regression.test.ts +207 -0
  39. package/tests/e2e/browser-public.test.ts +1 -1
  40. package/tests/e2e/output-formats.test.ts +10 -14
  41. package/tests/e2e/plugin-management.test.ts +4 -1
  42. package/tests/e2e/public-commands.test.ts +12 -1
  43. package/vitest.config.ts +1 -15
@@ -44,6 +44,21 @@ describe('weread private API regression', () => {
44
44
  }));
45
45
  await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toThrow('Not logged in');
46
46
  });
47
+ it('maps auth-expired API error codes to AUTH_REQUIRED even on HTTP 200', async () => {
48
+ const mockPage = {
49
+ getCookies: vi.fn().mockResolvedValue([]),
50
+ evaluate: vi.fn(),
51
+ };
52
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
53
+ ok: true,
54
+ status: 200,
55
+ json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
56
+ }));
57
+ await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toMatchObject({
58
+ code: 'AUTH_REQUIRED',
59
+ message: 'Not logged in to WeRead',
60
+ });
61
+ });
47
62
  it('maps non-auth API errors to API_ERROR', async () => {
48
63
  const mockPage = {
49
64
  getCookies: vi.fn().mockResolvedValue([]),
@@ -119,4 +134,174 @@ describe('weread private API regression', () => {
119
134
  },
120
135
  ]);
121
136
  });
137
+ it('falls back to structured shelf cache when the private API reports AUTH_REQUIRED', async () => {
138
+ const command = getRegistry().get('weread/shelf');
139
+ expect(command?.func).toBeTypeOf('function');
140
+ const mockPage = {
141
+ getCookies: vi.fn()
142
+ .mockResolvedValueOnce([
143
+ { name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
144
+ ])
145
+ .mockResolvedValueOnce([
146
+ { name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
147
+ ]),
148
+ goto: vi.fn().mockResolvedValue(undefined),
149
+ evaluate: vi.fn().mockImplementation(async (source) => {
150
+ expect(source).toContain('shelf:rawBooks:vid-current');
151
+ expect(source).toContain('shelf:shelfIndexes:vid-current');
152
+ return {
153
+ cacheFound: true,
154
+ rawBooks: [
155
+ {
156
+ bookId: '40055543',
157
+ title: '置身事内:中国政府与经济发展',
158
+ author: '兰小欢',
159
+ },
160
+ {
161
+ bookId: '29196155',
162
+ title: '文明、现代化、价值投资与中国',
163
+ author: '李录',
164
+ },
165
+ ],
166
+ shelfIndexes: [
167
+ { bookId: '29196155', idx: 0, role: 'book' },
168
+ { bookId: '40055543', idx: 1, role: 'book' },
169
+ ],
170
+ lastChapters: {
171
+ '29196155': 40,
172
+ '40055543': 60,
173
+ },
174
+ };
175
+ }),
176
+ };
177
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
178
+ ok: false,
179
+ status: 401,
180
+ json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
181
+ }));
182
+ const result = await command.func(mockPage, { limit: 1 });
183
+ expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
184
+ expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
185
+ expect(mockPage.evaluate).toHaveBeenCalledTimes(1);
186
+ expect(result).toEqual([
187
+ {
188
+ title: '文明、现代化、价值投资与中国',
189
+ author: '李录',
190
+ progress: '-',
191
+ bookId: '29196155',
192
+ },
193
+ ]);
194
+ });
195
+ it('rethrows AUTH_REQUIRED when the current session has no structured shelf cache', async () => {
196
+ const command = getRegistry().get('weread/shelf');
197
+ expect(command?.func).toBeTypeOf('function');
198
+ const mockPage = {
199
+ getCookies: vi.fn()
200
+ .mockResolvedValueOnce([
201
+ { name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
202
+ ])
203
+ .mockResolvedValueOnce([
204
+ { name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
205
+ ]),
206
+ goto: vi.fn().mockResolvedValue(undefined),
207
+ evaluate: vi.fn().mockResolvedValue({
208
+ cacheFound: false,
209
+ rawBooks: [],
210
+ shelfIndexes: [],
211
+ lastChapters: {},
212
+ }),
213
+ };
214
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
215
+ ok: false,
216
+ status: 401,
217
+ json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
218
+ }));
219
+ await expect(command.func(mockPage, { limit: 20 })).rejects.toMatchObject({
220
+ code: 'AUTH_REQUIRED',
221
+ message: 'Not logged in to WeRead',
222
+ });
223
+ expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
224
+ expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
225
+ });
226
+ it('returns an empty list when the current session cache is confirmed but empty', async () => {
227
+ const command = getRegistry().get('weread/shelf');
228
+ expect(command?.func).toBeTypeOf('function');
229
+ const mockPage = {
230
+ getCookies: vi.fn()
231
+ .mockResolvedValueOnce([
232
+ { name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
233
+ ])
234
+ .mockResolvedValueOnce([
235
+ { name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
236
+ ]),
237
+ goto: vi.fn().mockResolvedValue(undefined),
238
+ evaluate: vi.fn().mockResolvedValue({
239
+ cacheFound: true,
240
+ rawBooks: [],
241
+ shelfIndexes: [],
242
+ lastChapters: {},
243
+ }),
244
+ };
245
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
246
+ ok: false,
247
+ status: 401,
248
+ json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
249
+ }));
250
+ const result = await command.func(mockPage, { limit: 20 });
251
+ expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
252
+ expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
253
+ expect(result).toEqual([]);
254
+ });
255
+ it('falls back to raw book cache order when shelf indexes are unavailable', async () => {
256
+ const command = getRegistry().get('weread/shelf');
257
+ expect(command?.func).toBeTypeOf('function');
258
+ const mockPage = {
259
+ getCookies: vi.fn()
260
+ .mockResolvedValueOnce([
261
+ { name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
262
+ ])
263
+ .mockResolvedValueOnce([
264
+ { name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
265
+ ]),
266
+ goto: vi.fn().mockResolvedValue(undefined),
267
+ evaluate: vi.fn().mockResolvedValue({
268
+ cacheFound: true,
269
+ rawBooks: [
270
+ {
271
+ bookId: '40055543',
272
+ title: '置身事内:中国政府与经济发展',
273
+ author: '兰小欢',
274
+ },
275
+ {
276
+ bookId: '29196155',
277
+ title: '文明、现代化、价值投资与中国',
278
+ author: '李录',
279
+ },
280
+ ],
281
+ shelfIndexes: [],
282
+ }),
283
+ };
284
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
285
+ ok: false,
286
+ status: 401,
287
+ json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
288
+ }));
289
+ const result = await command.func(mockPage, { limit: 2 });
290
+ expect(mockPage.getCookies).toHaveBeenCalledWith({ url: 'https://i.weread.qq.com/shelf/sync?synckey=0&lectureSynckey=0' });
291
+ expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
292
+ expect(result).toEqual([
293
+ {
294
+ title: '置身事内:中国政府与经济发展',
295
+ author: '兰小欢',
296
+ progress: '-',
297
+ bookId: '40055543',
298
+ },
299
+ {
300
+ title: '文明、现代化、价值投资与中国',
301
+ author: '李录',
302
+ progress: '-',
303
+ bookId: '29196155',
304
+ },
305
+ ]);
306
+ });
122
307
  });
@@ -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: "minimized"
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.2",
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.2",
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.2",
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.2",
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: 'minimized',
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.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
  }
@@ -15,6 +15,7 @@ export type { DomSnapshotOptions } from './dom-snapshot.js';
15
15
 
16
16
  import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
17
17
  import { __test__ as cdpTest } from './cdp.js';
18
+ import { isRetryableSettleError } from './page.js';
18
19
  import { withTimeoutMs } from '../runtime.js';
19
20
 
20
21
  export const __test__ = {
@@ -24,4 +25,5 @@ export const __test__ = {
24
25
  withTimeoutMs,
25
26
  selectCDPTarget: cdpTest.selectCDPTarget,
26
27
  scoreCDPTarget: cdpTest.scoreCDPTarget,
28
+ isRetryableSettleError,
27
29
  };
@@ -28,6 +28,12 @@ import {
28
28
  waitForDomStableJs,
29
29
  } from './dom-helpers.js';
30
30
 
31
+ export function isRetryableSettleError(err: unknown): boolean {
32
+ const message = err instanceof Error ? err.message : String(err);
33
+ return message.includes('Inspected target navigated or closed')
34
+ || (message.includes('-32000') && message.toLowerCase().includes('target'));
35
+ }
36
+
31
37
  /**
32
38
  * Page — implements IPage by talking to the daemon via HTTP.
33
39
  */
@@ -75,10 +81,27 @@ export class Page implements IPage {
75
81
  // settleMs is now a timeout cap (default 1000ms), not a fixed wait.
76
82
  if (options?.waitUntil !== 'none') {
77
83
  const maxMs = options?.settleMs ?? 1000;
78
- await sendCommand('exec', {
84
+ const settleOpts = {
79
85
  code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
80
86
  ...this._cmdOpts(),
81
- });
87
+ };
88
+ try {
89
+ await sendCommand('exec', settleOpts);
90
+ } catch (err) {
91
+ if (!isRetryableSettleError(err)) throw err;
92
+ // SPA client-side redirects can invalidate the CDP target after
93
+ // chrome.tabs reports 'complete'. Wait briefly for the new document
94
+ // to load, then retry the settle probe once.
95
+ try {
96
+ await new Promise((r) => setTimeout(r, 200));
97
+ await sendCommand('exec', settleOpts);
98
+ } catch (retryErr) {
99
+ if (!isRetryableSettleError(retryErr)) throw retryErr;
100
+ // Retry also failed — give up silently. Settle is best-effort
101
+ // after successful navigation; the next real command will surface
102
+ // any persistent target error immediately.
103
+ }
104
+ }
82
105
  }
83
106
  }
84
107
 
@@ -45,6 +45,12 @@ describe('browser helpers', () => {
45
45
  await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
46
46
  });
47
47
 
48
+ it('retries settle only for target-invalidated errors', () => {
49
+ expect(__test__.isRetryableSettleError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))).toBe(true);
50
+ expect(__test__.isRetryableSettleError(new Error('attach failed: target no longer exists'))).toBe(false);
51
+ expect(__test__.isRetryableSettleError(new Error('malformed exec payload'))).toBe(false);
52
+ });
53
+
48
54
  it('prefers the real Electron app target over DevTools and blank pages', () => {
49
55
  const target = __test__.selectCDPTarget([
50
56
  {
@@ -38,13 +38,14 @@ describe('apple-podcasts search command', () => {
38
38
  'https://itunes.apple.com/search?term=machine%20learning&media=podcast&limit=5',
39
39
  );
40
40
  expect(result).toEqual([
41
- {
41
+ expect.objectContaining({
42
42
  id: 42,
43
43
  title: 'Machine Learning Guide',
44
44
  author: 'OpenCLI',
45
45
  episodes: 12,
46
46
  genre: 'Technology',
47
- },
47
+ url: '',
48
+ }),
48
49
  ]);
49
50
  });
50
51
  });
@@ -54,6 +55,30 @@ describe('apple-podcasts top command', () => {
54
55
  vi.restoreAllMocks();
55
56
  });
56
57
 
58
+ it('adds a timeout signal to chart fetches', async () => {
59
+ const cmd = getRegistry().get('apple-podcasts/top');
60
+ expect(cmd?.func).toBeTypeOf('function');
61
+
62
+ const fetchMock = vi.fn().mockResolvedValue({
63
+ ok: true,
64
+ json: () => Promise.resolve({
65
+ feed: {
66
+ results: [
67
+ { id: '100', name: 'Top Show', artistName: 'Host A' },
68
+ ],
69
+ },
70
+ }),
71
+ });
72
+ vi.stubGlobal('fetch', fetchMock);
73
+
74
+ await cmd!.func!(null as any, { country: 'US', limit: 1 });
75
+
76
+ const [, options] = fetchMock.mock.calls[0] ?? [];
77
+ expect(options).toBeDefined();
78
+ expect(options.signal).toBeDefined();
79
+ expect(options.signal).toHaveProperty('aborted', false);
80
+ });
81
+
57
82
  it('uses the canonical Apple charts host and maps ranked results', async () => {
58
83
  const cmd = getRegistry().get('apple-podcasts/top');
59
84
  expect(cmd?.func).toBeTypeOf('function');
@@ -75,6 +100,9 @@ describe('apple-podcasts top command', () => {
75
100
 
76
101
  expect(fetchMock).toHaveBeenCalledWith(
77
102
  'https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json',
103
+ expect.objectContaining({
104
+ signal: expect.any(Object),
105
+ }),
78
106
  );
79
107
  expect(result).toEqual([
80
108
  { rank: 1, title: 'Top Show', author: 'Host A', id: '100' },
@@ -3,6 +3,7 @@ import { CliError } from '../../errors.js';
3
3
 
4
4
  // Apple Marketing Tools RSS API — public, no key required
5
5
  const CHARTS_URL = 'https://rss.marketingtools.apple.com/api/v2';
6
+ const CHARTS_TIMEOUT_MS = 15_000;
6
7
 
7
8
  cli({
8
9
  site: 'apple-podcasts',
@@ -21,7 +22,9 @@ cli({
21
22
  const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
22
23
  let resp: Response;
23
24
  try {
24
- resp = await fetch(url);
25
+ resp = await fetch(url, {
26
+ signal: AbortSignal.timeout(CHARTS_TIMEOUT_MS),
27
+ });
25
28
  } catch (error: any) {
26
29
  const reason = error?.cause?.code ?? error?.message ?? 'unknown network error';
27
30
  throw new CliError(
@@ -3,7 +3,7 @@ name: hot
3
3
  description: V2EX 热门话题
4
4
  domain: www.v2ex.com
5
5
  strategy: public
6
- browser: true
6
+ browser: false
7
7
 
8
8
  args:
9
9
  limit:
@@ -12,22 +12,8 @@ args:
12
12
  description: Number of topics
13
13
 
14
14
  pipeline:
15
- - navigate: https://www.v2ex.com/
16
-
17
- - evaluate: |
18
- (async () => {
19
- const response = await fetch('/api/topics/hot.json', {
20
- credentials: 'include',
21
- headers: {
22
- accept: 'application/json, text/plain, */*',
23
- 'x-requested-with': 'XMLHttpRequest',
24
- },
25
- });
26
- if (!response.ok) {
27
- throw new Error(`V2EX hot API request failed: ${response.status}`);
28
- }
29
- return await response.json();
30
- })()
15
+ - fetch:
16
+ url: https://www.v2ex.com/api/topics/hot.json
31
17
 
32
18
  - map:
33
19
  rank: ${{ index + 1 }}