@jackwener/opencli 1.7.14 → 1.7.16

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 (153) hide show
  1. package/README.md +9 -6
  2. package/README.zh-CN.md +9 -6
  3. package/cli-manifest.json +374 -74
  4. package/clis/bilibili/subtitle.js +1 -1
  5. package/clis/chatgpt/ask.js +2 -1
  6. package/clis/chatgpt/detail.js +6 -1
  7. package/clis/chatgpt/read.js +2 -1
  8. package/clis/chatgpt/send.js +2 -1
  9. package/clis/chatgpt/utils.js +54 -12
  10. package/clis/chatgpt/utils.test.js +36 -1
  11. package/clis/claude/ask.js +22 -7
  12. package/clis/claude/detail.js +9 -2
  13. package/clis/claude/new.js +8 -2
  14. package/clis/claude/read.js +2 -1
  15. package/clis/claude/send.js +8 -3
  16. package/clis/claude/utils.js +27 -4
  17. package/clis/deepseek/ask.js +21 -8
  18. package/clis/deepseek/detail.js +9 -1
  19. package/clis/deepseek/new.js +13 -2
  20. package/clis/deepseek/read.js +2 -1
  21. package/clis/deepseek/utils.js +8 -1
  22. package/clis/dianping/cityResolver.js +185 -0
  23. package/clis/dianping/dianping.test.js +154 -0
  24. package/clis/dianping/search.js +6 -3
  25. package/clis/douyin/_shared/browser-fetch.js +14 -2
  26. package/clis/douyin/_shared/browser-fetch.test.js +13 -0
  27. package/clis/douyin/stats.js +1 -1
  28. package/clis/douyin/update.js +1 -1
  29. package/clis/jike/search.js +1 -1
  30. package/clis/linkedin/search.js +8 -11
  31. package/clis/maimai/search-talents.js +10 -6
  32. package/clis/openreview/author.js +58 -0
  33. package/clis/openreview/openreview.test.js +83 -1
  34. package/clis/openreview/utils.js +14 -0
  35. package/clis/reddit/comment.js +1 -0
  36. package/clis/reddit/frontpage.js +1 -0
  37. package/clis/reddit/popular.js +1 -0
  38. package/clis/reddit/read.js +2 -0
  39. package/clis/reddit/read.test.js +4 -0
  40. package/clis/reddit/save.js +1 -0
  41. package/clis/reddit/saved.js +1 -0
  42. package/clis/reddit/search.js +2 -1
  43. package/clis/reddit/subreddit.js +2 -1
  44. package/clis/reddit/subscribe.js +1 -0
  45. package/clis/reddit/upvote.js +1 -0
  46. package/clis/reddit/upvoted.js +1 -0
  47. package/clis/reddit/user-comments.js +2 -1
  48. package/clis/reddit/user-posts.js +2 -1
  49. package/clis/reddit/user.js +2 -1
  50. package/clis/twitter/article.js +9 -5
  51. package/clis/twitter/bookmark-folder.js +187 -0
  52. package/clis/twitter/bookmark-folder.test.js +337 -0
  53. package/clis/twitter/bookmark-folders.js +115 -0
  54. package/clis/twitter/bookmark-folders.test.js +152 -0
  55. package/clis/twitter/bookmark.js +15 -6
  56. package/clis/twitter/bookmark.test.js +74 -0
  57. package/clis/twitter/bookmarks.js +10 -10
  58. package/clis/twitter/delete.js +11 -35
  59. package/clis/twitter/delete.test.js +21 -9
  60. package/clis/twitter/download.js +6 -5
  61. package/clis/twitter/followers.js +10 -3
  62. package/clis/twitter/following.js +14 -11
  63. package/clis/twitter/following.test.js +2 -1
  64. package/clis/twitter/hide-reply.js +24 -5
  65. package/clis/twitter/hide-reply.test.js +76 -0
  66. package/clis/twitter/like.js +21 -11
  67. package/clis/twitter/like.test.js +73 -0
  68. package/clis/twitter/likes.js +11 -11
  69. package/clis/twitter/list-add.js +8 -7
  70. package/clis/twitter/list-add.test.js +23 -1
  71. package/clis/twitter/list-remove.js +8 -7
  72. package/clis/twitter/list-remove.test.js +23 -1
  73. package/clis/twitter/list-tweets.js +9 -9
  74. package/clis/twitter/lists.js +6 -8
  75. package/clis/twitter/notifications.js +3 -2
  76. package/clis/twitter/profile.js +11 -7
  77. package/clis/twitter/quote.js +60 -32
  78. package/clis/twitter/quote.test.js +96 -8
  79. package/clis/twitter/reply.js +24 -178
  80. package/clis/twitter/reply.test.js +29 -11
  81. package/clis/twitter/retweet.js +9 -14
  82. package/clis/twitter/retweet.test.js +5 -1
  83. package/clis/twitter/search.js +176 -23
  84. package/clis/twitter/search.test.js +266 -1
  85. package/clis/twitter/shared.js +43 -0
  86. package/clis/twitter/shared.test.js +107 -1
  87. package/clis/twitter/thread.js +11 -11
  88. package/clis/twitter/timeline.js +13 -13
  89. package/clis/twitter/trending.js +4 -4
  90. package/clis/twitter/tweets.js +8 -9
  91. package/clis/twitter/unbookmark.js +13 -6
  92. package/clis/twitter/unbookmark.test.js +73 -0
  93. package/clis/twitter/unlike.js +6 -13
  94. package/clis/twitter/unlike.test.js +5 -2
  95. package/clis/twitter/unretweet.js +9 -14
  96. package/clis/twitter/unretweet.test.js +5 -1
  97. package/clis/twitter/utils.js +286 -0
  98. package/clis/twitter/utils.test.js +169 -0
  99. package/clis/youtube/like.js +6 -2
  100. package/clis/youtube/subscribe.js +6 -2
  101. package/clis/youtube/unlike.js +6 -2
  102. package/clis/youtube/unsubscribe.js +6 -2
  103. package/clis/youtube/utils.js +19 -13
  104. package/clis/youtube/utils.test.js +17 -1
  105. package/dist/src/browser/ax-snapshot.d.ts +37 -0
  106. package/dist/src/browser/ax-snapshot.js +217 -0
  107. package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
  108. package/dist/src/browser/ax-snapshot.test.js +91 -0
  109. package/dist/src/browser/base-page.d.ts +51 -0
  110. package/dist/src/browser/base-page.js +545 -2
  111. package/dist/src/browser/base-page.test.js +520 -4
  112. package/dist/src/browser/bridge.d.ts +1 -0
  113. package/dist/src/browser/bridge.js +1 -1
  114. package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
  115. package/dist/src/browser/cdp-click-fixture.test.js +87 -0
  116. package/dist/src/browser/cdp.d.ts +1 -0
  117. package/dist/src/browser/cdp.js +5 -0
  118. package/dist/src/browser/cdp.test.js +1 -0
  119. package/dist/src/browser/daemon-client.d.ts +5 -3
  120. package/dist/src/browser/daemon-client.js +6 -3
  121. package/dist/src/browser/daemon-client.test.js +10 -0
  122. package/dist/src/browser/find.d.ts +9 -1
  123. package/dist/src/browser/find.js +219 -0
  124. package/dist/src/browser/find.test.js +61 -1
  125. package/dist/src/browser/page.d.ts +4 -2
  126. package/dist/src/browser/page.js +18 -1
  127. package/dist/src/browser/page.test.js +28 -0
  128. package/dist/src/browser/target-errors.d.ts +3 -1
  129. package/dist/src/browser/target-errors.js +2 -0
  130. package/dist/src/browser/target-resolver.d.ts +14 -0
  131. package/dist/src/browser/target-resolver.js +28 -0
  132. package/dist/src/browser/visual-refs.d.ts +11 -0
  133. package/dist/src/browser/visual-refs.js +108 -0
  134. package/dist/src/build-manifest.d.ts +23 -0
  135. package/dist/src/build-manifest.js +34 -0
  136. package/dist/src/build-manifest.test.js +108 -1
  137. package/dist/src/cli.js +630 -60
  138. package/dist/src/cli.test.js +731 -1
  139. package/dist/src/commanderAdapter.js +7 -0
  140. package/dist/src/doctor.js +2 -2
  141. package/dist/src/doctor.test.js +4 -4
  142. package/dist/src/execution.d.ts +2 -0
  143. package/dist/src/execution.js +31 -6
  144. package/dist/src/execution.test.js +43 -16
  145. package/dist/src/external-clis.yaml +24 -0
  146. package/dist/src/help.d.ts +33 -0
  147. package/dist/src/help.js +174 -0
  148. package/dist/src/main.js +4 -14
  149. package/dist/src/runtime.d.ts +3 -0
  150. package/dist/src/runtime.js +1 -0
  151. package/dist/src/types.d.ts +83 -1
  152. package/package.json +1 -1
  153. package/scripts/typed-error-lint-baseline.json +18 -18
@@ -0,0 +1,87 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { JSDOM } from 'jsdom';
3
+ function installDom(html) {
4
+ const dom = new JSDOM(html, { pretendToBeVisual: true });
5
+ globalThis.window = dom.window;
6
+ globalThis.document = dom.window.document;
7
+ globalThis.Event = dom.window.Event;
8
+ globalThis.MouseEvent = dom.window.MouseEvent;
9
+ return dom.window.document;
10
+ }
11
+ function dispatchNativeMouseSequence(target) {
12
+ for (const type of ['mousemove', 'pointerdown', 'mousedown', 'mouseup', 'pointerup', 'click']) {
13
+ target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
14
+ }
15
+ }
16
+ describe('CDP-primary click dropdown fixtures', () => {
17
+ afterEach(() => {
18
+ Reflect.deleteProperty(globalThis, 'window');
19
+ Reflect.deleteProperty(globalThis, 'document');
20
+ });
21
+ it('captures the Radix/shadcn class of dropdowns that open on pointerdown and select on pointerup', () => {
22
+ const document = installDom(`
23
+ <button id="trigger" role="combobox" aria-expanded="false">Category</button>
24
+ <div id="portal-root"></div>
25
+ <output id="value"></output>
26
+ `);
27
+ const trigger = document.querySelector('#trigger');
28
+ const portal = document.querySelector('#portal-root');
29
+ const value = document.querySelector('#value');
30
+ trigger.addEventListener('pointerdown', () => {
31
+ trigger.setAttribute('aria-expanded', 'true');
32
+ portal.innerHTML = `
33
+ <div role="listbox">
34
+ <div id="meals" role="option">Meals</div>
35
+ </div>
36
+ `;
37
+ portal.querySelector('#meals').addEventListener('pointerup', () => {
38
+ value.textContent = 'Meals';
39
+ trigger.textContent = 'Meals';
40
+ trigger.setAttribute('aria-expanded', 'false');
41
+ });
42
+ });
43
+ // Baseline: DOM el.click() dispatches click only. This is the old OpenCLI
44
+ // failure mode: the command reports success but the dropdown never opens.
45
+ trigger.click();
46
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
47
+ expect(portal.querySelector('[role="option"]')).toBeNull();
48
+ expect(value.textContent).toBe('');
49
+ // CDP-style mouse input opens the portal and can commit the option.
50
+ dispatchNativeMouseSequence(trigger);
51
+ const option = portal.querySelector('#meals');
52
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
53
+ dispatchNativeMouseSequence(option);
54
+ expect(value.textContent).toBe('Meals');
55
+ expect(trigger.textContent).toBe('Meals');
56
+ });
57
+ it('captures the MUI autocomplete class that opens on mousedown and commits on mousedown in a popper', () => {
58
+ const document = installDom(`
59
+ <label for="category">Category</label>
60
+ <input id="category" role="combobox" value="" />
61
+ <div id="mui-popper"></div>
62
+ <output id="value"></output>
63
+ `);
64
+ const input = document.querySelector('#category');
65
+ const popper = document.querySelector('#mui-popper');
66
+ const value = document.querySelector('#value');
67
+ input.addEventListener('mousedown', () => {
68
+ popper.innerHTML = `
69
+ <ul role="listbox">
70
+ <li id="travel" role="option">Travel</li>
71
+ </ul>
72
+ `;
73
+ popper.querySelector('#travel').addEventListener('mousedown', () => {
74
+ input.value = 'Travel';
75
+ value.textContent = 'Travel';
76
+ });
77
+ });
78
+ input.click();
79
+ expect(popper.querySelector('[role="option"]')).toBeNull();
80
+ expect(input.value).toBe('');
81
+ dispatchNativeMouseSequence(input);
82
+ const option = popper.querySelector('#travel');
83
+ dispatchNativeMouseSequence(option);
84
+ expect(input.value).toBe('Travel');
85
+ expect(value.textContent).toBe('Travel');
86
+ });
87
+ });
@@ -27,6 +27,7 @@ export declare class CDPBridge implements IBrowserFactory {
27
27
  cdpEndpoint?: string;
28
28
  contextId?: string;
29
29
  idleTimeout?: number;
30
+ windowMode?: 'foreground' | 'background';
30
31
  }): Promise<IPage>;
31
32
  close(): Promise<void>;
32
33
  send(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<unknown>;
@@ -369,6 +369,11 @@ class CDPPage extends BasePage {
369
369
  });
370
370
  }
371
371
  async nativeClick(x, y) {
372
+ await this.cdp('Input.dispatchMouseEvent', {
373
+ type: 'mouseMoved',
374
+ x,
375
+ y,
376
+ });
372
377
  await this.cdp('Input.dispatchMouseEvent', {
373
378
  type: 'mousePressed',
374
379
  x,
@@ -69,6 +69,7 @@ describe('CDPBridge cookies', () => {
69
69
  ['Input.insertText', { text: 'hello' }],
70
70
  ['Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', modifiers: 2 }],
71
71
  ['Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', modifiers: 2 }],
72
+ ['Input.dispatchMouseEvent', { type: 'mouseMoved', x: 10, y: 20 }],
72
73
  ['Input.dispatchMouseEvent', { type: 'mousePressed', x: 10, y: 20, button: 'left', clickCount: 1 }],
73
74
  ['Input.dispatchMouseEvent', { type: 'mouseReleased', x: 10, y: 20, button: 'left', clickCount: 1 }],
74
75
  ['Page.handleJavaScriptDialog', { accept: true, promptText: 'ok' }],
@@ -6,7 +6,7 @@
6
6
  import type { BrowserSessionInfo } from '../types.js';
7
7
  export interface DaemonCommand {
8
8
  id: string;
9
- action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'cdp' | 'frames';
9
+ action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames';
10
10
  /** Target page identity (targetId). Cross-layer contract with the extension. */
11
11
  page?: string;
12
12
  code?: string;
@@ -32,10 +32,12 @@ export interface DaemonCommand {
32
32
  text?: string;
33
33
  /** URL substring filter pattern for network capture */
34
34
  pattern?: string;
35
+ /** Download wait timeout in milliseconds */
36
+ timeoutMs?: number;
35
37
  cdpMethod?: string;
36
38
  cdpParams?: Record<string, unknown>;
37
- /** When true, the owned automation container is created in the foreground */
38
- windowFocused?: boolean;
39
+ /** Window foreground/background policy for owned Browser Bridge containers. */
40
+ windowMode?: 'foreground' | 'background';
39
41
  /** Custom idle timeout in seconds for this workspace session. Overrides the default. */
40
42
  idleTimeout?: number;
41
43
  /** Explicitly allow navigation inside a borrowed bound tab. */
@@ -89,10 +89,13 @@ async function sendCommandRaw(action, params) {
89
89
  const maxRetries = 4;
90
90
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
91
91
  const id = generateId();
92
- const wf = process.env.OPENCLI_WINDOW_FOCUSED;
93
- const windowFocused = (wf === '1' || wf === 'true') ? true : undefined;
92
+ const rawWindowMode = process.env.OPENCLI_WINDOW;
93
+ const envWindowMode = rawWindowMode === 'foreground' || rawWindowMode === 'background'
94
+ ? rawWindowMode
95
+ : undefined;
94
96
  const contextId = params.contextId ?? resolveProfileContextId();
95
- const command = { id, action, ...params, ...(contextId && { contextId }), ...(windowFocused && { windowFocused }) };
97
+ const windowMode = params.windowMode ?? envWindowMode;
98
+ const command = { id, action, ...params, ...(contextId && { contextId }), ...(windowMode && { windowMode }) };
96
99
  try {
97
100
  const res = await requestDaemon('/command', {
98
101
  method: 'POST',
@@ -144,6 +144,16 @@ describe('daemon-client', () => {
144
144
  const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
145
145
  expect(body.contextId).toBe('work');
146
146
  });
147
+ it('sendCommand uses explicit windowMode before OPENCLI_WINDOW env fallback', async () => {
148
+ vi.stubEnv('OPENCLI_WINDOW', 'foreground');
149
+ vi.mocked(fetch).mockResolvedValue({
150
+ status: 200,
151
+ json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }),
152
+ });
153
+ await sendCommand('exec', { code: '1 + 1', windowMode: 'background' });
154
+ const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
155
+ expect(body.windowMode).toBe('background');
156
+ });
147
157
  it('sendCommand retries with a new id when daemon reports a duplicate pending id', async () => {
148
158
  vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_123);
149
159
  const fetchMock = vi.mocked(fetch);
@@ -57,7 +57,7 @@ export interface FindResult {
57
57
  }
58
58
  export interface FindError {
59
59
  error: {
60
- code: 'invalid_selector' | 'selector_not_found';
60
+ code: 'invalid_selector' | 'selector_not_found' | 'semantic_not_found';
61
61
  message: string;
62
62
  hint?: string;
63
63
  };
@@ -68,9 +68,17 @@ export interface FindOptions {
68
68
  /** Max chars of trimmed text per entry. Default 120. */
69
69
  textMax?: number;
70
70
  }
71
+ export interface SemanticFindOptions extends FindOptions {
72
+ role?: string;
73
+ name?: string;
74
+ label?: string;
75
+ text?: string;
76
+ testid?: string;
77
+ }
71
78
  /**
72
79
  * Build the browser-side JS that performs the CSS query and emits the
73
80
  * FindResult (or FindError) envelope. Evaluated inside `page.evaluate`.
74
81
  */
75
82
  export declare function buildFindJs(selector: string, opts?: FindOptions): string;
83
+ export declare function buildSemanticFindJs(opts: SemanticFindOptions): string;
76
84
  export declare function isFindError(result: unknown): result is FindError;
@@ -174,6 +174,225 @@ export function buildFindJs(selector, opts = {}) {
174
174
  })()
175
175
  `;
176
176
  }
177
+ export function buildSemanticFindJs(opts) {
178
+ const criteria = JSON.stringify({
179
+ role: opts.role ?? '',
180
+ name: opts.name ?? '',
181
+ label: opts.label ?? '',
182
+ text: opts.text ?? '',
183
+ testid: opts.testid ?? '',
184
+ });
185
+ const limit = opts.limit ?? 50;
186
+ const textMax = opts.textMax ?? 120;
187
+ const whitelist = JSON.stringify(FIND_ATTR_WHITELIST);
188
+ return `
189
+ (() => {
190
+ const CRITERIA = ${criteria};
191
+ const LIMIT = ${limit};
192
+ const TEXT_MAX = ${textMax};
193
+ const ATTR_WHITELIST = ${whitelist};
194
+
195
+ ${COMPOUND_INFO_JS}
196
+
197
+ function normalize(value) {
198
+ return String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
199
+ }
200
+
201
+ function includesNeedle(value, needle) {
202
+ const n = normalize(needle);
203
+ if (!n) return true;
204
+ return normalize(value).includes(n);
205
+ }
206
+
207
+ function nativeRole(el) {
208
+ const explicit = el.getAttribute('role');
209
+ if (explicit) return explicit;
210
+ const tag = el.tagName.toLowerCase();
211
+ const type = (el.getAttribute('type') || '').toLowerCase();
212
+ if (tag === 'button') return 'button';
213
+ if (tag === 'a' && el.getAttribute('href')) return 'link';
214
+ if (tag === 'textarea') return 'textbox';
215
+ if (tag === 'select') return 'combobox';
216
+ if (tag === 'option') return 'option';
217
+ if (tag === 'input') {
218
+ if (type === 'button' || type === 'submit' || type === 'reset') return 'button';
219
+ if (type === 'checkbox') return 'checkbox';
220
+ if (type === 'radio') return 'radio';
221
+ if (type === 'range') return 'slider';
222
+ if (type === 'search') return 'searchbox';
223
+ return 'textbox';
224
+ }
225
+ return '';
226
+ }
227
+
228
+ function labelText(el) {
229
+ const parts = [];
230
+ function cssEscape(value) {
231
+ try {
232
+ if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
233
+ } catch (_) {}
234
+ return String(value).replace(/["\\\\]/g, '\\\\$&');
235
+ }
236
+ if (el.id) {
237
+ try {
238
+ const label = document.querySelector('label[for="' + cssEscape(el.id) + '"]');
239
+ if (label) parts.push(label.textContent || '');
240
+ } catch (_) {}
241
+ }
242
+ const parentLabel = el.closest?.('label');
243
+ if (parentLabel) parts.push(parentLabel.textContent || '');
244
+ return parts.join(' ');
245
+ }
246
+
247
+ function byIdText(ids) {
248
+ if (!ids) return '';
249
+ const parts = [];
250
+ for (const id of String(ids).split(/\\s+/)) {
251
+ if (!id) continue;
252
+ try {
253
+ const el = document.getElementById(id);
254
+ if (el) parts.push(el.textContent || '');
255
+ } catch (_) {}
256
+ }
257
+ return parts.join(' ');
258
+ }
259
+
260
+ function accessibleName(el) {
261
+ return [
262
+ el.getAttribute('aria-label') || '',
263
+ byIdText(el.getAttribute('aria-labelledby')),
264
+ labelText(el),
265
+ el.getAttribute('alt') || '',
266
+ el.getAttribute('title') || '',
267
+ el.getAttribute('placeholder') || '',
268
+ el.getAttribute('value') || '',
269
+ el.textContent || '',
270
+ ].filter(Boolean).join(' ');
271
+ }
272
+
273
+ function pickAttrs(el) {
274
+ const out = {};
275
+ for (const key of ATTR_WHITELIST) {
276
+ const v = el.getAttribute(key);
277
+ if (v != null && v !== '') out[key] = v;
278
+ }
279
+ return out;
280
+ }
281
+
282
+ function isVisible(el) {
283
+ const rect = el.getBoundingClientRect();
284
+ if (rect.width === 0 && rect.height === 0) return false;
285
+ try {
286
+ const style = getComputedStyle(el);
287
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
288
+ if (parseFloat(style.opacity || '1') === 0) return false;
289
+ } catch (_) {}
290
+ return true;
291
+ }
292
+
293
+ function fingerprintOf(el) {
294
+ return {
295
+ tag: el.tagName.toLowerCase(),
296
+ role: el.getAttribute('role') || '',
297
+ text: (el.textContent || '').trim().slice(0, 30),
298
+ ariaLabel: el.getAttribute('aria-label') || '',
299
+ id: el.id || '',
300
+ testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || '',
301
+ };
302
+ }
303
+
304
+ function matches(el) {
305
+ const role = nativeRole(el);
306
+ const name = accessibleName(el);
307
+ const label = labelText(el);
308
+ const text = el.textContent || '';
309
+ const testid = el.getAttribute('data-testid') || el.getAttribute('data-test') || el.getAttribute('test-id') || '';
310
+ if (CRITERIA.role && normalize(role) !== normalize(CRITERIA.role)) return false;
311
+ if (CRITERIA.name && !includesNeedle(name, CRITERIA.name)) return false;
312
+ if (CRITERIA.label && !includesNeedle(label, CRITERIA.label)) return false;
313
+ if (CRITERIA.text && !includesNeedle(text, CRITERIA.text)) return false;
314
+ if (CRITERIA.testid && !includesNeedle(testid, CRITERIA.testid)) return false;
315
+ return true;
316
+ }
317
+
318
+ const candidates = Array.from(document.querySelectorAll([
319
+ 'a[href]',
320
+ 'button',
321
+ 'input',
322
+ 'textarea',
323
+ 'select',
324
+ 'option',
325
+ '[role]',
326
+ '[aria-label]',
327
+ '[aria-labelledby]',
328
+ '[data-testid]',
329
+ '[data-test]',
330
+ '[test-id]',
331
+ 'label',
332
+ '[contenteditable="true"]',
333
+ ].join(',')));
334
+ const matchesList = candidates.filter(matches);
335
+
336
+ if (matchesList.length === 0) {
337
+ return {
338
+ error: {
339
+ code: 'semantic_not_found',
340
+ message: 'Semantic locator matched 0 elements',
341
+ hint: 'Try browser state, --source ax, or relax --role/--name/--label/--text/--testid.',
342
+ },
343
+ };
344
+ }
345
+
346
+ const identity = (window.__opencli_ref_identity = window.__opencli_ref_identity || {});
347
+ let maxRef = 0;
348
+ for (const k in identity) {
349
+ const n = parseInt(k, 10);
350
+ if (!isNaN(n) && n > maxRef) maxRef = n;
351
+ }
352
+ try {
353
+ const tagged = document.querySelectorAll('[data-opencli-ref]');
354
+ for (let t = 0; t < tagged.length; t++) {
355
+ const v = tagged[t].getAttribute('data-opencli-ref');
356
+ const n = v != null && /^\\d+$/.test(v) ? parseInt(v, 10) : NaN;
357
+ if (!isNaN(n) && n > maxRef) maxRef = n;
358
+ }
359
+ } catch (_) {}
360
+
361
+ const take = Math.min(matchesList.length, LIMIT);
362
+ const entries = [];
363
+ for (let i = 0; i < take; i++) {
364
+ const el = matchesList[i];
365
+ const refAttr = el.getAttribute('data-opencli-ref');
366
+ let refNum = refAttr != null && /^\\d+$/.test(refAttr) ? parseInt(refAttr, 10) : null;
367
+ if (refNum === null) {
368
+ refNum = ++maxRef;
369
+ try { el.setAttribute('data-opencli-ref', '' + refNum); } catch (_) {}
370
+ identity['' + refNum] = fingerprintOf(el);
371
+ } else if (!identity['' + refNum]) {
372
+ identity['' + refNum] = fingerprintOf(el);
373
+ }
374
+ const text = (el.textContent || '').trim();
375
+ const entry = {
376
+ nth: i,
377
+ ref: refNum,
378
+ tag: el.tagName.toLowerCase(),
379
+ role: nativeRole(el),
380
+ text: text.length > TEXT_MAX ? text.slice(0, TEXT_MAX) : text,
381
+ attrs: pickAttrs(el),
382
+ visible: isVisible(el),
383
+ };
384
+ const compound = compoundInfoOf(el);
385
+ if (compound) entry.compound = compound;
386
+ entries.push(entry);
387
+ }
388
+
389
+ return {
390
+ matches_n: matchesList.length,
391
+ entries,
392
+ };
393
+ })()
394
+ `;
395
+ }
177
396
  export function isFindError(result) {
178
397
  return !!result && typeof result === 'object' && 'error' in result;
179
398
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { buildFindJs, FIND_ATTR_WHITELIST, isFindError } from './find.js';
2
+ import { JSDOM } from 'jsdom';
3
+ import { buildFindJs, buildSemanticFindJs, FIND_ATTR_WHITELIST, isFindError } from './find.js';
3
4
  /**
4
5
  * These tests validate the shape and options of the generated JS string
5
6
  * (no DOM available in the default vitest unit env). Runtime behavior of
@@ -118,3 +119,62 @@ describe('isFindError', () => {
118
119
  expect(isFindError('string')).toBe(false);
119
120
  });
120
121
  });
122
+ describe('buildSemanticFindJs', () => {
123
+ function runSemanticFind(html, opts) {
124
+ const dom = new JSDOM(html, { runScripts: 'outside-only' });
125
+ return {
126
+ dom,
127
+ result: dom.window.eval(buildSemanticFindJs(opts)),
128
+ };
129
+ }
130
+ it('produces syntactically valid JS and embeds semantic criteria safely', () => {
131
+ const js = buildSemanticFindJs({ role: 'button', name: 'Save "now"', testid: 'submit' });
132
+ expect(() => new Function(`return (${js});`)).not.toThrow();
133
+ expect(js).toContain(JSON.stringify({
134
+ role: 'button',
135
+ name: 'Save "now"',
136
+ label: '',
137
+ text: '',
138
+ testid: 'submit',
139
+ }));
140
+ });
141
+ it('matches native roles, accessible name, labels, text, and test ids', () => {
142
+ const js = buildSemanticFindJs({ role: 'button', name: 'Save', label: 'Category', text: 'Travel', testid: 'category' });
143
+ expect(js).toContain('function nativeRole(el)');
144
+ expect(js).toContain('function accessibleName(el)');
145
+ expect(js).toContain('function labelText(el)');
146
+ expect(js).toContain('CRITERIA.role');
147
+ expect(js).toContain('CRITERIA.name');
148
+ expect(js).toContain('CRITERIA.label');
149
+ expect(js).toContain('CRITERIA.text');
150
+ expect(js).toContain('CRITERIA.testid');
151
+ });
152
+ it('allocates refs exactly like CSS find so downstream actions can click them', () => {
153
+ const js = buildSemanticFindJs({ role: 'button', name: 'Save' });
154
+ expect(js).toContain("el.setAttribute('data-opencli-ref'");
155
+ expect(js).toContain('__opencli_ref_identity');
156
+ expect(js).toContain("identity['' + refNum] = fingerprintOf(el)");
157
+ expect(js).toContain("document.querySelectorAll('[data-opencli-ref]')");
158
+ });
159
+ it('executes semantic role/name/testid matching and allocates a clickable ref', () => {
160
+ const { dom, result } = runSemanticFind('<button aria-label="Save expense" data-testid="save-button">Ignored copy</button>', { role: 'button', name: 'Save', testid: 'save' });
161
+ expect(result).toMatchObject({
162
+ matches_n: 1,
163
+ entries: [
164
+ { nth: 0, ref: 1, tag: 'button', role: 'button', attrs: { 'aria-label': 'Save expense', 'data-testid': 'save-button' } },
165
+ ],
166
+ });
167
+ const button = dom.window.document.querySelector('button');
168
+ expect(button.getAttribute('data-opencli-ref')).toBe('1');
169
+ expect(dom.window.__opencli_ref_identity['1']).toMatchObject({ tag: 'button', ariaLabel: 'Save expense' });
170
+ });
171
+ it('matches associated labels and placeholders for form controls', () => {
172
+ const { result } = runSemanticFind('<label for="category">Category</label><input id="category" placeholder="Expense category" value="Travel" />', { role: 'textbox', label: 'Category', name: 'Expense category' });
173
+ expect(result).toMatchObject({
174
+ matches_n: 1,
175
+ entries: [
176
+ { nth: 0, ref: 1, tag: 'input', role: 'textbox' },
177
+ ],
178
+ });
179
+ });
180
+ });
@@ -8,7 +8,7 @@
8
8
  * by the navigate action and pass it to all subsequent commands. This ensures
9
9
  * page-scoped operations target the correct page without guessing.
10
10
  */
11
- import type { BrowserCookie, ScreenshotOptions } from '../types.js';
11
+ import type { BrowserCookie, BrowserDownloadWaitResult, ScreenshotOptions } from '../types.js';
12
12
  import { BasePage } from './base-page.js';
13
13
  /**
14
14
  * Page — implements IPage by talking to the daemon via HTTP.
@@ -16,8 +16,9 @@ import { BasePage } from './base-page.js';
16
16
  export declare class Page extends BasePage {
17
17
  private readonly workspace;
18
18
  readonly contextId?: string | undefined;
19
+ private readonly windowMode?;
19
20
  private readonly _idleTimeout;
20
- constructor(workspace?: string, idleTimeout?: number, contextId?: string | undefined);
21
+ constructor(workspace?: string, idleTimeout?: number, contextId?: string | undefined, windowMode?: "foreground" | "background" | undefined);
21
22
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
22
23
  private _page;
23
24
  private _networkCaptureUnsupported;
@@ -53,6 +54,7 @@ export declare class Page extends BasePage {
53
54
  screenshot(options?: ScreenshotOptions): Promise<string>;
54
55
  startNetworkCapture(pattern?: string): Promise<boolean>;
55
56
  readNetworkCapture(): Promise<unknown[]>;
57
+ waitForDownload(pattern?: string, timeoutMs?: number): Promise<BrowserDownloadWaitResult>;
56
58
  /**
57
59
  * Set local file paths on a file input element via CDP DOM.setFileInputFiles.
58
60
  * Chrome reads the files directly from the local filesystem, avoiding the
@@ -28,11 +28,13 @@ function isUnsupportedNetworkCaptureError(err) {
28
28
  export class Page extends BasePage {
29
29
  workspace;
30
30
  contextId;
31
+ windowMode;
31
32
  _idleTimeout;
32
- constructor(workspace = 'default', idleTimeout, contextId) {
33
+ constructor(workspace = 'default', idleTimeout, contextId, windowMode) {
33
34
  super();
34
35
  this.workspace = workspace;
35
36
  this.contextId = contextId;
37
+ this.windowMode = windowMode;
36
38
  this._idleTimeout = idleTimeout;
37
39
  }
38
40
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
@@ -45,6 +47,7 @@ export class Page extends BasePage {
45
47
  workspace: this.workspace,
46
48
  ...(this.contextId && { contextId: this.contextId }),
47
49
  ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
50
+ ...(this.windowMode && { windowMode: this.windowMode }),
48
51
  };
49
52
  }
50
53
  /** Helper: spread workspace + page identity into command params */
@@ -54,6 +57,7 @@ export class Page extends BasePage {
54
57
  ...(this.contextId && { contextId: this.contextId }),
55
58
  ...(this._page !== undefined && { page: this._page }),
56
59
  ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
60
+ ...(this.windowMode && { windowMode: this.windowMode }),
57
61
  };
58
62
  }
59
63
  async goto(url, options) {
@@ -246,6 +250,14 @@ export class Page extends BasePage {
246
250
  return [];
247
251
  }
248
252
  }
253
+ async waitForDownload(pattern = '', timeoutMs = 30_000) {
254
+ const result = await sendCommand('wait-download', {
255
+ pattern,
256
+ timeoutMs,
257
+ ...this._cmdOpts(),
258
+ });
259
+ return result;
260
+ }
249
261
  /**
250
262
  * Set local file paths on a file input element via CDP DOM.setFileInputFiles.
251
263
  * Chrome reads the files directly from the local filesystem, avoiding the
@@ -360,6 +372,11 @@ export class Page extends BasePage {
360
372
  `);
361
373
  }
362
374
  async nativeClick(x, y) {
375
+ await this.cdp('Input.dispatchMouseEvent', {
376
+ type: 'mouseMoved',
377
+ x,
378
+ y,
379
+ });
363
380
  await this.cdp('Input.dispatchMouseEvent', {
364
381
  type: 'mousePressed',
365
382
  x, y,
@@ -102,6 +102,34 @@ describe('Page network capture compatibility', () => {
102
102
  expect(warnMock).toHaveBeenCalledTimes(1);
103
103
  });
104
104
  });
105
+ describe('Page download waits', () => {
106
+ beforeEach(() => {
107
+ sendCommandMock.mockReset();
108
+ sendCommandFullMock.mockReset();
109
+ warnMock.mockReset();
110
+ });
111
+ it('sends wait-download through the daemon with workspace and timeout', async () => {
112
+ sendCommandMock.mockResolvedValueOnce({
113
+ downloaded: true,
114
+ filename: '/tmp/receipt.pdf',
115
+ state: 'complete',
116
+ elapsedMs: 5,
117
+ });
118
+ const page = new Page('site:mercury');
119
+ const result = await page.waitForDownload('receipt', 1234);
120
+ expect(result).toEqual({
121
+ downloaded: true,
122
+ filename: '/tmp/receipt.pdf',
123
+ state: 'complete',
124
+ elapsedMs: 5,
125
+ });
126
+ expect(sendCommandMock).toHaveBeenCalledWith('wait-download', expect.objectContaining({
127
+ workspace: 'site:mercury',
128
+ pattern: 'receipt',
129
+ timeoutMs: 1234,
130
+ }));
131
+ });
132
+ });
105
133
  describe('Page CDP helpers', () => {
106
134
  beforeEach(() => {
107
135
  sendCommandMock.mockReset();
@@ -16,8 +16,10 @@
16
16
  * - selector_ambiguous: >1 matches for a write op without --nth
17
17
  * - selector_nth_out_of_range: --nth beyond matches_n
18
18
  * - not_editable: target exists but cannot accept text input
19
+ * - not_checkable: target exists but cannot be checked/unchecked
20
+ * - not_file_input: target exists but is not a usable file input
19
21
  */
20
- export type TargetErrorCode = 'not_found' | 'stale_ref' | 'invalid_selector' | 'selector_not_found' | 'selector_ambiguous' | 'selector_nth_out_of_range' | 'not_editable';
22
+ export type TargetErrorCode = 'not_found' | 'stale_ref' | 'invalid_selector' | 'selector_not_found' | 'selector_ambiguous' | 'selector_nth_out_of_range' | 'not_editable' | 'not_checkable' | 'not_file_input';
21
23
  export interface TargetErrorInfo {
22
24
  code: TargetErrorCode;
23
25
  message: string;
@@ -16,6 +16,8 @@
16
16
  * - selector_ambiguous: >1 matches for a write op without --nth
17
17
  * - selector_nth_out_of_range: --nth beyond matches_n
18
18
  * - not_editable: target exists but cannot accept text input
19
+ * - not_checkable: target exists but cannot be checked/unchecked
20
+ * - not_file_input: target exists but is not a usable file input
19
21
  */
20
22
  export class TargetError extends Error {
21
23
  code;