@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.
Files changed (197) hide show
  1. package/README.md +81 -59
  2. package/README.zh-CN.md +93 -67
  3. package/cli-manifest.json +5015 -2975
  4. package/clis/antigravity/serve.js +71 -25
  5. package/clis/baidu-scholar/search.js +87 -0
  6. package/clis/baidu-scholar/search.test.js +23 -0
  7. package/clis/bilibili/favorite.js +18 -13
  8. package/clis/binance/depth.js +3 -4
  9. package/clis/boss/utils.js +2 -3
  10. package/clis/chatgpt-app/ax.js +6 -3
  11. package/clis/deepseek/ask.js +74 -0
  12. package/clis/deepseek/history.js +25 -0
  13. package/clis/deepseek/new.js +20 -0
  14. package/clis/deepseek/read.js +22 -0
  15. package/clis/deepseek/status.js +24 -0
  16. package/clis/deepseek/utils.js +208 -0
  17. package/clis/douban/search.js +1 -0
  18. package/clis/douban/search.test.js +11 -0
  19. package/clis/douban/subject.js +20 -93
  20. package/clis/douban/subject.test.js +11 -0
  21. package/clis/douban/utils.js +250 -8
  22. package/clis/douban/utils.test.js +179 -4
  23. package/clis/doubao/utils.js +319 -130
  24. package/clis/doubao/utils.test.js +241 -2
  25. package/clis/eastmoney/_secid.js +78 -0
  26. package/clis/eastmoney/announcement.js +52 -0
  27. package/clis/eastmoney/convertible.js +73 -0
  28. package/clis/eastmoney/etf.js +65 -0
  29. package/clis/eastmoney/holders.js +78 -0
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/eastmoney/index-board.js +96 -0
  33. package/clis/eastmoney/kline.js +87 -0
  34. package/clis/eastmoney/kuaixun.js +54 -0
  35. package/clis/eastmoney/longhu.js +67 -0
  36. package/clis/eastmoney/money-flow.js +78 -0
  37. package/clis/eastmoney/northbound.js +57 -0
  38. package/clis/eastmoney/quote.js +107 -0
  39. package/clis/eastmoney/rank.js +94 -0
  40. package/clis/eastmoney/sectors.js +76 -0
  41. package/clis/google-scholar/search.js +58 -0
  42. package/clis/google-scholar/search.test.js +23 -0
  43. package/clis/gov-law/commands.test.js +39 -0
  44. package/clis/gov-law/recent.js +22 -0
  45. package/clis/gov-law/search.js +41 -0
  46. package/clis/gov-law/shared.js +51 -0
  47. package/clis/gov-policy/commands.test.js +27 -0
  48. package/clis/gov-policy/recent.js +47 -0
  49. package/clis/gov-policy/search.js +48 -0
  50. package/clis/grok/image.test.ts +107 -0
  51. package/clis/grok/image.ts +356 -0
  52. package/clis/nowcoder/companies.js +23 -0
  53. package/clis/nowcoder/creators.js +27 -0
  54. package/clis/nowcoder/detail.js +61 -0
  55. package/clis/nowcoder/experience.js +36 -0
  56. package/clis/nowcoder/hot.js +24 -0
  57. package/clis/nowcoder/jobs.js +21 -0
  58. package/clis/nowcoder/notifications.js +29 -0
  59. package/clis/nowcoder/papers.js +40 -0
  60. package/clis/nowcoder/practice.js +37 -0
  61. package/clis/nowcoder/recommend.js +30 -0
  62. package/clis/nowcoder/referral.js +39 -0
  63. package/clis/nowcoder/salary.js +40 -0
  64. package/clis/nowcoder/search.js +49 -0
  65. package/clis/nowcoder/suggest.js +33 -0
  66. package/clis/nowcoder/topics.js +27 -0
  67. package/clis/nowcoder/trending.js +25 -0
  68. package/clis/tdx/hot-rank.js +47 -0
  69. package/clis/tdx/hot-rank.test.js +59 -0
  70. package/clis/ths/hot-rank.js +49 -0
  71. package/clis/ths/hot-rank.test.js +64 -0
  72. package/clis/twitter/bookmarks.js +2 -1
  73. package/clis/twitter/list-add.js +337 -0
  74. package/clis/twitter/list-add.test.js +15 -0
  75. package/clis/twitter/list-remove.js +297 -0
  76. package/clis/twitter/list-remove.test.js +14 -0
  77. package/clis/twitter/list-tweets.js +185 -0
  78. package/clis/twitter/list-tweets.test.js +108 -0
  79. package/clis/twitter/lists.js +134 -47
  80. package/clis/twitter/lists.test.js +105 -38
  81. package/clis/uiverse/_shared.js +368 -0
  82. package/clis/uiverse/_shared.test.js +55 -0
  83. package/clis/uiverse/code.js +47 -0
  84. package/clis/uiverse/preview.js +71 -0
  85. package/clis/wanfang/search.js +66 -0
  86. package/clis/wanfang/search.test.js +23 -0
  87. package/clis/web/read.js +1 -1
  88. package/clis/weixin/download.js +3 -2
  89. package/clis/xiaohongshu/comments.js +2 -2
  90. package/clis/xiaohongshu/comments.test.js +46 -25
  91. package/clis/xiaohongshu/download.js +6 -7
  92. package/clis/xiaohongshu/download.test.js +17 -5
  93. package/clis/xiaohongshu/note-helpers.js +46 -12
  94. package/clis/xiaohongshu/note.js +3 -5
  95. package/clis/xiaohongshu/note.test.js +52 -25
  96. package/clis/xiaohongshu/publish.js +149 -28
  97. package/clis/xiaohongshu/publish.test.js +319 -6
  98. package/clis/xiaoyuzhou/auth.js +303 -0
  99. package/clis/xiaoyuzhou/auth.test.js +124 -0
  100. package/clis/xiaoyuzhou/download.js +53 -0
  101. package/clis/xiaoyuzhou/download.test.js +135 -0
  102. package/clis/xiaoyuzhou/episode.js +9 -4
  103. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  104. package/clis/xiaoyuzhou/podcast.js +9 -4
  105. package/clis/xiaoyuzhou/transcript.js +76 -0
  106. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  107. package/clis/xiaoyuzhou/utils.js +0 -40
  108. package/clis/xiaoyuzhou/utils.test.js +15 -75
  109. package/clis/youtube/feed.js +120 -0
  110. package/clis/youtube/history.js +118 -0
  111. package/clis/youtube/like.js +62 -0
  112. package/clis/youtube/playlist.js +97 -0
  113. package/clis/youtube/subscribe.js +71 -0
  114. package/clis/youtube/subscriptions.js +57 -0
  115. package/clis/youtube/unlike.js +62 -0
  116. package/clis/youtube/unsubscribe.js +71 -0
  117. package/clis/youtube/utils.js +122 -0
  118. package/clis/youtube/utils.test.js +32 -1
  119. package/clis/youtube/watch-later.js +76 -0
  120. package/clis/zsxq/dynamics.js +1 -1
  121. package/clis/zsxq/utils.js +6 -3
  122. package/clis/zsxq/utils.test.js +31 -0
  123. package/dist/src/browser/base-page.d.ts +1 -1
  124. package/dist/src/browser/base-page.js +25 -5
  125. package/dist/src/browser/bridge.d.ts +3 -0
  126. package/dist/src/browser/bridge.js +52 -15
  127. package/dist/src/browser/cdp.js +2 -1
  128. package/dist/src/browser/daemon-client.d.ts +7 -4
  129. package/dist/src/browser/daemon-client.js +6 -1
  130. package/dist/src/browser/daemon-client.test.js +40 -1
  131. package/dist/src/browser/dom-snapshot.js +20 -3
  132. package/dist/src/browser/page.d.ts +18 -5
  133. package/dist/src/browser/page.js +96 -15
  134. package/dist/src/browser/page.test.js +158 -1
  135. package/dist/src/browser/target-errors.d.ts +23 -0
  136. package/dist/src/browser/target-errors.js +29 -0
  137. package/dist/src/browser/target-errors.test.js +61 -0
  138. package/dist/src/browser/target-resolver.d.ts +57 -0
  139. package/dist/src/browser/target-resolver.js +298 -0
  140. package/dist/src/browser/target-resolver.test.js +43 -0
  141. package/dist/src/browser.test.js +38 -1
  142. package/dist/src/cli.js +272 -187
  143. package/dist/src/cli.test.js +167 -90
  144. package/dist/src/commanderAdapter.d.ts +0 -1
  145. package/dist/src/commanderAdapter.js +2 -16
  146. package/dist/src/commanderAdapter.test.js +1 -1
  147. package/dist/src/commands/daemon.d.ts +4 -2
  148. package/dist/src/commands/daemon.js +22 -2
  149. package/dist/src/commands/daemon.test.js +65 -2
  150. package/dist/src/completion-shared.js +2 -5
  151. package/dist/src/daemon.js +10 -0
  152. package/dist/src/doctor.d.ts +1 -0
  153. package/dist/src/doctor.js +32 -9
  154. package/dist/src/doctor.test.js +28 -12
  155. package/dist/src/download/article-download.d.ts +1 -0
  156. package/dist/src/download/article-download.js +3 -0
  157. package/dist/src/download/article-download.test.js +39 -0
  158. package/dist/src/external-clis.yaml +2 -2
  159. package/dist/src/logger.d.ts +2 -2
  160. package/dist/src/logger.js +3 -3
  161. package/dist/src/output.js +1 -5
  162. package/dist/src/output.test.js +0 -21
  163. package/dist/src/pipeline/steps/transform.js +1 -1
  164. package/dist/src/pipeline/template.d.ts +1 -0
  165. package/dist/src/pipeline/template.js +11 -3
  166. package/dist/src/pipeline/template.test.js +3 -0
  167. package/dist/src/pipeline/transform.test.js +14 -0
  168. package/dist/src/plugin.d.ts +8 -9
  169. package/dist/src/plugin.js +24 -28
  170. package/dist/src/plugin.test.js +16 -60
  171. package/dist/src/registry.d.ts +1 -0
  172. package/dist/src/registry.js +3 -2
  173. package/dist/src/registry.test.js +22 -0
  174. package/dist/src/types.d.ts +15 -6
  175. package/package.json +1 -1
  176. package/clis/twitter/lists-parser.js +0 -77
  177. package/clis/twitter/lists.d.ts +0 -5
  178. package/dist/src/cascade.d.ts +0 -46
  179. package/dist/src/cascade.js +0 -135
  180. package/dist/src/explore.d.ts +0 -99
  181. package/dist/src/explore.js +0 -402
  182. package/dist/src/generate-verified.d.ts +0 -105
  183. package/dist/src/generate-verified.js +0 -696
  184. package/dist/src/generate-verified.test.js +0 -925
  185. package/dist/src/generate.d.ts +0 -46
  186. package/dist/src/generate.js +0 -117
  187. package/dist/src/record.d.ts +0 -96
  188. package/dist/src/record.js +0 -657
  189. package/dist/src/record.test.js +0 -293
  190. package/dist/src/skill-generate.d.ts +0 -30
  191. package/dist/src/skill-generate.js +0 -75
  192. package/dist/src/skill-generate.test.js +0 -173
  193. package/dist/src/synthesize.d.ts +0 -97
  194. package/dist/src/synthesize.js +0 -208
  195. /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
  196. /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
  197. /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
@@ -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
- constructor(workspace = 'default') {
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
- /** @deprecated Use getActivePage() instead */
97
- getActiveTabId() {
98
- return undefined;
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 selectTab(index) {
135
- const result = await sendCommandFull('tabs', { op: 'select', index, ...this._wsOpt() });
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
- await sendCommand('network-capture-start', {
156
- pattern,
157
- ...this._cmdOpts(),
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
- const result = await sendCommand('network-capture-read', {
162
- ...this._cmdOpts(),
163
- });
164
- return Array.isArray(result) ? result : [];
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;