@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
@@ -337,6 +337,236 @@ describe('createProgram root help descriptions', () => {
337
337
  registry.set(key, value);
338
338
  }
339
339
  });
340
+ it('renders browser namespace structured help from Commander metadata', () => {
341
+ const argv = process.argv;
342
+ try {
343
+ const program = createProgram('', '');
344
+ const browser = program.commands.find(cmd => cmd.name() === 'browser');
345
+ expect(browser).toBeTruthy();
346
+ process.argv = ['node', 'opencli', 'browser', '--help', '-f', 'yaml'];
347
+ const data = yaml.load(browser.helpInformation());
348
+ expect(data.namespace).toBe('browser');
349
+ expect(data.command).toBe('opencli browser');
350
+ expect(data.description).toBe('Browser control — navigate, click, type, extract, wait (no LLM needed)');
351
+ expect(data.command_count).toBeGreaterThan(20);
352
+ expect(data.namespace_options).toEqual(expect.arrayContaining([
353
+ expect.objectContaining({
354
+ name: 'workspace',
355
+ flags: '--workspace <name>',
356
+ takes_value: 'required',
357
+ }),
358
+ expect.objectContaining({
359
+ name: 'window',
360
+ flags: '--window <mode>',
361
+ takes_value: 'required',
362
+ }),
363
+ expect.objectContaining({
364
+ name: 'keepTab',
365
+ flags: '--keep-tab <bool>',
366
+ takes_value: 'required',
367
+ }),
368
+ ]));
369
+ expect(data.global_options).toEqual(expect.arrayContaining([
370
+ expect.objectContaining({
371
+ name: 'version',
372
+ flags: '-V, --version',
373
+ }),
374
+ expect.objectContaining({
375
+ name: 'profile',
376
+ flags: '--profile <name>',
377
+ takes_value: 'required',
378
+ }),
379
+ ]));
380
+ const click = data.commands.find((cmd) => cmd.name === 'click');
381
+ expect(click).toMatchObject({
382
+ command: 'opencli browser click',
383
+ usage: 'opencli browser click [target] [options]',
384
+ positionals: [{ name: 'target' }],
385
+ });
386
+ expect(click.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
387
+ const tabList = data.commands.find((cmd) => cmd.name === 'tab list');
388
+ expect(tabList).toMatchObject({
389
+ command: 'opencli browser tab list',
390
+ usage: 'opencli browser tab list [options]',
391
+ command_options: [],
392
+ });
393
+ const getText = data.commands.find((cmd) => cmd.name === 'get text');
394
+ expect(getText).toMatchObject({
395
+ command: 'opencli browser get text',
396
+ positionals: [{ name: 'target' }],
397
+ });
398
+ expect(data.structured_help).toMatchObject({
399
+ formats: ['yaml', 'json'],
400
+ usage: 'opencli browser --help -f yaml',
401
+ });
402
+ }
403
+ finally {
404
+ process.argv = argv;
405
+ }
406
+ });
407
+ it('renders nested browser parent structured help for a subtree', () => {
408
+ const argv = process.argv;
409
+ try {
410
+ const program = createProgram('', '');
411
+ const browser = program.commands.find(cmd => cmd.name() === 'browser');
412
+ const tab = browser.commands.find(cmd => cmd.name() === 'tab');
413
+ expect(tab).toBeTruthy();
414
+ process.argv = ['node', 'opencli', 'browser', 'tab', '--help', '-f', 'yaml'];
415
+ const data = yaml.load(tab.helpInformation());
416
+ expect(data).toMatchObject({
417
+ namespace: 'browser',
418
+ group: 'tab',
419
+ command: 'opencli browser tab',
420
+ usage: 'opencli browser tab <command> [args] [options]',
421
+ command_count: 4,
422
+ });
423
+ expect(data.commands.map((cmd) => cmd.name)).toEqual([
424
+ 'tab close',
425
+ 'tab list',
426
+ 'tab new',
427
+ 'tab select',
428
+ ]);
429
+ expect(data.commands.find((cmd) => cmd.name === 'tab close')).toMatchObject({
430
+ command: 'opencli browser tab close',
431
+ usage: 'opencli browser tab close [targetId] [options]',
432
+ positionals: [{ name: 'targetId', help: 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"' }],
433
+ });
434
+ expect(data.namespace_options.map((option) => option.name)).toEqual(['workspace', 'window', 'keepTab']);
435
+ expect(data.structured_help).toMatchObject({
436
+ usage: 'opencli browser tab --help -f yaml',
437
+ });
438
+ }
439
+ finally {
440
+ process.argv = argv;
441
+ }
442
+ });
443
+ it('renders browser command structured help without needing the full namespace dump', () => {
444
+ const argv = process.argv;
445
+ try {
446
+ const program = createProgram('', '');
447
+ const browser = program.commands.find(cmd => cmd.name() === 'browser');
448
+ const click = browser.commands.find(cmd => cmd.name() === 'click');
449
+ expect(click).toBeTruthy();
450
+ process.argv = ['node', 'opencli', 'browser', 'click', '--help', '-f', 'yaml'];
451
+ const data = yaml.load(click.helpInformation());
452
+ expect(data).toMatchObject({
453
+ namespace: 'browser',
454
+ name: 'click',
455
+ command: 'opencli browser click',
456
+ usage: 'opencli browser click [target] [options]',
457
+ positionals: [{ name: 'target' }],
458
+ structured_help: {
459
+ usage: 'opencli browser click --help -f yaml',
460
+ },
461
+ });
462
+ expect(data.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
463
+ expect(data.namespace_options.map((option) => option.name)).toEqual(['workspace', 'window', 'keepTab']);
464
+ expect(data.global_options.map((option) => option.name)).toContain('profile');
465
+ }
466
+ finally {
467
+ process.argv = argv;
468
+ }
469
+ });
470
+ it('renders daemon namespace structured help with leaves and global options', () => {
471
+ const argv = process.argv;
472
+ try {
473
+ const program = createProgram('', '');
474
+ const daemon = program.commands.find(cmd => cmd.name() === 'daemon');
475
+ expect(daemon).toBeTruthy();
476
+ process.argv = ['node', 'opencli', 'daemon', '--help', '-f', 'yaml'];
477
+ const data = yaml.load(daemon.helpInformation());
478
+ expect(data).toMatchObject({
479
+ namespace: 'daemon',
480
+ command: 'opencli daemon',
481
+ usage: 'opencli daemon <command> [args] [options]',
482
+ description: 'Manage the opencli daemon',
483
+ command_count: 3,
484
+ namespace_options: [],
485
+ structured_help: { usage: 'opencli daemon --help -f yaml' },
486
+ });
487
+ expect(data.commands.map((cmd) => cmd.name)).toEqual(['restart', 'status', 'stop']);
488
+ expect(data.global_options.map((option) => option.name)).toEqual(expect.arrayContaining(['version', 'profile']));
489
+ }
490
+ finally {
491
+ process.argv = argv;
492
+ }
493
+ });
494
+ it('renders plugin namespace structured help with positional + option leaves', () => {
495
+ const argv = process.argv;
496
+ try {
497
+ const program = createProgram('', '');
498
+ const plugin = program.commands.find(cmd => cmd.name() === 'plugin');
499
+ expect(plugin).toBeTruthy();
500
+ process.argv = ['node', 'opencli', 'plugin', '--help', '-f', 'yaml'];
501
+ const data = yaml.load(plugin.helpInformation());
502
+ expect(data).toMatchObject({
503
+ namespace: 'plugin',
504
+ command: 'opencli plugin',
505
+ description: 'Manage opencli plugins',
506
+ namespace_options: [],
507
+ });
508
+ expect(data.commands.map((cmd) => cmd.name)).toEqual(['create', 'install', 'list', 'uninstall', 'update']);
509
+ const update = data.commands.find((cmd) => cmd.name === 'update');
510
+ expect(update).toMatchObject({
511
+ usage: 'opencli plugin update [name] [options]',
512
+ positionals: [{ name: 'name' }],
513
+ });
514
+ expect(update.command_options.map((option) => option.name)).toEqual(['all']);
515
+ }
516
+ finally {
517
+ process.argv = argv;
518
+ }
519
+ });
520
+ it('renders adapter namespace structured help preserving original description after applyRootSubcommandSummaries', () => {
521
+ const argv = process.argv;
522
+ try {
523
+ const program = createProgram('', '');
524
+ const adapter = program.commands.find(cmd => cmd.name() === 'adapter');
525
+ expect(adapter).toBeTruthy();
526
+ process.argv = ['node', 'opencli', 'adapter', '--help', '-f', 'yaml'];
527
+ const data = yaml.load(adapter.helpInformation());
528
+ // applyRootSubcommandSummaries() rewrites .description() to a child-name listing;
529
+ // structured help must surface the original product description via the snapshot.
530
+ expect(data.description).toBe('Manage CLI adapters');
531
+ expect(data.commands.map((cmd) => cmd.name)).toEqual(['eject', 'reset', 'status']);
532
+ const reset = data.commands.find((cmd) => cmd.name === 'reset');
533
+ expect(reset).toMatchObject({
534
+ usage: 'opencli adapter reset [site] [options]',
535
+ positionals: [{ name: 'site' }],
536
+ });
537
+ expect(reset.command_options.map((option) => option.name)).toEqual(['all']);
538
+ }
539
+ finally {
540
+ process.argv = argv;
541
+ }
542
+ });
543
+ it('renders profile namespace structured help including required positionals', () => {
544
+ const argv = process.argv;
545
+ try {
546
+ const program = createProgram('', '');
547
+ const profile = program.commands.find(cmd => cmd.name() === 'profile');
548
+ expect(profile).toBeTruthy();
549
+ process.argv = ['node', 'opencli', 'profile', '--help', '-f', 'yaml'];
550
+ const data = yaml.load(profile.helpInformation());
551
+ expect(data).toMatchObject({
552
+ namespace: 'profile',
553
+ description: 'Manage Browser Bridge Chrome profiles',
554
+ command_count: 3,
555
+ });
556
+ expect(data.commands.map((cmd) => cmd.name)).toEqual(['list', 'rename', 'use']);
557
+ const rename = data.commands.find((cmd) => cmd.name === 'rename');
558
+ expect(rename).toMatchObject({
559
+ usage: 'opencli profile rename <contextId> <alias> [options]',
560
+ positionals: [
561
+ { name: 'contextId', required: true },
562
+ { name: 'alias', required: true },
563
+ ],
564
+ });
565
+ }
566
+ finally {
567
+ process.argv = argv;
568
+ }
569
+ });
340
570
  });
341
571
  describe('resolveBrowserVerifyInvocation', () => {
342
572
  it('prefers the built entry declared in package metadata', () => {
@@ -607,6 +837,8 @@ describe('browser tab targeting commands', () => {
607
837
  stderrSpy.mockClear();
608
838
  mockBrowserConnect.mockClear();
609
839
  mockBrowserClose.mockReset().mockResolvedValue(undefined);
840
+ delete process.env.OPENCLI_WINDOW;
841
+ delete process.env.OPENCLI_KEEP_TAB;
610
842
  mockBindTab.mockReset().mockResolvedValue({
611
843
  workspace: 'bound:default',
612
844
  page: 'tab-2',
@@ -636,7 +868,17 @@ describe('browser tab targeting commands', () => {
636
868
  { index: 0, frameId: 'frame-1', url: 'https://x.example/embed', name: 'x-embed' },
637
869
  ]),
638
870
  evaluateInFrame: vi.fn().mockResolvedValue('inside frame'),
871
+ screenshot: vi.fn().mockResolvedValue('base64-shot'),
872
+ annotatedScreenshot: vi.fn().mockResolvedValue('annotated-base64-shot'),
639
873
  readNetworkCapture: vi.fn().mockResolvedValue([]),
874
+ closeWindow: vi.fn().mockResolvedValue(undefined),
875
+ waitForDownload: vi.fn().mockResolvedValue({
876
+ downloaded: true,
877
+ filename: '/tmp/receipt.pdf',
878
+ url: 'https://app.example/receipt.pdf',
879
+ state: 'complete',
880
+ elapsedMs: 10,
881
+ }),
640
882
  };
641
883
  });
642
884
  function lastJsonLog() {
@@ -672,8 +914,90 @@ describe('browser tab targeting commands', () => {
672
914
  it('runs browser commands against an explicit bound workspace', async () => {
673
915
  const program = createProgram('', '');
674
916
  await program.parseAsync(['node', 'opencli', 'browser', '--workspace', 'bound:default', 'state']);
675
- expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 30, workspace: 'bound:default' });
917
+ expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 30, workspace: 'bound:default', windowMode: 'foreground' });
918
+ expect(browserState.page?.snapshot).toHaveBeenCalled();
919
+ });
920
+ it('passes browser --window through Commander options without relying on env pre-processing', async () => {
921
+ const program = createProgram('', '');
922
+ await program.parseAsync(['node', 'opencli', 'browser', '--window', 'background', 'state']);
923
+ expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 30, workspace: 'browser:default', windowMode: 'background' });
924
+ expect(browserState.page?.snapshot).toHaveBeenCalled();
925
+ });
926
+ it('releases non-bound browser tab leases when --keep-tab=false', async () => {
927
+ const program = createProgram('', '');
928
+ await program.parseAsync(['node', 'opencli', 'browser', '--keep-tab', 'false', 'state']);
676
929
  expect(browserState.page?.snapshot).toHaveBeenCalled();
930
+ expect(browserState.page?.closeWindow).toHaveBeenCalled();
931
+ });
932
+ it('does not auto-release explicit bound workspaces when --keep-tab=false', async () => {
933
+ const program = createProgram('', '');
934
+ await program.parseAsync(['node', 'opencli', 'browser', '--workspace', 'bound:default', '--keep-tab', 'false', 'state']);
935
+ expect(browserState.page?.snapshot).toHaveBeenCalled();
936
+ expect(browserState.page?.closeWindow).not.toHaveBeenCalled();
937
+ expect(stderrSpy.mock.calls.flat().join('')).toContain('--window/--keep-tab ignored for bound:* workspaces');
938
+ });
939
+ it('passes the opt-in AX source to browser state', async () => {
940
+ const program = createProgram('', '');
941
+ await program.parseAsync(['node', 'opencli', 'browser', 'state', '--source', 'ax']);
942
+ expect(browserState.page?.snapshot).toHaveBeenCalledWith({ viewportExpand: 2000, source: 'ax' });
943
+ });
944
+ it('prints DOM vs AX snapshot metrics without changing default state output', async () => {
945
+ browserState.page = {
946
+ ...browserState.page,
947
+ snapshot: vi.fn(async (opts) => {
948
+ if (opts?.source === 'ax') {
949
+ return 'source: ax\n---\n[1]button "Save"\nframe "https://app.example/embed":\n [2]button "Frame Save"\n---\ninteractive: 2';
950
+ }
951
+ return 'URL: https://app.example\n[1] button "Save"';
952
+ }),
953
+ };
954
+ const program = createProgram('', '');
955
+ await program.parseAsync(['node', 'opencli', 'browser', 'state', '--compare-sources']);
956
+ expect(browserState.page?.snapshot).toHaveBeenCalledWith({ viewportExpand: 2000, source: 'dom' });
957
+ expect(browserState.page?.snapshot).toHaveBeenCalledWith({ viewportExpand: 2000, source: 'ax' });
958
+ const out = lastJsonLog();
959
+ expect(out.url).toBe('https://one.example');
960
+ expect(out.sources.dom).toMatchObject({ ok: true, refs: 1, frame_sections: 0 });
961
+ expect(out.sources.ax).toMatchObject({ ok: true, refs: 2, frame_sections: 1, interactive: 2 });
962
+ });
963
+ it('keeps compare-sources usable when one observation backend fails', async () => {
964
+ browserState.page = {
965
+ ...browserState.page,
966
+ snapshot: vi.fn(async (opts) => {
967
+ if (opts?.source === 'ax')
968
+ throw new Error('AX unavailable');
969
+ return '[1] button "Save"';
970
+ }),
971
+ };
972
+ const program = createProgram('', '');
973
+ await program.parseAsync(['node', 'opencli', 'browser', 'state', '--compare-sources']);
974
+ const out = lastJsonLog();
975
+ expect(out.sources.dom).toMatchObject({ ok: true, refs: 1 });
976
+ expect(out.sources.ax).toMatchObject({
977
+ ok: false,
978
+ error: { message: 'AX unavailable' },
979
+ });
980
+ });
981
+ it('rejects unknown browser state sources before touching the page', async () => {
982
+ const program = createProgram('', '');
983
+ await program.parseAsync(['node', 'opencli', 'browser', 'state', '--source', 'magic']);
984
+ expect(browserState.page?.snapshot).not.toHaveBeenCalled();
985
+ const out = lastJsonLog();
986
+ expect(out.error.code).toBe('invalid_source');
987
+ expect(process.exitCode).toBeDefined();
988
+ });
989
+ it('captures annotated screenshots through the visual ref overlay path', async () => {
990
+ const program = createProgram('', '');
991
+ await program.parseAsync(['node', 'opencli', 'browser', 'screenshot', '--annotate']);
992
+ expect(browserState.page?.annotatedScreenshot).toHaveBeenCalledWith({
993
+ fullPage: false,
994
+ annotate: true,
995
+ width: undefined,
996
+ height: undefined,
997
+ format: 'png',
998
+ });
999
+ expect(browserState.page?.screenshot).not.toHaveBeenCalled();
1000
+ expect(consoleLogSpy).toHaveBeenLastCalledWith('annotated-base64-shot');
677
1001
  });
678
1002
  it('blocks history navigation on bound workspaces unless explicitly allowed', async () => {
679
1003
  browserState.page = {
@@ -1036,6 +1360,55 @@ describe('browser tab targeting commands', () => {
1036
1360
  expect(bufferReads).toBe(2);
1037
1361
  expect(out.matched.url).toBe('https://target.example/api/target');
1038
1362
  });
1363
+ it('browser wait download delegates to the Browser Bridge download observer', async () => {
1364
+ browserState.page = {
1365
+ goto: vi.fn().mockResolvedValue(undefined),
1366
+ wait: vi.fn().mockResolvedValue(undefined),
1367
+ waitForDownload: vi.fn().mockResolvedValue({
1368
+ downloaded: true,
1369
+ filename: '/tmp/receipt.pdf',
1370
+ url: 'https://app.example/receipt.pdf',
1371
+ state: 'complete',
1372
+ elapsedMs: 10,
1373
+ }),
1374
+ setActivePage: vi.fn(),
1375
+ getActivePage: vi.fn().mockReturnValue('tab-1'),
1376
+ getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'),
1377
+ tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]),
1378
+ };
1379
+ const program = createProgram('', '');
1380
+ await program.parseAsync(['node', 'opencli', 'browser', 'wait', 'download', 'receipt', '--timeout', '900']);
1381
+ expect(browserState.page?.waitForDownload).toHaveBeenCalledWith('receipt', 900);
1382
+ expect(lastJsonLog()).toEqual({
1383
+ downloaded: true,
1384
+ filename: '/tmp/receipt.pdf',
1385
+ url: 'https://app.example/receipt.pdf',
1386
+ state: 'complete',
1387
+ elapsedMs: 10,
1388
+ });
1389
+ });
1390
+ it('browser wait download reports an error envelope when no matching download completes', async () => {
1391
+ browserState.page = {
1392
+ goto: vi.fn().mockResolvedValue(undefined),
1393
+ wait: vi.fn().mockResolvedValue(undefined),
1394
+ waitForDownload: vi.fn().mockResolvedValue({
1395
+ downloaded: false,
1396
+ state: 'interrupted',
1397
+ error: 'No download matched "receipt" within 900ms',
1398
+ elapsedMs: 900,
1399
+ }),
1400
+ setActivePage: vi.fn(),
1401
+ getActivePage: vi.fn().mockReturnValue('tab-1'),
1402
+ getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'),
1403
+ tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]),
1404
+ };
1405
+ const program = createProgram('', '');
1406
+ await program.parseAsync(['node', 'opencli', 'browser', 'wait', 'download', 'receipt', '--timeout', '900']);
1407
+ const out = lastJsonLog();
1408
+ expect(out.error.code).toBe('download_not_seen');
1409
+ expect(out.download.elapsedMs).toBe(900);
1410
+ expect(process.exitCode).toBeDefined();
1411
+ });
1039
1412
  });
1040
1413
  describe('browser network command', () => {
1041
1414
  const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
@@ -1687,6 +2060,25 @@ describe('browser find command', () => {
1687
2060
  expect(out.entries[1].ref).toBe(17);
1688
2061
  expect(process.exitCode).toBeUndefined();
1689
2062
  });
2063
+ it('finds elements by semantic role/name without requiring CSS', async () => {
2064
+ browserState.page.evaluate.mockResolvedValueOnce({
2065
+ matches_n: 1,
2066
+ entries: [
2067
+ { nth: 0, ref: 9, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
2068
+ ],
2069
+ });
2070
+ const program = createProgram('', '');
2071
+ await program.parseAsync(['node', 'opencli', 'browser', 'find', '--role', 'button', '--name', 'Save']);
2072
+ const js = browserState.page.evaluate.mock.calls[0][0];
2073
+ expect(js).toContain('CRITERIA');
2074
+ expect(js).toContain('function accessibleName');
2075
+ expect(lastJsonLog()).toEqual({
2076
+ matches_n: 1,
2077
+ entries: [
2078
+ { nth: 0, ref: 9, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
2079
+ ],
2080
+ });
2081
+ });
1690
2082
  it('forwards --limit / --text-max into the generated JS', async () => {
1691
2083
  browserState.page.evaluate.mockResolvedValueOnce({ matches_n: 0, entries: [] });
1692
2084
  const program = createProgram('', '');
@@ -1742,6 +2134,40 @@ describe('browser get text/value/attributes commands', () => {
1742
2134
  await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '7']);
1743
2135
  expect(lastJsonLog()).toEqual({ value: 'Hello world', matches_n: 1, match_level: 'exact' });
1744
2136
  });
2137
+ it('resolves a semantic locator to a ref before get text', async () => {
2138
+ const evalMock = browserState.page.evaluate;
2139
+ evalMock.mockResolvedValueOnce({
2140
+ matches_n: 1,
2141
+ entries: [
2142
+ { nth: 0, ref: 12, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
2143
+ ],
2144
+ });
2145
+ evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
2146
+ evalMock.mockResolvedValueOnce('Save');
2147
+ const program = createProgram('', '');
2148
+ await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '--role', 'button', '--name', 'Save']);
2149
+ expect(evalMock.mock.calls[0][0]).toContain('function accessibleName');
2150
+ expect(evalMock.mock.calls[1][0]).toContain('const ref = "12"');
2151
+ expect(lastJsonLog()).toEqual({ value: 'Save', matches_n: 1, match_level: 'exact' });
2152
+ });
2153
+ it('reports total_matches when semantic get reads the first of multiple matches', async () => {
2154
+ const evalMock = browserState.page.evaluate;
2155
+ evalMock.mockResolvedValueOnce({
2156
+ matches_n: 3,
2157
+ entries: [
2158
+ { nth: 0, ref: 12, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
2159
+ { nth: 1, ref: 13, tag: 'button', role: 'button', text: 'Save draft', attrs: {}, visible: true },
2160
+ { nth: 2, ref: 14, tag: 'button', role: 'button', text: 'Save copy', attrs: {}, visible: true },
2161
+ ],
2162
+ });
2163
+ evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
2164
+ evalMock.mockResolvedValueOnce('Save');
2165
+ const program = createProgram('', '');
2166
+ await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '--role', 'button', '--name', 'Save']);
2167
+ expect(evalMock.mock.calls[0][0]).toContain('const LIMIT = 6');
2168
+ expect(evalMock.mock.calls[1][0]).toContain('const ref = "12"');
2169
+ expect(lastJsonLog()).toEqual({ value: 'Save', matches_n: 1, match_level: 'exact', total_matches: 3 });
2170
+ });
1745
2171
  it('reports matches_n on multi-match CSS (read path: first match wins)', async () => {
1746
2172
  const evalMock = browserState.page.evaluate;
1747
2173
  evalMock.mockResolvedValueOnce({ ok: true, matches_n: 3, match_level: 'exact' });
@@ -1797,6 +2223,28 @@ describe('browser click/type commands', () => {
1797
2223
  const { lastJsonLog } = installSelectorFirstTestHarness('click-type', () => ({
1798
2224
  evaluate: vi.fn().mockResolvedValue(false),
1799
2225
  click: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
2226
+ dblClick: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
2227
+ hover: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
2228
+ focus: vi.fn().mockResolvedValue({ focused: true, matches_n: 1, match_level: 'exact' }),
2229
+ setChecked: vi.fn().mockResolvedValue({ checked: true, changed: true, matches_n: 1, match_level: 'exact', kind: 'checkbox' }),
2230
+ uploadFiles: vi.fn().mockResolvedValue({
2231
+ uploaded: true,
2232
+ files: 1,
2233
+ file_names: ['receipt.pdf'],
2234
+ target: '#file',
2235
+ matches_n: 1,
2236
+ match_level: 'exact',
2237
+ multiple: false,
2238
+ }),
2239
+ drag: vi.fn().mockResolvedValue({
2240
+ dragged: true,
2241
+ source: '#card',
2242
+ target: '#lane',
2243
+ source_matches_n: 1,
2244
+ target_matches_n: 1,
2245
+ source_match_level: 'exact',
2246
+ target_match_level: 'exact',
2247
+ }),
1800
2248
  typeText: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
1801
2249
  fillText: vi.fn().mockResolvedValue({
1802
2250
  filled: true,
@@ -1817,6 +2265,163 @@ describe('browser click/type commands', () => {
1817
2265
  expect(browserState.page.click).toHaveBeenCalledWith('#save', {});
1818
2266
  expect(lastJsonLog()).toEqual({ clicked: true, target: '#save', matches_n: 1, match_level: 'exact' });
1819
2267
  });
2268
+ it('clicks a unique semantic locator without a prior state call', async () => {
2269
+ browserState.page.evaluate.mockResolvedValueOnce({
2270
+ matches_n: 1,
2271
+ entries: [
2272
+ { nth: 0, ref: 23, tag: 'button', role: 'button', text: 'Submit', attrs: {}, visible: true },
2273
+ ],
2274
+ });
2275
+ browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
2276
+ const program = createProgram('', '');
2277
+ await program.parseAsync(['node', 'opencli', 'browser', 'click', '--role', 'button', '--name', 'Submit']);
2278
+ expect(browserState.page.click).toHaveBeenCalledWith('23', {});
2279
+ expect(lastJsonLog()).toEqual({ clicked: true, target: '23', matches_n: 1, match_level: 'exact' });
2280
+ });
2281
+ it('rejects ambiguous semantic locators before write actions', async () => {
2282
+ browserState.page.evaluate.mockResolvedValueOnce({
2283
+ matches_n: 2,
2284
+ entries: [
2285
+ { nth: 0, ref: 1, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
2286
+ { nth: 1, ref: 2, tag: 'button', role: 'button', text: 'Save draft', attrs: {}, visible: true },
2287
+ ],
2288
+ });
2289
+ const program = createProgram('', '');
2290
+ await program.parseAsync(['node', 'opencli', 'browser', 'click', '--role', 'button', '--name', 'Save']);
2291
+ const err = lastJsonLog().error;
2292
+ expect(err.code).toBe('semantic_ambiguous');
2293
+ expect(err.matches_n).toBe(2);
2294
+ expect(browserState.page.click).not.toHaveBeenCalled();
2295
+ expect(process.exitCode).toBeDefined();
2296
+ });
2297
+ it('hover: resolves a semantic locator before moving the mouse', async () => {
2298
+ browserState.page.evaluate.mockResolvedValueOnce({
2299
+ matches_n: 1,
2300
+ entries: [
2301
+ { nth: 0, ref: 31, tag: 'button', role: 'button', text: 'Settings', attrs: {}, visible: true },
2302
+ ],
2303
+ });
2304
+ const program = createProgram('', '');
2305
+ await program.parseAsync(['node', 'opencli', 'browser', 'hover', '--role', 'button', '--name', 'Settings']);
2306
+ expect(browserState.page.hover).toHaveBeenCalledWith('31', {});
2307
+ expect(lastJsonLog()).toEqual({ hovered: true, target: '31', matches_n: 1, match_level: 'exact' });
2308
+ });
2309
+ it('check: resolves a semantic locator before setting checked state', async () => {
2310
+ browserState.page.evaluate.mockResolvedValueOnce({
2311
+ matches_n: 1,
2312
+ entries: [
2313
+ { nth: 0, ref: 32, tag: 'input', role: 'checkbox', text: 'Accept', attrs: {}, visible: true },
2314
+ ],
2315
+ });
2316
+ browserState.page.setChecked.mockResolvedValueOnce({ checked: true, changed: false, matches_n: 1, match_level: 'exact', kind: 'checkbox' });
2317
+ const program = createProgram('', '');
2318
+ await program.parseAsync(['node', 'opencli', 'browser', 'check', '--role', 'checkbox', '--name', 'Accept']);
2319
+ expect(browserState.page.setChecked).toHaveBeenCalledWith('32', true, {});
2320
+ expect(lastJsonLog()).toEqual({ checked: true, changed: false, target: '32', matches_n: 1, match_level: 'exact', kind: 'checkbox' });
2321
+ });
2322
+ it('upload: treats the first positional as a file when using semantic locator flags', async () => {
2323
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-upload-semantic-'));
2324
+ const file = path.join(dir, 'receipt.pdf');
2325
+ fs.writeFileSync(file, 'pdf');
2326
+ browserState.page.evaluate.mockResolvedValueOnce({
2327
+ matches_n: 1,
2328
+ entries: [
2329
+ { nth: 0, ref: 33, tag: 'input', role: 'button', text: 'Upload receipt', attrs: {}, visible: true },
2330
+ ],
2331
+ });
2332
+ browserState.page.uploadFiles.mockResolvedValueOnce({
2333
+ uploaded: true,
2334
+ files: 1,
2335
+ file_names: ['receipt.pdf'],
2336
+ target: '33',
2337
+ matches_n: 1,
2338
+ match_level: 'exact',
2339
+ multiple: false,
2340
+ });
2341
+ const program = createProgram('', '');
2342
+ await program.parseAsync(['node', 'opencli', 'browser', 'upload', '--role', 'button', '--name', 'Upload receipt', file]);
2343
+ expect(browserState.page.uploadFiles).toHaveBeenCalledWith('33', [file], {});
2344
+ expect(lastJsonLog()).toMatchObject({ uploaded: true, target: '33', files: 1 });
2345
+ });
2346
+ it('type: treats the first positional as text when using semantic locator flags', async () => {
2347
+ browserState.page.evaluate
2348
+ .mockResolvedValueOnce({
2349
+ matches_n: 1,
2350
+ entries: [
2351
+ { nth: 0, ref: 34, tag: 'input', role: 'textbox', text: '', attrs: {}, visible: true },
2352
+ ],
2353
+ })
2354
+ .mockResolvedValueOnce(false);
2355
+ browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
2356
+ browserState.page.typeText.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
2357
+ const program = createProgram('', '');
2358
+ await program.parseAsync(['node', 'opencli', 'browser', 'type', '--label', 'Email', 'me@example.com']);
2359
+ expect(browserState.page.click).toHaveBeenCalledWith('34', {});
2360
+ expect(browserState.page.typeText).toHaveBeenCalledWith('34', 'me@example.com', {});
2361
+ expect(lastJsonLog()).toMatchObject({ typed: true, target: '34', text: 'me@example.com' });
2362
+ });
2363
+ it('fill: treats the first positional as text when using semantic locator flags', async () => {
2364
+ browserState.page.evaluate.mockResolvedValueOnce({
2365
+ matches_n: 1,
2366
+ entries: [
2367
+ { nth: 0, ref: 35, tag: 'input', role: 'textbox', text: '', attrs: {}, visible: true },
2368
+ ],
2369
+ });
2370
+ browserState.page.fillText.mockResolvedValueOnce({
2371
+ filled: true,
2372
+ verified: true,
2373
+ expected: 'me@example.com',
2374
+ actual: 'me@example.com',
2375
+ length: 14,
2376
+ matches_n: 1,
2377
+ match_level: 'exact',
2378
+ });
2379
+ const program = createProgram('', '');
2380
+ await program.parseAsync(['node', 'opencli', 'browser', 'fill', '--label', 'Email', 'me@example.com']);
2381
+ expect(browserState.page.fillText).toHaveBeenCalledWith('35', 'me@example.com', {});
2382
+ expect(lastJsonLog()).toMatchObject({ filled: true, verified: true, target: '35', text: 'me@example.com' });
2383
+ });
2384
+ it('drag: resolves source and target from prefixed semantic locators', async () => {
2385
+ browserState.page.evaluate
2386
+ .mockResolvedValueOnce({
2387
+ matches_n: 1,
2388
+ entries: [
2389
+ { nth: 0, ref: 40, tag: 'div', role: 'button', text: 'Card A', attrs: {}, visible: true },
2390
+ ],
2391
+ })
2392
+ .mockResolvedValueOnce({
2393
+ matches_n: 1,
2394
+ entries: [
2395
+ { nth: 0, ref: 41, tag: 'div', role: 'region', text: 'Done', attrs: {}, visible: true },
2396
+ ],
2397
+ });
2398
+ browserState.page.drag.mockResolvedValueOnce({
2399
+ dragged: true,
2400
+ source: '40',
2401
+ target: '41',
2402
+ source_matches_n: 1,
2403
+ target_matches_n: 1,
2404
+ source_match_level: 'exact',
2405
+ target_match_level: 'exact',
2406
+ });
2407
+ const program = createProgram('', '');
2408
+ await program.parseAsync([
2409
+ 'node',
2410
+ 'opencli',
2411
+ 'browser',
2412
+ 'drag',
2413
+ '--from-role',
2414
+ 'button',
2415
+ '--from-name',
2416
+ 'Card A',
2417
+ '--to-role',
2418
+ 'region',
2419
+ '--to-name',
2420
+ 'Done',
2421
+ ]);
2422
+ expect(browserState.page.drag).toHaveBeenCalledWith('40', '41', { from: {}, to: {} });
2423
+ expect(lastJsonLog()).toMatchObject({ dragged: true, source: '40', target: '41' });
2424
+ });
1820
2425
  it('surfaces match_level=stable when resolver falls back to fingerprint match', async () => {
1821
2426
  browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'stable' });
1822
2427
  const program = createProgram('', '');
@@ -1863,6 +2468,116 @@ describe('browser click/type commands', () => {
1863
2468
  expect(browserState.page.click).not.toHaveBeenCalled();
1864
2469
  expect(process.exitCode).toBeDefined();
1865
2470
  });
2471
+ it('hover: delegates to page.hover and emits a structured envelope', async () => {
2472
+ browserState.page.hover.mockResolvedValueOnce({ matches_n: 2, match_level: 'exact' });
2473
+ const program = createProgram('', '');
2474
+ await program.parseAsync(['node', 'opencli', 'browser', 'hover', '.menu', '--nth', '1']);
2475
+ expect(browserState.page.hover).toHaveBeenCalledWith('.menu', { nth: 1 });
2476
+ expect(lastJsonLog()).toEqual({ hovered: true, target: '.menu', matches_n: 2, match_level: 'exact' });
2477
+ });
2478
+ it('focus: delegates to page.focus and reports whether the element took focus', async () => {
2479
+ browserState.page.focus.mockResolvedValueOnce({ focused: true, matches_n: 1, match_level: 'stable' });
2480
+ const program = createProgram('', '');
2481
+ await program.parseAsync(['node', 'opencli', 'browser', 'focus', '7']);
2482
+ expect(browserState.page.focus).toHaveBeenCalledWith('7', {});
2483
+ expect(lastJsonLog()).toEqual({ focused: true, target: '7', matches_n: 1, match_level: 'stable' });
2484
+ });
2485
+ it('dblclick: delegates to page.dblClick and emits a structured envelope', async () => {
2486
+ browserState.page.dblClick.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
2487
+ const program = createProgram('', '');
2488
+ await program.parseAsync(['node', 'opencli', 'browser', 'dblclick', '#row']);
2489
+ expect(browserState.page.dblClick).toHaveBeenCalledWith('#row', {});
2490
+ expect(lastJsonLog()).toEqual({ dblclicked: true, target: '#row', matches_n: 1, match_level: 'exact' });
2491
+ });
2492
+ it('check: ensures target is checked through page.setChecked', async () => {
2493
+ browserState.page.setChecked.mockResolvedValueOnce({
2494
+ checked: true,
2495
+ changed: true,
2496
+ matches_n: 2,
2497
+ match_level: 'exact',
2498
+ kind: 'checkbox',
2499
+ });
2500
+ const program = createProgram('', '');
2501
+ await program.parseAsync(['node', 'opencli', 'browser', 'check', '.todo', '--nth', '1']);
2502
+ expect(browserState.page.setChecked).toHaveBeenCalledWith('.todo', true, { nth: 1 });
2503
+ expect(lastJsonLog()).toEqual({ checked: true, changed: true, target: '.todo', matches_n: 2, match_level: 'exact', kind: 'checkbox' });
2504
+ });
2505
+ it('uncheck: ensures target is unchecked through page.setChecked', async () => {
2506
+ browserState.page.setChecked.mockResolvedValueOnce({
2507
+ checked: false,
2508
+ changed: false,
2509
+ matches_n: 1,
2510
+ match_level: 'stable',
2511
+ kind: 'checkbox',
2512
+ });
2513
+ const program = createProgram('', '');
2514
+ await program.parseAsync(['node', 'opencli', 'browser', 'uncheck', '#subscribe']);
2515
+ expect(browserState.page.setChecked).toHaveBeenCalledWith('#subscribe', false, {});
2516
+ expect(lastJsonLog()).toEqual({ checked: false, changed: false, target: '#subscribe', matches_n: 1, match_level: 'stable', kind: 'checkbox' });
2517
+ });
2518
+ it('upload: validates local files and delegates to page.uploadFiles', async () => {
2519
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-upload-'));
2520
+ const file = path.join(dir, 'receipt.pdf');
2521
+ fs.writeFileSync(file, 'pdf');
2522
+ browserState.page.uploadFiles.mockResolvedValueOnce({
2523
+ uploaded: true,
2524
+ files: 1,
2525
+ file_names: ['receipt.pdf'],
2526
+ target: '#file',
2527
+ matches_n: 1,
2528
+ match_level: 'exact',
2529
+ multiple: false,
2530
+ });
2531
+ const program = createProgram('', '');
2532
+ await program.parseAsync(['node', 'opencli', 'browser', 'upload', '#file', file]);
2533
+ expect(browserState.page.uploadFiles).toHaveBeenCalledWith('#file', [file], {});
2534
+ expect(lastJsonLog()).toEqual({
2535
+ uploaded: true,
2536
+ files: 1,
2537
+ file_names: ['receipt.pdf'],
2538
+ target: '#file',
2539
+ matches_n: 1,
2540
+ match_level: 'exact',
2541
+ multiple: false,
2542
+ });
2543
+ });
2544
+ it('upload: rejects missing files before touching the page', async () => {
2545
+ const program = createProgram('', '');
2546
+ await program.parseAsync(['node', 'opencli', 'browser', 'upload', '#file', '/tmp/opencli-missing-file']);
2547
+ expect(lastJsonLog().error.code).toBe('file_not_found');
2548
+ expect(browserState.page.uploadFiles).not.toHaveBeenCalled();
2549
+ expect(process.exitCode).toBeDefined();
2550
+ });
2551
+ it('drag: delegates to page.drag and forwards source/target nth options', async () => {
2552
+ browserState.page.drag.mockResolvedValueOnce({
2553
+ dragged: true,
2554
+ source: '.card',
2555
+ target: '.lane',
2556
+ source_matches_n: 3,
2557
+ target_matches_n: 2,
2558
+ source_match_level: 'exact',
2559
+ target_match_level: 'stable',
2560
+ });
2561
+ const program = createProgram('', '');
2562
+ await program.parseAsync(['node', 'opencli', 'browser', 'drag', '.card', '.lane', '--from-nth', '2', '--to-nth', '1']);
2563
+ expect(browserState.page.drag).toHaveBeenCalledWith('.card', '.lane', { from: { nth: 2 }, to: { nth: 1 } });
2564
+ expect(lastJsonLog()).toEqual({
2565
+ dragged: true,
2566
+ source: '.card',
2567
+ target: '.lane',
2568
+ source_matches_n: 3,
2569
+ target_matches_n: 2,
2570
+ source_match_level: 'exact',
2571
+ target_match_level: 'stable',
2572
+ });
2573
+ });
2574
+ it('drag: rejects malformed --from-nth before touching the page', async () => {
2575
+ const program = createProgram('', '');
2576
+ await program.parseAsync(['node', 'opencli', 'browser', 'drag', '.card', '.lane', '--from-nth', 'abc']);
2577
+ expect(lastJsonLog().error.code).toBe('usage_error');
2578
+ expect(browserState.page.drag).not.toHaveBeenCalled();
2579
+ expect(process.exitCode).toBeDefined();
2580
+ });
1866
2581
  it('type: clicks, waits, then typeText — emits {typed, text, target, matches_n, match_level, autocomplete}', async () => {
1867
2582
  browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
1868
2583
  browserState.page.typeText.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
@@ -1996,6 +2711,21 @@ describe('browser select command', () => {
1996
2711
  expect(err.available).toEqual(['US', 'CA']);
1997
2712
  expect(process.exitCode).toBeDefined();
1998
2713
  });
2714
+ it('select: treats the first positional as option when using semantic locator flags', async () => {
2715
+ const evalMock = browserState.page.evaluate;
2716
+ evalMock
2717
+ .mockResolvedValueOnce({
2718
+ matches_n: 1,
2719
+ entries: [
2720
+ { nth: 0, ref: 36, tag: 'select', role: 'combobox', text: 'Country', attrs: {}, visible: true },
2721
+ ],
2722
+ })
2723
+ .mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' })
2724
+ .mockResolvedValueOnce({ selected: 'Uruguay' });
2725
+ const program = createProgram('', '');
2726
+ await program.parseAsync(['node', 'opencli', 'browser', 'select', '--label', 'Country', 'Uruguay']);
2727
+ expect(lastJsonLog()).toEqual({ selected: 'Uruguay', target: '36', matches_n: 1, match_level: 'exact' });
2728
+ });
1999
2729
  it('surfaces selector_ambiguous from the resolver before calling selectResolvedJs', async () => {
2000
2730
  browserState.page.evaluate.mockResolvedValueOnce({
2001
2731
  ok: false,