@jackwener/opencli 1.7.3 → 1.7.5
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/README.md +81 -59
- package/README.zh-CN.md +93 -67
- package/cli-manifest.json +5015 -2975
- package/clis/antigravity/serve.js +71 -25
- package/clis/baidu-scholar/search.js +87 -0
- package/clis/baidu-scholar/search.test.js +23 -0
- package/clis/bilibili/favorite.js +18 -13
- package/clis/binance/depth.js +3 -4
- package/clis/boss/utils.js +2 -3
- package/clis/chatgpt-app/ax.js +6 -3
- package/clis/deepseek/ask.js +74 -0
- package/clis/deepseek/history.js +25 -0
- package/clis/deepseek/new.js +20 -0
- package/clis/deepseek/read.js +22 -0
- package/clis/deepseek/status.js +24 -0
- package/clis/deepseek/utils.js +208 -0
- package/clis/douban/search.js +1 -0
- package/clis/douban/search.test.js +11 -0
- package/clis/douban/subject.js +20 -93
- package/clis/douban/subject.test.js +11 -0
- package/clis/douban/utils.js +250 -8
- package/clis/douban/utils.test.js +179 -4
- package/clis/doubao/utils.js +319 -130
- package/clis/doubao/utils.test.js +241 -2
- package/clis/eastmoney/_secid.js +78 -0
- package/clis/eastmoney/announcement.js +52 -0
- package/clis/eastmoney/convertible.js +73 -0
- package/clis/eastmoney/etf.js +65 -0
- package/clis/eastmoney/holders.js +78 -0
- package/clis/eastmoney/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/eastmoney/index-board.js +96 -0
- package/clis/eastmoney/kline.js +87 -0
- package/clis/eastmoney/kuaixun.js +54 -0
- package/clis/eastmoney/longhu.js +67 -0
- package/clis/eastmoney/money-flow.js +78 -0
- package/clis/eastmoney/northbound.js +57 -0
- package/clis/eastmoney/quote.js +107 -0
- package/clis/eastmoney/rank.js +94 -0
- package/clis/eastmoney/sectors.js +76 -0
- package/clis/google-scholar/search.js +58 -0
- package/clis/google-scholar/search.test.js +23 -0
- package/clis/gov-law/commands.test.js +39 -0
- package/clis/gov-law/recent.js +22 -0
- package/clis/gov-law/search.js +41 -0
- package/clis/gov-law/shared.js +51 -0
- package/clis/gov-policy/commands.test.js +27 -0
- package/clis/gov-policy/recent.js +47 -0
- package/clis/gov-policy/search.js +48 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -0
- package/clis/nowcoder/companies.js +23 -0
- package/clis/nowcoder/creators.js +27 -0
- package/clis/nowcoder/detail.js +61 -0
- package/clis/nowcoder/experience.js +36 -0
- package/clis/nowcoder/hot.js +24 -0
- package/clis/nowcoder/jobs.js +21 -0
- package/clis/nowcoder/notifications.js +29 -0
- package/clis/nowcoder/papers.js +40 -0
- package/clis/nowcoder/practice.js +37 -0
- package/clis/nowcoder/recommend.js +30 -0
- package/clis/nowcoder/referral.js +39 -0
- package/clis/nowcoder/salary.js +40 -0
- package/clis/nowcoder/search.js +49 -0
- package/clis/nowcoder/suggest.js +33 -0
- package/clis/nowcoder/topics.js +27 -0
- package/clis/nowcoder/trending.js +25 -0
- package/clis/tdx/hot-rank.js +47 -0
- package/clis/tdx/hot-rank.test.js +59 -0
- package/clis/ths/hot-rank.js +49 -0
- package/clis/ths/hot-rank.test.js +64 -0
- package/clis/twitter/bookmarks.js +2 -1
- package/clis/twitter/list-add.js +337 -0
- package/clis/twitter/list-add.test.js +15 -0
- package/clis/twitter/list-remove.js +297 -0
- package/clis/twitter/list-remove.test.js +14 -0
- package/clis/twitter/list-tweets.js +185 -0
- package/clis/twitter/list-tweets.test.js +108 -0
- package/clis/twitter/lists.js +134 -47
- package/clis/twitter/lists.test.js +105 -38
- package/clis/uiverse/_shared.js +368 -0
- package/clis/uiverse/_shared.test.js +55 -0
- package/clis/uiverse/code.js +47 -0
- package/clis/uiverse/preview.js +71 -0
- package/clis/wanfang/search.js +66 -0
- package/clis/wanfang/search.test.js +23 -0
- package/clis/web/read.js +1 -1
- package/clis/weixin/download.js +3 -2
- package/clis/xiaohongshu/comments.js +2 -2
- package/clis/xiaohongshu/comments.test.js +46 -25
- package/clis/xiaohongshu/download.js +6 -7
- package/clis/xiaohongshu/download.test.js +17 -5
- package/clis/xiaohongshu/note-helpers.js +46 -12
- package/clis/xiaohongshu/note.js +3 -5
- package/clis/xiaohongshu/note.test.js +52 -25
- package/clis/xiaohongshu/publish.js +149 -28
- package/clis/xiaohongshu/publish.test.js +319 -6
- package/clis/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +53 -0
- package/clis/xiaoyuzhou/download.test.js +135 -0
- package/clis/xiaoyuzhou/episode.js +9 -4
- package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
- package/clis/xiaoyuzhou/podcast.js +9 -4
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- package/clis/xiaoyuzhou/utils.js +0 -40
- package/clis/xiaoyuzhou/utils.test.js +15 -75
- package/clis/youtube/feed.js +120 -0
- package/clis/youtube/history.js +118 -0
- package/clis/youtube/like.js +62 -0
- package/clis/youtube/playlist.js +97 -0
- package/clis/youtube/subscribe.js +71 -0
- package/clis/youtube/subscriptions.js +57 -0
- package/clis/youtube/unlike.js +62 -0
- package/clis/youtube/unsubscribe.js +71 -0
- package/clis/youtube/utils.js +122 -0
- package/clis/youtube/utils.test.js +32 -1
- package/clis/youtube/watch-later.js +76 -0
- package/clis/zsxq/dynamics.js +1 -1
- package/clis/zsxq/utils.js +6 -3
- package/clis/zsxq/utils.test.js +31 -0
- package/dist/src/browser/base-page.d.ts +1 -1
- package/dist/src/browser/base-page.js +25 -5
- package/dist/src/browser/bridge.d.ts +3 -0
- package/dist/src/browser/bridge.js +52 -15
- package/dist/src/browser/cdp.js +2 -1
- package/dist/src/browser/daemon-client.d.ts +7 -4
- package/dist/src/browser/daemon-client.js +6 -1
- package/dist/src/browser/daemon-client.test.js +40 -1
- package/dist/src/browser/dom-snapshot.js +20 -3
- package/dist/src/browser/page.d.ts +18 -5
- package/dist/src/browser/page.js +96 -15
- package/dist/src/browser/page.test.js +158 -1
- package/dist/src/browser/target-errors.d.ts +23 -0
- package/dist/src/browser/target-errors.js +29 -0
- package/dist/src/browser/target-errors.test.js +61 -0
- package/dist/src/browser/target-resolver.d.ts +57 -0
- package/dist/src/browser/target-resolver.js +298 -0
- package/dist/src/browser/target-resolver.test.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +272 -187
- package/dist/src/cli.test.js +167 -90
- package/dist/src/commanderAdapter.d.ts +0 -1
- package/dist/src/commanderAdapter.js +2 -16
- package/dist/src/commanderAdapter.test.js +1 -1
- package/dist/src/commands/daemon.d.ts +4 -2
- package/dist/src/commands/daemon.js +22 -2
- package/dist/src/commands/daemon.test.js +65 -2
- package/dist/src/completion-shared.js +2 -5
- package/dist/src/daemon.js +10 -0
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +32 -9
- package/dist/src/doctor.test.js +28 -12
- package/dist/src/download/article-download.d.ts +1 -0
- package/dist/src/download/article-download.js +3 -0
- package/dist/src/download/article-download.test.js +39 -0
- package/dist/src/external-clis.yaml +2 -2
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +3 -3
- package/dist/src/output.js +1 -5
- package/dist/src/output.test.js +0 -21
- package/dist/src/pipeline/steps/transform.js +1 -1
- package/dist/src/pipeline/template.d.ts +1 -0
- package/dist/src/pipeline/template.js +11 -3
- package/dist/src/pipeline/template.test.js +3 -0
- package/dist/src/pipeline/transform.test.js +14 -0
- package/dist/src/plugin.d.ts +8 -9
- package/dist/src/plugin.js +24 -28
- package/dist/src/plugin.test.js +16 -60
- package/dist/src/registry.d.ts +1 -0
- package/dist/src/registry.js +3 -2
- package/dist/src/registry.test.js +22 -0
- package/dist/src/types.d.ts +15 -6
- package/package.json +1 -1
- package/clis/twitter/lists-parser.js +0 -77
- package/clis/twitter/lists.d.ts +0 -5
- package/dist/src/cascade.d.ts +0 -46
- package/dist/src/cascade.js +0 -135
- package/dist/src/explore.d.ts +0 -99
- package/dist/src/explore.js +0 -402
- package/dist/src/generate-verified.d.ts +0 -105
- package/dist/src/generate-verified.js +0 -696
- package/dist/src/generate-verified.test.js +0 -925
- package/dist/src/generate.d.ts +0 -46
- package/dist/src/generate.js +0 -117
- package/dist/src/record.d.ts +0 -96
- package/dist/src/record.js +0 -657
- package/dist/src/record.test.js +0 -293
- package/dist/src/skill-generate.d.ts +0 -30
- package/dist/src/skill-generate.js +0 -75
- package/dist/src/skill-generate.test.js +0 -173
- package/dist/src/synthesize.d.ts +0 -97
- package/dist/src/synthesize.js +0 -208
- /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
- /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
- /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
package/dist/src/browser/page.js
CHANGED
|
@@ -15,26 +15,38 @@ import { generateStealthJs } from './stealth.js';
|
|
|
15
15
|
import { waitForDomStableJs } from './dom-helpers.js';
|
|
16
16
|
import { BasePage } from './base-page.js';
|
|
17
17
|
import { classifyBrowserError } from './errors.js';
|
|
18
|
+
import { log } from '../logger.js';
|
|
19
|
+
function isUnsupportedNetworkCaptureError(err) {
|
|
20
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
21
|
+
const normalized = message.toLowerCase();
|
|
22
|
+
return (normalized.includes('unknown action') && normalized.includes('network-capture'))
|
|
23
|
+
|| (normalized.includes('network capture') && normalized.includes('not supported'));
|
|
24
|
+
}
|
|
18
25
|
/**
|
|
19
26
|
* Page — implements IPage by talking to the daemon via HTTP.
|
|
20
27
|
*/
|
|
21
28
|
export class Page extends BasePage {
|
|
22
29
|
workspace;
|
|
23
|
-
|
|
30
|
+
_idleTimeout;
|
|
31
|
+
constructor(workspace = 'default', idleTimeout) {
|
|
24
32
|
super();
|
|
25
33
|
this.workspace = workspace;
|
|
34
|
+
this._idleTimeout = idleTimeout;
|
|
26
35
|
}
|
|
27
36
|
/** Active page identity (targetId), set after navigate and used in all subsequent commands */
|
|
28
37
|
_page;
|
|
38
|
+
_networkCaptureUnsupported = false;
|
|
39
|
+
_networkCaptureWarned = false;
|
|
29
40
|
/** Helper: spread workspace into command params */
|
|
30
41
|
_wsOpt() {
|
|
31
|
-
return { workspace: this.workspace };
|
|
42
|
+
return { workspace: this.workspace, ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }) };
|
|
32
43
|
}
|
|
33
44
|
/** Helper: spread workspace + page identity into command params */
|
|
34
45
|
_cmdOpts() {
|
|
35
46
|
return {
|
|
36
47
|
workspace: this.workspace,
|
|
37
48
|
...(this._page !== undefined && { page: this._page }),
|
|
49
|
+
...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
|
|
38
50
|
};
|
|
39
51
|
}
|
|
40
52
|
async goto(url, options) {
|
|
@@ -93,9 +105,18 @@ export class Page extends BasePage {
|
|
|
93
105
|
getActivePage() {
|
|
94
106
|
return this._page;
|
|
95
107
|
}
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
/** Bind this Page instance to a specific page identity (targetId). */
|
|
109
|
+
setActivePage(page) {
|
|
110
|
+
this._page = page;
|
|
111
|
+
this._lastUrl = null;
|
|
112
|
+
}
|
|
113
|
+
_markUnsupportedNetworkCapture() {
|
|
114
|
+
this._networkCaptureUnsupported = true;
|
|
115
|
+
if (this._networkCaptureWarned)
|
|
116
|
+
return;
|
|
117
|
+
this._networkCaptureWarned = true;
|
|
118
|
+
log.warn('Browser Bridge extension does not support network capture; continuing without it. ' +
|
|
119
|
+
'Explore output may miss API endpoints until you reload or reinstall the extension.');
|
|
99
120
|
}
|
|
100
121
|
async evaluate(js) {
|
|
101
122
|
const code = wrapForEval(js);
|
|
@@ -125,16 +146,47 @@ export class Page extends BasePage {
|
|
|
125
146
|
finally {
|
|
126
147
|
this._page = undefined;
|
|
127
148
|
this._lastUrl = null;
|
|
149
|
+
this._networkCaptureUnsupported = false;
|
|
150
|
+
this._networkCaptureWarned = false;
|
|
128
151
|
}
|
|
129
152
|
}
|
|
130
153
|
async tabs() {
|
|
131
154
|
const result = await sendCommand('tabs', { op: 'list', ...this._wsOpt() });
|
|
132
155
|
return Array.isArray(result) ? result : [];
|
|
133
156
|
}
|
|
134
|
-
async
|
|
135
|
-
const result = await sendCommandFull('tabs', {
|
|
157
|
+
async newTab(url) {
|
|
158
|
+
const result = await sendCommandFull('tabs', {
|
|
159
|
+
op: 'new',
|
|
160
|
+
...(url !== undefined && { url }),
|
|
161
|
+
...this._wsOpt(),
|
|
162
|
+
});
|
|
163
|
+
this._lastUrl = null;
|
|
164
|
+
return result.page;
|
|
165
|
+
}
|
|
166
|
+
async closeTab(target) {
|
|
167
|
+
const params = { op: 'close', ...this._wsOpt() };
|
|
168
|
+
if (typeof target === 'number')
|
|
169
|
+
params.index = target;
|
|
170
|
+
else if (typeof target === 'string')
|
|
171
|
+
params.page = target;
|
|
172
|
+
else if (this._page !== undefined)
|
|
173
|
+
params.page = this._page;
|
|
174
|
+
const result = await sendCommand('tabs', params);
|
|
175
|
+
const closedPage = typeof result?.closed === 'string' ? result.closed : undefined;
|
|
176
|
+
if ((closedPage && closedPage === this._page) || (!closedPage && (target === undefined || target === this._page))) {
|
|
177
|
+
this._page = undefined;
|
|
178
|
+
this._lastUrl = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async selectTab(target) {
|
|
182
|
+
const result = await sendCommandFull('tabs', {
|
|
183
|
+
op: 'select',
|
|
184
|
+
...(typeof target === 'number' ? { index: target } : { page: target }),
|
|
185
|
+
...this._wsOpt(),
|
|
186
|
+
});
|
|
136
187
|
if (result.page)
|
|
137
188
|
this._page = result.page;
|
|
189
|
+
this._lastUrl = null;
|
|
138
190
|
}
|
|
139
191
|
/**
|
|
140
192
|
* Capture a screenshot via CDP Page.captureScreenshot.
|
|
@@ -152,16 +204,37 @@ export class Page extends BasePage {
|
|
|
152
204
|
return base64;
|
|
153
205
|
}
|
|
154
206
|
async startNetworkCapture(pattern = '') {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
207
|
+
if (this._networkCaptureUnsupported)
|
|
208
|
+
return false;
|
|
209
|
+
try {
|
|
210
|
+
await sendCommand('network-capture-start', {
|
|
211
|
+
pattern,
|
|
212
|
+
...this._cmdOpts(),
|
|
213
|
+
});
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
if (!isUnsupportedNetworkCaptureError(err))
|
|
218
|
+
throw err;
|
|
219
|
+
this._markUnsupportedNetworkCapture();
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
159
222
|
}
|
|
160
223
|
async readNetworkCapture() {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
224
|
+
if (this._networkCaptureUnsupported)
|
|
225
|
+
return [];
|
|
226
|
+
try {
|
|
227
|
+
const result = await sendCommand('network-capture-read', {
|
|
228
|
+
...this._cmdOpts(),
|
|
229
|
+
});
|
|
230
|
+
return Array.isArray(result) ? result : [];
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
if (!isUnsupportedNetworkCaptureError(err))
|
|
234
|
+
throw err;
|
|
235
|
+
this._markUnsupportedNetworkCapture();
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
165
238
|
}
|
|
166
239
|
/**
|
|
167
240
|
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
|
|
@@ -187,6 +260,14 @@ export class Page extends BasePage {
|
|
|
187
260
|
throw new Error('insertText returned no inserted flag — command may not be supported by the extension');
|
|
188
261
|
}
|
|
189
262
|
}
|
|
263
|
+
async frames() {
|
|
264
|
+
const result = await sendCommand('frames', { ...this._cmdOpts() });
|
|
265
|
+
return Array.isArray(result) ? result : [];
|
|
266
|
+
}
|
|
267
|
+
async evaluateInFrame(js, frameIndex) {
|
|
268
|
+
const code = wrapForEval(js);
|
|
269
|
+
return sendCommand('exec', { code, frameIndex, ...this._cmdOpts() });
|
|
270
|
+
}
|
|
190
271
|
async cdp(method, params = {}) {
|
|
191
272
|
return sendCommand('cdp', {
|
|
192
273
|
cdpMethod: method,
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
const { sendCommandMock } = vi.hoisted(() => ({
|
|
2
|
+
const { sendCommandMock, sendCommandFullMock } = vi.hoisted(() => ({
|
|
3
3
|
sendCommandMock: vi.fn(),
|
|
4
|
+
sendCommandFullMock: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
const { warnMock } = vi.hoisted(() => ({
|
|
7
|
+
warnMock: vi.fn(),
|
|
4
8
|
}));
|
|
5
9
|
vi.mock('./daemon-client.js', () => ({
|
|
6
10
|
sendCommand: sendCommandMock,
|
|
11
|
+
sendCommandFull: sendCommandFullMock,
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('../logger.js', () => ({
|
|
14
|
+
log: {
|
|
15
|
+
warn: warnMock,
|
|
16
|
+
},
|
|
7
17
|
}));
|
|
8
18
|
import { Page } from './page.js';
|
|
9
19
|
describe('Page.getCurrentUrl', () => {
|
|
10
20
|
beforeEach(() => {
|
|
11
21
|
sendCommandMock.mockReset();
|
|
22
|
+
sendCommandFullMock.mockReset();
|
|
23
|
+
warnMock.mockReset();
|
|
12
24
|
});
|
|
13
25
|
it('reads the real browser URL when no local navigation cache exists', async () => {
|
|
14
26
|
sendCommandMock.mockResolvedValueOnce('https://notebooklm.google.com/notebook/nb-live');
|
|
@@ -31,6 +43,8 @@ describe('Page.getCurrentUrl', () => {
|
|
|
31
43
|
describe('Page.evaluate', () => {
|
|
32
44
|
beforeEach(() => {
|
|
33
45
|
sendCommandMock.mockReset();
|
|
46
|
+
sendCommandFullMock.mockReset();
|
|
47
|
+
warnMock.mockReset();
|
|
34
48
|
});
|
|
35
49
|
it('retries once when the inspected target navigated during exec', async () => {
|
|
36
50
|
sendCommandMock
|
|
@@ -42,3 +56,146 @@ describe('Page.evaluate', () => {
|
|
|
42
56
|
expect(sendCommandMock).toHaveBeenCalledTimes(2);
|
|
43
57
|
});
|
|
44
58
|
});
|
|
59
|
+
describe('Page network capture compatibility', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
sendCommandMock.mockReset();
|
|
62
|
+
sendCommandFullMock.mockReset();
|
|
63
|
+
warnMock.mockReset();
|
|
64
|
+
});
|
|
65
|
+
it('treats unknown network-capture-start as unsupported and memoizes it', async () => {
|
|
66
|
+
sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-start'));
|
|
67
|
+
const page = new Page('site:notebooklm');
|
|
68
|
+
await expect(page.startNetworkCapture()).resolves.toBe(false);
|
|
69
|
+
await expect(page.startNetworkCapture()).resolves.toBe(false);
|
|
70
|
+
expect(sendCommandMock).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('does not support network capture'));
|
|
73
|
+
expect(sendCommandMock).toHaveBeenCalledWith('network-capture-start', expect.objectContaining({
|
|
74
|
+
workspace: 'site:notebooklm',
|
|
75
|
+
}));
|
|
76
|
+
});
|
|
77
|
+
it('returns an empty capture when network-capture-read is unsupported', async () => {
|
|
78
|
+
sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-read'));
|
|
79
|
+
const page = new Page('site:notebooklm');
|
|
80
|
+
await expect(page.readNetworkCapture()).resolves.toEqual([]);
|
|
81
|
+
await expect(page.readNetworkCapture()).resolves.toEqual([]);
|
|
82
|
+
expect(sendCommandMock).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(sendCommandMock).toHaveBeenCalledWith('network-capture-read', expect.objectContaining({
|
|
85
|
+
workspace: 'site:notebooklm',
|
|
86
|
+
}));
|
|
87
|
+
});
|
|
88
|
+
it('rethrows unrelated network capture failures', async () => {
|
|
89
|
+
sendCommandMock.mockRejectedValueOnce(new Error('Extension disconnected'));
|
|
90
|
+
const page = new Page('site:notebooklm');
|
|
91
|
+
await expect(page.startNetworkCapture()).rejects.toThrow('Extension disconnected');
|
|
92
|
+
expect(sendCommandMock).toHaveBeenCalledTimes(1);
|
|
93
|
+
expect(warnMock).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
it('warns only once even if both start and read hit the compatibility fallback', async () => {
|
|
96
|
+
sendCommandMock
|
|
97
|
+
.mockRejectedValueOnce(new Error('Unknown action: network-capture-start'))
|
|
98
|
+
.mockRejectedValueOnce(new Error('Unknown action: network-capture-read'));
|
|
99
|
+
const page = new Page('site:notebooklm');
|
|
100
|
+
await expect(page.startNetworkCapture()).resolves.toBe(false);
|
|
101
|
+
await expect(page.readNetworkCapture()).resolves.toEqual([]);
|
|
102
|
+
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('Page active target tracking', () => {
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
sendCommandMock.mockReset();
|
|
108
|
+
sendCommandFullMock.mockReset();
|
|
109
|
+
warnMock.mockReset();
|
|
110
|
+
});
|
|
111
|
+
it('tracks only one active page identity at a time', async () => {
|
|
112
|
+
sendCommandFullMock
|
|
113
|
+
.mockResolvedValueOnce({ data: { url: 'https://first.example' }, page: 'page-1' })
|
|
114
|
+
.mockResolvedValueOnce({ data: { selected: true }, page: 'page-2' });
|
|
115
|
+
sendCommandMock.mockResolvedValue('ok');
|
|
116
|
+
const page = new Page('browser:default');
|
|
117
|
+
await page.goto('https://first.example', { waitUntil: 'none' });
|
|
118
|
+
expect(page.getActivePage()).toBe('page-1');
|
|
119
|
+
await page.selectTab(1);
|
|
120
|
+
expect(page.getActivePage()).toBe('page-2');
|
|
121
|
+
await page.evaluate('1 + 1');
|
|
122
|
+
expect(sendCommandMock).toHaveBeenLastCalledWith('exec', expect.objectContaining({
|
|
123
|
+
workspace: 'browser:default',
|
|
124
|
+
page: 'page-2',
|
|
125
|
+
}));
|
|
126
|
+
});
|
|
127
|
+
it('allows the caller to bind a specific active page identity explicitly', async () => {
|
|
128
|
+
sendCommandMock.mockResolvedValue('bound');
|
|
129
|
+
const page = new Page('browser:default');
|
|
130
|
+
page.setActivePage?.('page-explicit');
|
|
131
|
+
await page.evaluate('1 + 1');
|
|
132
|
+
expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({
|
|
133
|
+
workspace: 'browser:default',
|
|
134
|
+
page: 'page-explicit',
|
|
135
|
+
}));
|
|
136
|
+
});
|
|
137
|
+
it('creates a new tab without changing the current active page binding', async () => {
|
|
138
|
+
sendCommandFullMock
|
|
139
|
+
.mockResolvedValueOnce({ data: { url: 'https://first.example' }, page: 'page-1' })
|
|
140
|
+
.mockResolvedValueOnce({
|
|
141
|
+
data: { url: 'https://second.example' },
|
|
142
|
+
page: 'page-2',
|
|
143
|
+
});
|
|
144
|
+
sendCommandMock.mockResolvedValue('ok');
|
|
145
|
+
const page = new Page('browser:default');
|
|
146
|
+
await page.goto('https://first.example', { waitUntil: 'none' });
|
|
147
|
+
const created = await page.newTab?.('https://second.example');
|
|
148
|
+
expect(created).toBe('page-2');
|
|
149
|
+
expect(page.getActivePage()).toBe('page-1');
|
|
150
|
+
await page.evaluate('1 + 1');
|
|
151
|
+
expect(sendCommandMock).toHaveBeenLastCalledWith('exec', expect.objectContaining({
|
|
152
|
+
workspace: 'browser:default',
|
|
153
|
+
page: 'page-1',
|
|
154
|
+
}));
|
|
155
|
+
});
|
|
156
|
+
it('allows the caller to adopt a new tab explicitly after creation', async () => {
|
|
157
|
+
sendCommandFullMock.mockResolvedValueOnce({
|
|
158
|
+
data: { url: 'https://second.example' },
|
|
159
|
+
page: 'page-2',
|
|
160
|
+
});
|
|
161
|
+
const page = new Page('browser:default');
|
|
162
|
+
const created = await page.newTab?.('https://second.example');
|
|
163
|
+
expect(created).toBe('page-2');
|
|
164
|
+
expect(page.getActivePage()).toBeUndefined();
|
|
165
|
+
page.setActivePage?.(created);
|
|
166
|
+
expect(page.getActivePage()).toBe('page-2');
|
|
167
|
+
expect(sendCommandFullMock).toHaveBeenCalledWith('tabs', expect.objectContaining({
|
|
168
|
+
op: 'new',
|
|
169
|
+
url: 'https://second.example',
|
|
170
|
+
workspace: 'browser:default',
|
|
171
|
+
}));
|
|
172
|
+
});
|
|
173
|
+
it('closes a tab by explicit page identity', async () => {
|
|
174
|
+
sendCommandMock.mockResolvedValueOnce({ closed: 'page-2' });
|
|
175
|
+
const page = new Page('browser:default');
|
|
176
|
+
await page.closeTab?.('page-2');
|
|
177
|
+
expect(sendCommandMock).toHaveBeenCalledWith('tabs', expect.objectContaining({
|
|
178
|
+
op: 'close',
|
|
179
|
+
workspace: 'browser:default',
|
|
180
|
+
page: 'page-2',
|
|
181
|
+
}));
|
|
182
|
+
});
|
|
183
|
+
it('clears the active page binding when closing the selected tab by numeric index', async () => {
|
|
184
|
+
sendCommandFullMock.mockResolvedValueOnce({ data: { selected: true }, page: 'page-2' });
|
|
185
|
+
sendCommandMock
|
|
186
|
+
.mockResolvedValueOnce({ closed: 'page-2' })
|
|
187
|
+
.mockResolvedValueOnce('ok');
|
|
188
|
+
const page = new Page('browser:default');
|
|
189
|
+
await page.selectTab(1);
|
|
190
|
+
expect(page.getActivePage()).toBe('page-2');
|
|
191
|
+
await page.closeTab?.(1);
|
|
192
|
+
expect(page.getActivePage()).toBeUndefined();
|
|
193
|
+
await page.evaluate('1 + 1');
|
|
194
|
+
const evalCall = sendCommandMock.mock.calls.at(-1);
|
|
195
|
+
expect(evalCall?.[0]).toBe('exec');
|
|
196
|
+
expect(evalCall?.[1]).toEqual(expect.objectContaining({
|
|
197
|
+
workspace: 'browser:default',
|
|
198
|
+
}));
|
|
199
|
+
expect(evalCall?.[1]).not.toHaveProperty('page');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured error types for the target resolution system.
|
|
3
|
+
*
|
|
4
|
+
* Every browser action (click, type, select, get) that targets a DOM element
|
|
5
|
+
* goes through the unified resolver. When resolution fails, one of these
|
|
6
|
+
* structured errors is thrown so that AI agents and adapter authors get
|
|
7
|
+
* actionable diagnostics instead of a generic "Element not found".
|
|
8
|
+
*/
|
|
9
|
+
export type TargetErrorCode = 'not_found' | 'ambiguous' | 'stale_ref';
|
|
10
|
+
export interface TargetErrorInfo {
|
|
11
|
+
code: TargetErrorCode;
|
|
12
|
+
message: string;
|
|
13
|
+
hint: string;
|
|
14
|
+
candidates?: string[];
|
|
15
|
+
}
|
|
16
|
+
export declare class TargetError extends Error {
|
|
17
|
+
readonly code: TargetErrorCode;
|
|
18
|
+
readonly hint: string;
|
|
19
|
+
readonly candidates?: string[];
|
|
20
|
+
constructor(info: TargetErrorInfo);
|
|
21
|
+
/** Serialize for structured output to AI agents */
|
|
22
|
+
toJSON(): TargetErrorInfo;
|
|
23
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured error types for the target resolution system.
|
|
3
|
+
*
|
|
4
|
+
* Every browser action (click, type, select, get) that targets a DOM element
|
|
5
|
+
* goes through the unified resolver. When resolution fails, one of these
|
|
6
|
+
* structured errors is thrown so that AI agents and adapter authors get
|
|
7
|
+
* actionable diagnostics instead of a generic "Element not found".
|
|
8
|
+
*/
|
|
9
|
+
export class TargetError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
hint;
|
|
12
|
+
candidates;
|
|
13
|
+
constructor(info) {
|
|
14
|
+
super(info.message);
|
|
15
|
+
this.name = 'TargetError';
|
|
16
|
+
this.code = info.code;
|
|
17
|
+
this.hint = info.hint;
|
|
18
|
+
this.candidates = info.candidates;
|
|
19
|
+
}
|
|
20
|
+
/** Serialize for structured output to AI agents */
|
|
21
|
+
toJSON() {
|
|
22
|
+
return {
|
|
23
|
+
code: this.code,
|
|
24
|
+
message: this.message,
|
|
25
|
+
hint: this.hint,
|
|
26
|
+
...(this.candidates && { candidates: this.candidates }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { TargetError } from './target-errors.js';
|
|
3
|
+
describe('TargetError', () => {
|
|
4
|
+
it('creates not_found error with code and hint', () => {
|
|
5
|
+
const err = new TargetError({
|
|
6
|
+
code: 'not_found',
|
|
7
|
+
message: 'ref=99 not found in DOM',
|
|
8
|
+
hint: 'Re-run `opencli browser state` to get a fresh snapshot.',
|
|
9
|
+
});
|
|
10
|
+
expect(err).toBeInstanceOf(Error);
|
|
11
|
+
expect(err.name).toBe('TargetError');
|
|
12
|
+
expect(err.code).toBe('not_found');
|
|
13
|
+
expect(err.message).toBe('ref=99 not found in DOM');
|
|
14
|
+
expect(err.hint).toContain('fresh snapshot');
|
|
15
|
+
expect(err.candidates).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
it('creates ambiguous error with candidates', () => {
|
|
18
|
+
const err = new TargetError({
|
|
19
|
+
code: 'ambiguous',
|
|
20
|
+
message: 'CSS selector ".btn" matched 3 elements',
|
|
21
|
+
hint: 'Use a more specific selector.',
|
|
22
|
+
candidates: ['<button> "Login"', '<button> "Sign Up"', '<button> "Cancel"'],
|
|
23
|
+
});
|
|
24
|
+
expect(err.code).toBe('ambiguous');
|
|
25
|
+
expect(err.candidates).toHaveLength(3);
|
|
26
|
+
expect(err.candidates[0]).toContain('Login');
|
|
27
|
+
});
|
|
28
|
+
it('creates stale_ref error', () => {
|
|
29
|
+
const err = new TargetError({
|
|
30
|
+
code: 'stale_ref',
|
|
31
|
+
message: 'ref=12 was <button>"Login" but now points to <div>"Header"',
|
|
32
|
+
hint: 'Re-run `opencli browser state` to refresh.',
|
|
33
|
+
});
|
|
34
|
+
expect(err.code).toBe('stale_ref');
|
|
35
|
+
expect(err.message).toContain('was <button>');
|
|
36
|
+
});
|
|
37
|
+
it('serializes to JSON for structured output', () => {
|
|
38
|
+
const err = new TargetError({
|
|
39
|
+
code: 'ambiguous',
|
|
40
|
+
message: 'matched 3',
|
|
41
|
+
hint: 'be specific',
|
|
42
|
+
candidates: ['a', 'b'],
|
|
43
|
+
});
|
|
44
|
+
const json = err.toJSON();
|
|
45
|
+
expect(json).toEqual({
|
|
46
|
+
code: 'ambiguous',
|
|
47
|
+
message: 'matched 3',
|
|
48
|
+
hint: 'be specific',
|
|
49
|
+
candidates: ['a', 'b'],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
it('omits candidates from JSON when not present', () => {
|
|
53
|
+
const err = new TargetError({
|
|
54
|
+
code: 'not_found',
|
|
55
|
+
message: 'gone',
|
|
56
|
+
hint: 'refresh',
|
|
57
|
+
});
|
|
58
|
+
const json = err.toJSON();
|
|
59
|
+
expect(json).not.toHaveProperty('candidates');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified target resolver for browser actions.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the ad-hoc 4-strategy fallback in dom-helpers.ts with a
|
|
5
|
+
* principled resolution pipeline:
|
|
6
|
+
*
|
|
7
|
+
* 1. Input classification: numeric → ref path, CSS-like → CSS path
|
|
8
|
+
* 2. Ref path: lookup by data-opencli-ref, then verify fingerprint
|
|
9
|
+
* 3. CSS path: querySelectorAll + uniqueness check
|
|
10
|
+
* 4. Structured errors: stale_ref / ambiguous / not_found
|
|
11
|
+
*
|
|
12
|
+
* All JS is generated as strings for page.evaluate() — runs in the browser.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Generate JS that resolves a target to a single DOM element.
|
|
16
|
+
*
|
|
17
|
+
* Returns a JS expression that evaluates to:
|
|
18
|
+
* { ok: true, el: Element } — success (el is assigned to `__resolved`)
|
|
19
|
+
* { ok: false, code, message, hint, candidates } — structured error
|
|
20
|
+
*
|
|
21
|
+
* The resolved element is stored in `__resolved` for the caller to use.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveTargetJs(ref: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Generate JS for click that uses the unified resolver.
|
|
26
|
+
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
27
|
+
*/
|
|
28
|
+
export declare function clickResolvedJs(): string;
|
|
29
|
+
/**
|
|
30
|
+
* Generate JS for type that uses the unified resolver.
|
|
31
|
+
*/
|
|
32
|
+
export declare function typeResolvedJs(text: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Generate JS for scrollTo that uses the unified resolver.
|
|
35
|
+
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
36
|
+
*/
|
|
37
|
+
export declare function scrollResolvedJs(): string;
|
|
38
|
+
/**
|
|
39
|
+
* Generate JS to get text content of resolved element.
|
|
40
|
+
*/
|
|
41
|
+
export declare function getTextResolvedJs(): string;
|
|
42
|
+
/**
|
|
43
|
+
* Generate JS to get value of resolved input/textarea element.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getValueResolvedJs(): string;
|
|
46
|
+
/**
|
|
47
|
+
* Generate JS to get all attributes of resolved element.
|
|
48
|
+
*/
|
|
49
|
+
export declare function getAttributesResolvedJs(): string;
|
|
50
|
+
/**
|
|
51
|
+
* Generate JS to select an option on a resolved <select> element.
|
|
52
|
+
*/
|
|
53
|
+
export declare function selectResolvedJs(option: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* Generate JS to check if resolved element is an autocomplete/combobox field.
|
|
56
|
+
*/
|
|
57
|
+
export declare function isAutocompleteResolvedJs(): string;
|