@jackwener/opencli 1.7.14 → 1.7.15

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 (94) hide show
  1. package/cli-manifest.json +215 -45
  2. package/clis/bilibili/subtitle.js +1 -1
  3. package/clis/dianping/cityResolver.js +185 -0
  4. package/clis/dianping/dianping.test.js +154 -0
  5. package/clis/dianping/search.js +6 -3
  6. package/clis/douyin/_shared/browser-fetch.js +14 -2
  7. package/clis/douyin/_shared/browser-fetch.test.js +13 -0
  8. package/clis/douyin/stats.js +1 -1
  9. package/clis/douyin/update.js +1 -1
  10. package/clis/jike/search.js +1 -1
  11. package/clis/reddit/search.js +1 -1
  12. package/clis/reddit/subreddit.js +1 -1
  13. package/clis/reddit/user-comments.js +1 -1
  14. package/clis/reddit/user-posts.js +1 -1
  15. package/clis/reddit/user.js +1 -1
  16. package/clis/twitter/article.js +2 -1
  17. package/clis/twitter/bookmark-folder.js +189 -0
  18. package/clis/twitter/bookmark-folder.test.js +334 -0
  19. package/clis/twitter/bookmark-folders.js +117 -0
  20. package/clis/twitter/bookmark-folders.test.js +150 -0
  21. package/clis/twitter/bookmark.js +15 -6
  22. package/clis/twitter/bookmark.test.js +74 -0
  23. package/clis/twitter/bookmarks.js +7 -5
  24. package/clis/twitter/delete.js +11 -35
  25. package/clis/twitter/delete.test.js +21 -9
  26. package/clis/twitter/download.js +5 -5
  27. package/clis/twitter/followers.js +9 -3
  28. package/clis/twitter/following.js +11 -5
  29. package/clis/twitter/hide-reply.js +24 -5
  30. package/clis/twitter/hide-reply.test.js +76 -0
  31. package/clis/twitter/like.js +21 -11
  32. package/clis/twitter/like.test.js +73 -0
  33. package/clis/twitter/likes.js +8 -6
  34. package/clis/twitter/list-add.js +4 -4
  35. package/clis/twitter/list-remove.js +4 -4
  36. package/clis/twitter/list-tweets.js +6 -4
  37. package/clis/twitter/lists.js +3 -3
  38. package/clis/twitter/notifications.js +2 -2
  39. package/clis/twitter/profile.js +4 -3
  40. package/clis/twitter/quote.js +60 -32
  41. package/clis/twitter/quote.test.js +96 -8
  42. package/clis/twitter/reply.js +24 -178
  43. package/clis/twitter/reply.test.js +29 -11
  44. package/clis/twitter/retweet.js +9 -14
  45. package/clis/twitter/retweet.test.js +5 -1
  46. package/clis/twitter/search.js +175 -23
  47. package/clis/twitter/search.test.js +266 -1
  48. package/clis/twitter/shared.js +43 -0
  49. package/clis/twitter/shared.test.js +107 -1
  50. package/clis/twitter/thread.js +6 -4
  51. package/clis/twitter/timeline.js +8 -6
  52. package/clis/twitter/tweets.js +5 -3
  53. package/clis/twitter/unbookmark.js +13 -6
  54. package/clis/twitter/unbookmark.test.js +73 -0
  55. package/clis/twitter/unlike.js +6 -13
  56. package/clis/twitter/unlike.test.js +5 -2
  57. package/clis/twitter/unretweet.js +9 -14
  58. package/clis/twitter/unretweet.test.js +5 -1
  59. package/clis/twitter/utils.js +286 -0
  60. package/clis/twitter/utils.test.js +169 -0
  61. package/dist/src/browser/ax-snapshot.d.ts +37 -0
  62. package/dist/src/browser/ax-snapshot.js +217 -0
  63. package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
  64. package/dist/src/browser/ax-snapshot.test.js +91 -0
  65. package/dist/src/browser/base-page.d.ts +51 -0
  66. package/dist/src/browser/base-page.js +545 -2
  67. package/dist/src/browser/base-page.test.js +520 -4
  68. package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
  69. package/dist/src/browser/cdp-click-fixture.test.js +87 -0
  70. package/dist/src/browser/cdp.js +5 -0
  71. package/dist/src/browser/cdp.test.js +1 -0
  72. package/dist/src/browser/daemon-client.d.ts +3 -1
  73. package/dist/src/browser/find.d.ts +9 -1
  74. package/dist/src/browser/find.js +219 -0
  75. package/dist/src/browser/find.test.js +61 -1
  76. package/dist/src/browser/page.d.ts +2 -1
  77. package/dist/src/browser/page.js +13 -0
  78. package/dist/src/browser/page.test.js +28 -0
  79. package/dist/src/browser/target-errors.d.ts +3 -1
  80. package/dist/src/browser/target-errors.js +2 -0
  81. package/dist/src/browser/target-resolver.d.ts +14 -0
  82. package/dist/src/browser/target-resolver.js +28 -0
  83. package/dist/src/browser/visual-refs.d.ts +11 -0
  84. package/dist/src/browser/visual-refs.js +108 -0
  85. package/dist/src/build-manifest.d.ts +23 -0
  86. package/dist/src/build-manifest.js +34 -0
  87. package/dist/src/build-manifest.test.js +108 -1
  88. package/dist/src/cli.js +560 -58
  89. package/dist/src/cli.test.js +598 -0
  90. package/dist/src/help.d.ts +32 -0
  91. package/dist/src/help.js +145 -0
  92. package/dist/src/types.d.ts +82 -0
  93. package/package.json +1 -1
  94. package/scripts/typed-error-lint-baseline.json +18 -18
@@ -21,9 +21,12 @@ class ActionPage extends BasePage {
21
21
  withArgsResults = [];
22
22
  scripts = [];
23
23
  withArgs = [];
24
+ screenshotCalls = [];
24
25
  nativeType;
25
26
  insertText;
26
27
  nativeKeyPress;
28
+ nativeClick;
29
+ setFileInput;
27
30
  cdp;
28
31
  async goto() { }
29
32
  async evaluate(js) {
@@ -36,7 +39,10 @@ class ActionPage extends BasePage {
36
39
  return this.withArgsResults.shift() ?? null;
37
40
  }
38
41
  async getCookies() { return []; }
39
- async screenshot() { return ''; }
42
+ async screenshot(options = {}) {
43
+ this.screenshotCalls.push(options);
44
+ return 'shot';
45
+ }
40
46
  async tabs() { return []; }
41
47
  async selectTab() { }
42
48
  }
@@ -98,6 +104,23 @@ describe('BasePage.fetchJson', () => {
98
104
  });
99
105
  });
100
106
  });
107
+ describe('BasePage annotatedScreenshot', () => {
108
+ it('refreshes DOM refs, captures with a temporary visual overlay, and cleans up', async () => {
109
+ const page = new ActionPage();
110
+ page.results = [
111
+ 'snapshot',
112
+ '["hash"]',
113
+ { annotated: 1, truncated: false },
114
+ true,
115
+ ];
116
+ await expect(page.annotatedScreenshot({ path: '/tmp/opencli.png', annotate: true })).resolves.toBe('shot');
117
+ expect(page.scripts[0]).toContain('const VIEWPORT_EXPAND = 0');
118
+ expect(page.scripts[2]).toContain('__opencli_visual_ref_overlay');
119
+ expect(page.scripts[2]).toContain('[data-opencli-ref]');
120
+ expect(page.scripts[3]).toContain('__opencli_visual_ref_overlay');
121
+ expect(page.screenshotCalls).toEqual([{ path: '/tmp/opencli.png', annotate: false }]);
122
+ });
123
+ });
101
124
  describe('BasePage native input routing', () => {
102
125
  it('types rich-editor text via native Input.insertText when available', async () => {
103
126
  const page = new ActionPage();
@@ -212,19 +235,512 @@ describe('BasePage native input routing', () => {
212
235
  expect(err).toBeInstanceOf(TargetError);
213
236
  expect(err.code).toBe('not_editable');
214
237
  });
215
- it('uses CDP DOM scrollIntoViewIfNeeded before JS click when available', async () => {
238
+ it('uses CDP DOM scrollIntoViewIfNeeded before measuring rect for click', async () => {
216
239
  const page = new ActionPage();
240
+ page.nativeClick = vi.fn().mockResolvedValue(undefined);
217
241
  page.cdp = vi.fn()
218
242
  .mockResolvedValueOnce({})
219
243
  .mockResolvedValueOnce({ root: { nodeId: 1 } })
220
244
  .mockResolvedValueOnce({ nodeId: 9 })
221
245
  .mockResolvedValueOnce({});
222
- page.results = [resolveOk, { status: 'clicked', x: 1, y: 2 }];
223
- page.withArgsResults = [{ ok: true }, undefined];
246
+ page.results = [resolveOk, { x: 50, y: 100, w: 200, h: 32, visible: true }];
247
+ page.withArgsResults = [{ ok: true, multiple: false, accept: 'application/pdf' }, undefined];
224
248
  await page.click('#save');
225
249
  expect(page.cdp).toHaveBeenCalledWith('DOM.scrollIntoViewIfNeeded', { nodeId: 9 });
250
+ // After CDP scroll, boundingRectResolvedJs runs with skipScroll=true.
226
251
  expect(page.scripts.at(-1)).toContain('if (false) el.scrollIntoView');
227
252
  });
253
+ it('clicks via CDP Input.dispatchMouseEvent when rect is visible', async () => {
254
+ const page = new ActionPage();
255
+ page.nativeClick = vi.fn().mockResolvedValue(undefined);
256
+ page.results = [resolveOk, { x: 50, y: 100, w: 200, h: 32, visible: true }];
257
+ await page.click('#category');
258
+ expect(page.nativeClick).toHaveBeenCalledWith(50, 100);
259
+ expect(page.nativeClick).toHaveBeenCalledTimes(1);
260
+ expect(page.scripts).toHaveLength(2);
261
+ expect(page.scripts[1]).toContain('getBoundingClientRect');
262
+ expect(page.scripts.join('\n')).not.toContain('el.click()');
263
+ });
264
+ it('clicks AX snapshot refs through backend node coordinates without DOM resolver', async () => {
265
+ const page = new ActionPage();
266
+ page.nativeClick = vi.fn().mockResolvedValue(undefined);
267
+ page.cdp = vi.fn(async (method) => {
268
+ if (method === 'Accessibility.getFullAXTree') {
269
+ return {
270
+ nodes: [
271
+ { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Demo' }, childIds: ['2'] },
272
+ { nodeId: '2', role: { value: 'button' }, name: { value: 'Submit' }, backendDOMNodeId: 10 },
273
+ ],
274
+ };
275
+ }
276
+ if (method === 'Page.getFrameTree') {
277
+ return {
278
+ frameTree: { frame: { id: 'root', url: 'https://app.example/' } },
279
+ };
280
+ }
281
+ if (method === 'DOM.getBoxModel') {
282
+ return { model: { content: [10, 20, 50, 20, 50, 40, 10, 40] } };
283
+ }
284
+ return {};
285
+ });
286
+ await expect(page.snapshot({ source: 'ax' })).resolves.toContain('[1]button "Submit"');
287
+ await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });
288
+ expect(page.cdp).toHaveBeenNthCalledWith(1, 'Accessibility.enable', {});
289
+ expect(page.cdp).toHaveBeenNthCalledWith(2, 'Accessibility.getFullAXTree', {});
290
+ expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', {});
291
+ expect(page.cdp).toHaveBeenCalledWith('Page.getFrameTree', {});
292
+ expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 10 });
293
+ expect(page.nativeClick).toHaveBeenCalledWith(30, 30);
294
+ expect(page.scripts).toHaveLength(0);
295
+ });
296
+ it('adds same-origin iframe AX refs and clicks them by frame-scoped backend node', async () => {
297
+ const page = new ActionPage();
298
+ page.nativeClick = vi.fn().mockResolvedValue(undefined);
299
+ page.cdp = vi.fn(async (method, params) => {
300
+ if (method === 'Accessibility.getFullAXTree' && params?.frameId === 'same-frame') {
301
+ return {
302
+ nodes: [
303
+ { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Embedded' }, childIds: ['2'] },
304
+ { nodeId: '2', role: { value: 'button' }, name: { value: 'Frame Save' }, backendDOMNodeId: 20 },
305
+ ],
306
+ };
307
+ }
308
+ if (method === 'Accessibility.getFullAXTree' && params?.frameId === 'cross-frame') {
309
+ return {
310
+ nodes: [
311
+ { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Cross' }, childIds: ['2'] },
312
+ { nodeId: '2', role: { value: 'button' }, name: { value: 'Cross Save' }, backendDOMNodeId: 30 },
313
+ ],
314
+ };
315
+ }
316
+ if (method === 'Accessibility.getFullAXTree') {
317
+ return {
318
+ nodes: [
319
+ { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Demo' }, childIds: ['2'] },
320
+ { nodeId: '2', role: { value: 'button' }, name: { value: 'Main Save' }, backendDOMNodeId: 10 },
321
+ ],
322
+ };
323
+ }
324
+ if (method === 'Page.getFrameTree') {
325
+ return {
326
+ frameTree: {
327
+ frame: { id: 'root', url: 'https://app.example/' },
328
+ childFrames: [
329
+ { frame: { id: 'same-frame', url: 'https://app.example/embed' } },
330
+ { frame: { id: 'cross-frame', url: 'https://other.example/embed' } },
331
+ ],
332
+ },
333
+ };
334
+ }
335
+ if (method === 'DOM.getBoxModel') {
336
+ return { model: { content: [100, 200, 140, 200, 140, 220, 100, 220] } };
337
+ }
338
+ return {};
339
+ });
340
+ const snapshot = await page.snapshot({ source: 'ax' });
341
+ expect(snapshot).toContain('[1]button "Main Save"');
342
+ expect(snapshot).toContain('frame "https://app.example/embed":');
343
+ expect(snapshot).toContain('[2]button "Frame Save"');
344
+ expect(snapshot).toContain('frame "https://other.example/embed":');
345
+ expect(snapshot).toContain('[3]button "Cross Save"');
346
+ await expect(page.click('2')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });
347
+ expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'same-frame' });
348
+ expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
349
+ expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
350
+ expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 20 });
351
+ expect(page.nativeClick).toHaveBeenCalledWith(120, 210);
352
+ });
353
+ it('clicks cross-origin AX refs through a frame-target CDP route', async () => {
354
+ const page = new ActionPage();
355
+ page.nativeClick = vi.fn().mockResolvedValue(undefined);
356
+ page.cdp = vi.fn(async (method, params) => {
357
+ if (method === 'Page.getFrameTree') {
358
+ return {
359
+ frameTree: {
360
+ frame: { id: 'root', url: 'https://app.example/' },
361
+ childFrames: [{ frame: { id: 'cross-frame', url: 'https://other.example/embed' } }],
362
+ },
363
+ };
364
+ }
365
+ if (method === 'Accessibility.getFullAXTree' && params?.sessionId === 'target') {
366
+ return {
367
+ nodes: [
368
+ { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
369
+ { nodeId: '2', role: { value: 'button' }, name: { value: 'Cross Save' }, backendDOMNodeId: 99 },
370
+ ],
371
+ };
372
+ }
373
+ if (method === 'Accessibility.getFullAXTree') {
374
+ return { nodes: [{ nodeId: '1', role: { value: 'RootWebArea' } }] };
375
+ }
376
+ if (method === 'DOM.getBoxModel') {
377
+ return { model: { content: [300, 400, 340, 400, 340, 420, 300, 420] } };
378
+ }
379
+ return {};
380
+ });
381
+ const snapshot = await page.snapshot({ source: 'ax' });
382
+ expect(snapshot).toContain('[1]button "Cross Save"');
383
+ await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });
384
+ expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
385
+ expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
386
+ expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 99, frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
387
+ expect(page.nativeClick).toHaveBeenCalledWith(320, 410);
388
+ });
389
+ it('enables Accessibility in cross-origin frame target sessions before stale AX recovery', async () => {
390
+ const page = new ActionPage();
391
+ page.nativeClick = vi.fn().mockResolvedValue(undefined);
392
+ let crossAxCalls = 0;
393
+ page.cdp = vi.fn(async (method, params) => {
394
+ if (method === 'Page.getFrameTree') {
395
+ return {
396
+ frameTree: {
397
+ frame: { id: 'root', url: 'https://app.example/' },
398
+ childFrames: [{ frame: { id: 'cross-frame', url: 'https://other.example/embed' } }],
399
+ },
400
+ };
401
+ }
402
+ if (method === 'Accessibility.getFullAXTree' && params?.sessionId === 'target') {
403
+ crossAxCalls++;
404
+ const backendDOMNodeId = crossAxCalls === 1 ? 99 : 100;
405
+ return {
406
+ nodes: [
407
+ { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
408
+ { nodeId: '2', role: { value: 'button' }, name: { value: 'Cross Save' }, backendDOMNodeId },
409
+ ],
410
+ };
411
+ }
412
+ if (method === 'Accessibility.getFullAXTree') {
413
+ return { nodes: [{ nodeId: '1', role: { value: 'RootWebArea' } }] };
414
+ }
415
+ if (method === 'DOM.getBoxModel') {
416
+ if (params?.backendNodeId === 99)
417
+ throw new Error('No node with given id found');
418
+ return { model: { content: [300, 400, 340, 400, 340, 420, 300, 420] } };
419
+ }
420
+ return {};
421
+ });
422
+ await page.snapshot({ source: 'ax' });
423
+ await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'reidentified' });
424
+ expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
425
+ expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
426
+ expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 99, frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
427
+ expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 100, frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
428
+ expect(page.nativeClick).toHaveBeenCalledWith(320, 410);
429
+ });
430
+ it('recovers stale iframe AX refs inside the original frame', async () => {
431
+ const page = new ActionPage();
432
+ page.nativeClick = vi.fn().mockResolvedValue(undefined);
433
+ let iframeAxCalls = 0;
434
+ page.cdp = vi.fn(async (method, params) => {
435
+ if (method === 'Page.getFrameTree') {
436
+ return {
437
+ frameTree: {
438
+ frame: { id: 'root', url: 'https://app.example/' },
439
+ childFrames: [{ frame: { id: 'same-frame', url: 'https://app.example/embed' } }],
440
+ },
441
+ };
442
+ }
443
+ if (method === 'Accessibility.getFullAXTree' && params?.frameId === 'same-frame') {
444
+ iframeAxCalls++;
445
+ return {
446
+ nodes: [
447
+ { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
448
+ { nodeId: '2', role: { value: 'button' }, name: { value: 'Frame Save' }, backendDOMNodeId: iframeAxCalls === 1 ? 20 : 42 },
449
+ ],
450
+ };
451
+ }
452
+ if (method === 'Accessibility.getFullAXTree') {
453
+ return {
454
+ nodes: [
455
+ { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
456
+ { nodeId: '2', role: { value: 'button' }, name: { value: 'Main Save' }, backendDOMNodeId: 10 },
457
+ ],
458
+ };
459
+ }
460
+ if (method === 'DOM.getBoxModel') {
461
+ if (params?.backendNodeId === 20)
462
+ throw new Error('No node with given id found');
463
+ return { model: { content: [200, 300, 240, 300, 240, 320, 200, 320] } };
464
+ }
465
+ return {};
466
+ });
467
+ await page.snapshot({ source: 'ax' });
468
+ await expect(page.click('2')).resolves.toEqual({ matches_n: 1, match_level: 'reidentified' });
469
+ expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', {});
470
+ expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'same-frame' });
471
+ expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 20 });
472
+ expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 42 });
473
+ expect(page.nativeClick).toHaveBeenCalledWith(220, 310);
474
+ });
475
+ it('recovers stale AX refs by role/name/nth before clicking', async () => {
476
+ const page = new ActionPage();
477
+ page.nativeClick = vi.fn().mockResolvedValue(undefined);
478
+ let axCalls = 0;
479
+ page.cdp = vi.fn(async (method, params) => {
480
+ if (method === 'Accessibility.getFullAXTree') {
481
+ axCalls++;
482
+ return {
483
+ nodes: [
484
+ { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Demo' }, childIds: ['2'] },
485
+ { nodeId: '2', role: { value: 'button' }, name: { value: 'Submit' }, backendDOMNodeId: axCalls === 1 ? 10 : 42 },
486
+ ],
487
+ };
488
+ }
489
+ if (method === 'DOM.getBoxModel') {
490
+ if (params?.backendNodeId === 10)
491
+ throw new Error('No node with given id found');
492
+ return { model: { content: [100, 200, 140, 200, 140, 220, 100, 220] } };
493
+ }
494
+ return {};
495
+ });
496
+ await page.snapshot({ source: 'ax' });
497
+ await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'reidentified' });
498
+ expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 10 });
499
+ expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 42 });
500
+ expect(page.nativeClick).toHaveBeenCalledWith(120, 210);
501
+ });
502
+ it('recovers AX refs across 10 repeated React-style rerenders', async () => {
503
+ const page = new ActionPage();
504
+ page.nativeClick = vi.fn().mockResolvedValue(undefined);
505
+ let currentBackendId = 100;
506
+ const staleBackendIds = new Set();
507
+ page.cdp = vi.fn(async (method, params) => {
508
+ if (method === 'Accessibility.getFullAXTree') {
509
+ return {
510
+ nodes: [
511
+ { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Demo' }, childIds: ['2'] },
512
+ { nodeId: '2', role: { value: 'button' }, name: { value: 'Submit' }, backendDOMNodeId: currentBackendId },
513
+ ],
514
+ };
515
+ }
516
+ if (method === 'DOM.getBoxModel') {
517
+ const id = params?.backendNodeId;
518
+ if (staleBackendIds.has(id))
519
+ throw new Error('No node with given id found');
520
+ return { model: { content: [id, id, id + 20, id, id + 20, id + 10, id, id + 10] } };
521
+ }
522
+ return {};
523
+ });
524
+ await page.snapshot({ source: 'ax' });
525
+ for (let i = 0; i < 10; i++) {
526
+ staleBackendIds.add(currentBackendId);
527
+ currentBackendId += 1;
528
+ await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'reidentified' });
529
+ }
530
+ expect(page.nativeClick).toHaveBeenCalledTimes(10);
531
+ });
532
+ it('falls back to JS el.click() when nativeClick is unavailable', async () => {
533
+ const page = new ActionPage();
534
+ page.results = [
535
+ resolveOk,
536
+ { x: 50, y: 100, w: 200, h: 32, visible: true },
537
+ { status: 'clicked', x: 50, y: 100 },
538
+ ];
539
+ await page.click('#save');
540
+ expect(page.scripts).toHaveLength(3);
541
+ expect(page.scripts[1]).toContain('getBoundingClientRect');
542
+ expect(page.scripts[2]).toContain('el.click()');
543
+ });
544
+ it('falls back to JS el.click() when rect is zero-area', async () => {
545
+ const page = new ActionPage();
546
+ page.nativeClick = vi.fn().mockResolvedValue(undefined);
547
+ page.results = [
548
+ resolveOk,
549
+ { x: 0, y: 0, w: 0, h: 0, visible: false },
550
+ { status: 'clicked', x: 0, y: 0 },
551
+ ];
552
+ await page.click('#hidden');
553
+ expect(page.nativeClick).not.toHaveBeenCalled();
554
+ expect(page.scripts).toHaveLength(3);
555
+ expect(page.scripts[2]).toContain('el.click()');
556
+ });
557
+ it('retries CDP click when JS path throws but yields coordinates', async () => {
558
+ const page = new ActionPage();
559
+ const nativeClick = vi.fn()
560
+ .mockRejectedValueOnce(new Error('cdp transient'))
561
+ .mockResolvedValueOnce(undefined);
562
+ page.nativeClick = nativeClick;
563
+ page.results = [
564
+ resolveOk,
565
+ { x: 10, y: 20, w: 100, h: 30, visible: true },
566
+ { status: 'js_failed', x: 10, y: 20, error: 'click intercepted' },
567
+ ];
568
+ await page.click('#flaky');
569
+ expect(nativeClick).toHaveBeenCalledTimes(2);
570
+ expect(nativeClick).toHaveBeenNthCalledWith(1, 10, 20);
571
+ expect(nativeClick).toHaveBeenNthCalledWith(2, 10, 20);
572
+ });
573
+ it('hovers via native CDP mouseMoved when coordinates are available', async () => {
574
+ const page = new ActionPage();
575
+ page.cdp = vi.fn().mockResolvedValue({});
576
+ page.results = [resolveOk, { x: 70, y: 80, w: 100, h: 20, visible: true }];
577
+ await expect(page.hover('#menu')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });
578
+ expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 70, y: 80 });
579
+ expect(page.scripts.at(-1)).toContain('getBoundingClientRect');
580
+ });
581
+ it('focuses through CDP DOM.focus when available', async () => {
582
+ const page = new ActionPage();
583
+ page.cdp = vi.fn(async (method) => {
584
+ if (method === 'DOM.getDocument')
585
+ return { root: { nodeId: 1 } };
586
+ if (method === 'DOM.querySelector')
587
+ return { nodeId: 9 };
588
+ return {};
589
+ });
590
+ page.results = [resolveOk, true];
591
+ page.withArgsResults = [{ ok: true }, undefined];
592
+ await expect(page.focus('#email')).resolves.toEqual({ focused: true, matches_n: 1, match_level: 'exact' });
593
+ expect(page.cdp).toHaveBeenCalledWith('DOM.focus', { nodeId: 9 });
594
+ });
595
+ it('verifies CDP focus and falls back to DOM focus when focus did not stick', async () => {
596
+ const page = new ActionPage();
597
+ page.cdp = vi.fn(async (method) => {
598
+ if (method === 'DOM.getDocument')
599
+ return { root: { nodeId: 1 } };
600
+ if (method === 'DOM.querySelector')
601
+ return { nodeId: 9 };
602
+ return {};
603
+ });
604
+ page.results = [resolveOk, false, true];
605
+ page.withArgsResults = [{ ok: true }, undefined];
606
+ await expect(page.focus('#email')).resolves.toEqual({ focused: true, matches_n: 1, match_level: 'exact' });
607
+ expect(page.cdp).toHaveBeenCalledWith('DOM.focus', { nodeId: 9 });
608
+ expect(page.scripts.at(-2)).toContain('document.activeElement === el');
609
+ expect(page.scripts.at(-1)).toContain('el.focus');
610
+ });
611
+ it('double-clicks via native CDP mouse events', async () => {
612
+ const page = new ActionPage();
613
+ page.cdp = vi.fn().mockResolvedValue({});
614
+ page.results = [resolveOk, { x: 20, y: 30, w: 100, h: 20, visible: true }];
615
+ await expect(page.dblClick('#row')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });
616
+ expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 20, y: 30 });
617
+ expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mousePressed', x: 20, y: 30, button: 'left', clickCount: 2 });
618
+ expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 20, y: 30, button: 'left', clickCount: 2 });
619
+ });
620
+ it('checks a checkbox only when its current state differs', async () => {
621
+ const page = new ActionPage();
622
+ page.nativeClick = vi.fn().mockResolvedValue(undefined);
623
+ page.results = [
624
+ resolveOk,
625
+ { ok: true, checked: false, disabled: false, kind: 'checkbox' },
626
+ resolveOk,
627
+ { x: 20, y: 30, w: 40, h: 20, visible: true },
628
+ { ok: true, checked: true, disabled: false, kind: 'checkbox' },
629
+ ];
630
+ await expect(page.setChecked('#agree', true)).resolves.toEqual({
631
+ checked: true,
632
+ changed: true,
633
+ matches_n: 1,
634
+ match_level: 'exact',
635
+ kind: 'checkbox',
636
+ });
637
+ expect(page.nativeClick).toHaveBeenCalledWith(20, 30);
638
+ });
639
+ it('does not click a checkbox that already has the requested state', async () => {
640
+ const page = new ActionPage();
641
+ page.nativeClick = vi.fn().mockResolvedValue(undefined);
642
+ page.results = [resolveOk, { ok: true, checked: false, disabled: false, kind: 'checkbox' }];
643
+ await expect(page.setChecked('#agree', false)).resolves.toEqual({
644
+ checked: false,
645
+ changed: false,
646
+ matches_n: 1,
647
+ match_level: 'exact',
648
+ kind: 'checkbox',
649
+ });
650
+ expect(page.nativeClick).not.toHaveBeenCalled();
651
+ });
652
+ it('rejects non-checkable targets with a structured error', async () => {
653
+ const page = new ActionPage();
654
+ page.results = [resolveOk, { ok: false, reason: 'not_checkable', tag: 'button' }];
655
+ const err = await page.setChecked('button', true).catch((error) => error);
656
+ expect(err).toBeInstanceOf(TargetError);
657
+ expect(err.code).toBe('not_checkable');
658
+ });
659
+ it('rejects attempts to uncheck a radio button directly', async () => {
660
+ const page = new ActionPage();
661
+ page.results = [resolveOk, { ok: true, checked: true, disabled: false, kind: 'radio' }];
662
+ const err = await page.setChecked('#radio', false).catch((error) => error);
663
+ expect(err).toBeInstanceOf(TargetError);
664
+ expect(err.code).toBe('not_checkable');
665
+ expect(err.hint).toContain('Select another radio');
666
+ });
667
+ it('treats ARIA radio controls like radio buttons', async () => {
668
+ const page = new ActionPage();
669
+ page.results = [resolveOk, { ok: true, checked: true, disabled: false, kind: 'menuitemradio' }];
670
+ const err = await page.setChecked('[role="menuitemradio"]', false).catch((error) => error);
671
+ expect(err).toBeInstanceOf(TargetError);
672
+ expect(err.code).toBe('not_checkable');
673
+ expect(err.hint).toContain('Select another radio');
674
+ });
675
+ it('uploads files through setFileInput using a temporary marker selector', async () => {
676
+ const page = new ActionPage();
677
+ page.setFileInput = vi.fn().mockResolvedValue(undefined);
678
+ page.results = [
679
+ resolveOk,
680
+ ['receipt.pdf'],
681
+ ];
682
+ page.withArgsResults = [{ ok: true, multiple: false, accept: 'application/pdf' }, undefined];
683
+ await expect(page.uploadFiles('#file', ['/tmp/receipt.pdf'])).resolves.toEqual({
684
+ uploaded: true,
685
+ files: 1,
686
+ file_names: ['receipt.pdf'],
687
+ target: '#file',
688
+ matches_n: 1,
689
+ match_level: 'exact',
690
+ multiple: false,
691
+ accept: 'application/pdf',
692
+ });
693
+ expect(page.setFileInput).toHaveBeenCalledWith(['/tmp/receipt.pdf'], expect.stringMatching(/data-opencli-upload-target/));
694
+ expect(page.withArgs.at(0)).toMatchObject({ markerAttr: 'data-opencli-upload-target' });
695
+ expect(page.withArgs.at(-1)).toMatchObject({ markerAttr: 'data-opencli-upload-target' });
696
+ });
697
+ it('rejects non-file-input upload targets with a structured error', async () => {
698
+ const page = new ActionPage();
699
+ page.setFileInput = vi.fn().mockResolvedValue(undefined);
700
+ page.results = [resolveOk];
701
+ page.withArgsResults = [{ ok: false, tag: 'button', type: '' }];
702
+ const err = await page.uploadFiles('button', ['/tmp/receipt.pdf']).catch((error) => error);
703
+ expect(err).toBeInstanceOf(TargetError);
704
+ expect(err.code).toBe('not_file_input');
705
+ expect(page.setFileInput).not.toHaveBeenCalled();
706
+ });
707
+ it('rejects multiple files for a single-file input before mutating files', async () => {
708
+ const page = new ActionPage();
709
+ page.setFileInput = vi.fn().mockResolvedValue(undefined);
710
+ page.results = [resolveOk];
711
+ page.withArgsResults = [{ ok: true, multiple: false, accept: '' }, undefined];
712
+ const err = await page.uploadFiles('#file', ['/tmp/a.pdf', '/tmp/b.pdf']).catch((error) => error);
713
+ expect(err).toBeInstanceOf(TargetError);
714
+ expect(err.code).toBe('not_file_input');
715
+ expect(page.setFileInput).not.toHaveBeenCalled();
716
+ });
717
+ it('drags between two resolved element centers via native CDP mouse events', async () => {
718
+ const page = new ActionPage();
719
+ page.cdp = vi.fn().mockResolvedValue({});
720
+ page.results = [
721
+ resolveOk,
722
+ { x: 10, y: 20, w: 30, h: 20, visible: true },
723
+ { ok: true, matches_n: 2, match_level: 'stable' },
724
+ {
725
+ source: { x: 10, y: 20, w: 30, h: 20, visible: true },
726
+ target: { x: 110, y: 120, w: 40, h: 30, visible: true },
727
+ },
728
+ ];
729
+ await expect(page.drag('#card', '.lane', { to: { nth: 1 } })).resolves.toEqual({
730
+ dragged: true,
731
+ source: '#card',
732
+ target: '.lane',
733
+ source_matches_n: 1,
734
+ target_matches_n: 2,
735
+ source_match_level: 'exact',
736
+ target_match_level: 'stable',
737
+ });
738
+ expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 10, y: 20 });
739
+ expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mousePressed', x: 10, y: 20, button: 'left', clickCount: 1 });
740
+ expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 60, y: 70, button: 'left', buttons: 1 });
741
+ expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 110, y: 120, button: 'left', buttons: 1 });
742
+ expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 110, y: 120, button: 'left', clickCount: 1 });
743
+ });
228
744
  it('presses key chords through native CDP key events when available', async () => {
229
745
  const page = new ActionPage();
230
746
  page.nativeKeyPress = vi.fn().mockResolvedValue(undefined);
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ });
@@ -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,