@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
@@ -337,6 +337,126 @@ 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).toMatchObject([
353
+ {
354
+ name: 'workspace',
355
+ flags: '--workspace <name>',
356
+ takes_value: 'required',
357
+ },
358
+ ]);
359
+ expect(data.global_options).toEqual(expect.arrayContaining([
360
+ expect.objectContaining({
361
+ name: 'version',
362
+ flags: '-V, --version',
363
+ }),
364
+ expect.objectContaining({
365
+ name: 'profile',
366
+ flags: '--profile <name>',
367
+ takes_value: 'required',
368
+ }),
369
+ ]));
370
+ const click = data.commands.find((cmd) => cmd.name === 'click');
371
+ expect(click).toMatchObject({
372
+ command: 'opencli browser click',
373
+ usage: 'opencli browser click [target] [options]',
374
+ positionals: [{ name: 'target' }],
375
+ });
376
+ expect(click.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
377
+ const tabList = data.commands.find((cmd) => cmd.name === 'tab list');
378
+ expect(tabList).toMatchObject({
379
+ command: 'opencli browser tab list',
380
+ usage: 'opencli browser tab list [options]',
381
+ command_options: [],
382
+ });
383
+ const getText = data.commands.find((cmd) => cmd.name === 'get text');
384
+ expect(getText).toMatchObject({
385
+ command: 'opencli browser get text',
386
+ positionals: [{ name: 'target' }],
387
+ });
388
+ expect(data.structured_help).toMatchObject({
389
+ formats: ['yaml', 'json'],
390
+ usage: 'opencli browser --help -f yaml',
391
+ });
392
+ }
393
+ finally {
394
+ process.argv = argv;
395
+ }
396
+ });
397
+ it('renders nested browser parent structured help for a subtree', () => {
398
+ const argv = process.argv;
399
+ try {
400
+ const program = createProgram('', '');
401
+ const browser = program.commands.find(cmd => cmd.name() === 'browser');
402
+ const tab = browser.commands.find(cmd => cmd.name() === 'tab');
403
+ expect(tab).toBeTruthy();
404
+ process.argv = ['node', 'opencli', 'browser', 'tab', '--help', '-f', 'yaml'];
405
+ const data = yaml.load(tab.helpInformation());
406
+ expect(data).toMatchObject({
407
+ namespace: 'browser',
408
+ group: 'tab',
409
+ command: 'opencli browser tab',
410
+ usage: 'opencli browser tab <command> [args] [options]',
411
+ command_count: 4,
412
+ });
413
+ expect(data.commands.map((cmd) => cmd.name)).toEqual([
414
+ 'tab close',
415
+ 'tab list',
416
+ 'tab new',
417
+ 'tab select',
418
+ ]);
419
+ expect(data.commands.find((cmd) => cmd.name === 'tab close')).toMatchObject({
420
+ command: 'opencli browser tab close',
421
+ usage: 'opencli browser tab close [targetId] [options]',
422
+ positionals: [{ name: 'targetId', help: 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"' }],
423
+ });
424
+ expect(data.namespace_options.map((option) => option.name)).toEqual(['workspace']);
425
+ expect(data.structured_help).toMatchObject({
426
+ usage: 'opencli browser tab --help -f yaml',
427
+ });
428
+ }
429
+ finally {
430
+ process.argv = argv;
431
+ }
432
+ });
433
+ it('renders browser command structured help without needing the full namespace dump', () => {
434
+ const argv = process.argv;
435
+ try {
436
+ const program = createProgram('', '');
437
+ const browser = program.commands.find(cmd => cmd.name() === 'browser');
438
+ const click = browser.commands.find(cmd => cmd.name() === 'click');
439
+ expect(click).toBeTruthy();
440
+ process.argv = ['node', 'opencli', 'browser', 'click', '--help', '-f', 'yaml'];
441
+ const data = yaml.load(click.helpInformation());
442
+ expect(data).toMatchObject({
443
+ namespace: 'browser',
444
+ name: 'click',
445
+ command: 'opencli browser click',
446
+ usage: 'opencli browser click [target] [options]',
447
+ positionals: [{ name: 'target' }],
448
+ structured_help: {
449
+ usage: 'opencli browser click --help -f yaml',
450
+ },
451
+ });
452
+ expect(data.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
453
+ expect(data.namespace_options.map((option) => option.name)).toEqual(['workspace']);
454
+ expect(data.global_options.map((option) => option.name)).toContain('profile');
455
+ }
456
+ finally {
457
+ process.argv = argv;
458
+ }
459
+ });
340
460
  });
341
461
  describe('resolveBrowserVerifyInvocation', () => {
342
462
  it('prefers the built entry declared in package metadata', () => {
@@ -636,7 +756,16 @@ describe('browser tab targeting commands', () => {
636
756
  { index: 0, frameId: 'frame-1', url: 'https://x.example/embed', name: 'x-embed' },
637
757
  ]),
638
758
  evaluateInFrame: vi.fn().mockResolvedValue('inside frame'),
759
+ screenshot: vi.fn().mockResolvedValue('base64-shot'),
760
+ annotatedScreenshot: vi.fn().mockResolvedValue('annotated-base64-shot'),
639
761
  readNetworkCapture: vi.fn().mockResolvedValue([]),
762
+ waitForDownload: vi.fn().mockResolvedValue({
763
+ downloaded: true,
764
+ filename: '/tmp/receipt.pdf',
765
+ url: 'https://app.example/receipt.pdf',
766
+ state: 'complete',
767
+ elapsedMs: 10,
768
+ }),
640
769
  };
641
770
  });
642
771
  function lastJsonLog() {
@@ -675,6 +804,69 @@ describe('browser tab targeting commands', () => {
675
804
  expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 30, workspace: 'bound:default' });
676
805
  expect(browserState.page?.snapshot).toHaveBeenCalled();
677
806
  });
807
+ it('passes the opt-in AX source to browser state', async () => {
808
+ const program = createProgram('', '');
809
+ await program.parseAsync(['node', 'opencli', 'browser', 'state', '--source', 'ax']);
810
+ expect(browserState.page?.snapshot).toHaveBeenCalledWith({ viewportExpand: 2000, source: 'ax' });
811
+ });
812
+ it('prints DOM vs AX snapshot metrics without changing default state output', async () => {
813
+ browserState.page = {
814
+ ...browserState.page,
815
+ snapshot: vi.fn(async (opts) => {
816
+ if (opts?.source === 'ax') {
817
+ return 'source: ax\n---\n[1]button "Save"\nframe "https://app.example/embed":\n [2]button "Frame Save"\n---\ninteractive: 2';
818
+ }
819
+ return 'URL: https://app.example\n[1] button "Save"';
820
+ }),
821
+ };
822
+ const program = createProgram('', '');
823
+ await program.parseAsync(['node', 'opencli', 'browser', 'state', '--compare-sources']);
824
+ expect(browserState.page?.snapshot).toHaveBeenCalledWith({ viewportExpand: 2000, source: 'dom' });
825
+ expect(browserState.page?.snapshot).toHaveBeenCalledWith({ viewportExpand: 2000, source: 'ax' });
826
+ const out = lastJsonLog();
827
+ expect(out.url).toBe('https://one.example');
828
+ expect(out.sources.dom).toMatchObject({ ok: true, refs: 1, frame_sections: 0 });
829
+ expect(out.sources.ax).toMatchObject({ ok: true, refs: 2, frame_sections: 1, interactive: 2 });
830
+ });
831
+ it('keeps compare-sources usable when one observation backend fails', async () => {
832
+ browserState.page = {
833
+ ...browserState.page,
834
+ snapshot: vi.fn(async (opts) => {
835
+ if (opts?.source === 'ax')
836
+ throw new Error('AX unavailable');
837
+ return '[1] button "Save"';
838
+ }),
839
+ };
840
+ const program = createProgram('', '');
841
+ await program.parseAsync(['node', 'opencli', 'browser', 'state', '--compare-sources']);
842
+ const out = lastJsonLog();
843
+ expect(out.sources.dom).toMatchObject({ ok: true, refs: 1 });
844
+ expect(out.sources.ax).toMatchObject({
845
+ ok: false,
846
+ error: { message: 'AX unavailable' },
847
+ });
848
+ });
849
+ it('rejects unknown browser state sources before touching the page', async () => {
850
+ const program = createProgram('', '');
851
+ await program.parseAsync(['node', 'opencli', 'browser', 'state', '--source', 'magic']);
852
+ expect(browserState.page?.snapshot).not.toHaveBeenCalled();
853
+ const out = lastJsonLog();
854
+ expect(out.error.code).toBe('invalid_source');
855
+ expect(process.exitCode).toBeDefined();
856
+ });
857
+ it('captures annotated screenshots through the visual ref overlay path', async () => {
858
+ const program = createProgram('', '');
859
+ await program.parseAsync(['node', 'opencli', 'browser', 'screenshot', '--annotate']);
860
+ expect(browserState.page?.annotatedScreenshot).toHaveBeenCalledWith({
861
+ fullPage: false,
862
+ annotate: true,
863
+ width: undefined,
864
+ height: undefined,
865
+ format: 'png',
866
+ });
867
+ expect(browserState.page?.screenshot).not.toHaveBeenCalled();
868
+ expect(consoleLogSpy).toHaveBeenLastCalledWith('annotated-base64-shot');
869
+ });
678
870
  it('blocks history navigation on bound workspaces unless explicitly allowed', async () => {
679
871
  browserState.page = {
680
872
  ...browserState.page,
@@ -1036,6 +1228,55 @@ describe('browser tab targeting commands', () => {
1036
1228
  expect(bufferReads).toBe(2);
1037
1229
  expect(out.matched.url).toBe('https://target.example/api/target');
1038
1230
  });
1231
+ it('browser wait download delegates to the Browser Bridge download observer', async () => {
1232
+ browserState.page = {
1233
+ goto: vi.fn().mockResolvedValue(undefined),
1234
+ wait: vi.fn().mockResolvedValue(undefined),
1235
+ waitForDownload: vi.fn().mockResolvedValue({
1236
+ downloaded: true,
1237
+ filename: '/tmp/receipt.pdf',
1238
+ url: 'https://app.example/receipt.pdf',
1239
+ state: 'complete',
1240
+ elapsedMs: 10,
1241
+ }),
1242
+ setActivePage: vi.fn(),
1243
+ getActivePage: vi.fn().mockReturnValue('tab-1'),
1244
+ getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'),
1245
+ tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]),
1246
+ };
1247
+ const program = createProgram('', '');
1248
+ await program.parseAsync(['node', 'opencli', 'browser', 'wait', 'download', 'receipt', '--timeout', '900']);
1249
+ expect(browserState.page?.waitForDownload).toHaveBeenCalledWith('receipt', 900);
1250
+ expect(lastJsonLog()).toEqual({
1251
+ downloaded: true,
1252
+ filename: '/tmp/receipt.pdf',
1253
+ url: 'https://app.example/receipt.pdf',
1254
+ state: 'complete',
1255
+ elapsedMs: 10,
1256
+ });
1257
+ });
1258
+ it('browser wait download reports an error envelope when no matching download completes', async () => {
1259
+ browserState.page = {
1260
+ goto: vi.fn().mockResolvedValue(undefined),
1261
+ wait: vi.fn().mockResolvedValue(undefined),
1262
+ waitForDownload: vi.fn().mockResolvedValue({
1263
+ downloaded: false,
1264
+ state: 'interrupted',
1265
+ error: 'No download matched "receipt" within 900ms',
1266
+ elapsedMs: 900,
1267
+ }),
1268
+ setActivePage: vi.fn(),
1269
+ getActivePage: vi.fn().mockReturnValue('tab-1'),
1270
+ getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'),
1271
+ tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]),
1272
+ };
1273
+ const program = createProgram('', '');
1274
+ await program.parseAsync(['node', 'opencli', 'browser', 'wait', 'download', 'receipt', '--timeout', '900']);
1275
+ const out = lastJsonLog();
1276
+ expect(out.error.code).toBe('download_not_seen');
1277
+ expect(out.download.elapsedMs).toBe(900);
1278
+ expect(process.exitCode).toBeDefined();
1279
+ });
1039
1280
  });
1040
1281
  describe('browser network command', () => {
1041
1282
  const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
@@ -1687,6 +1928,25 @@ describe('browser find command', () => {
1687
1928
  expect(out.entries[1].ref).toBe(17);
1688
1929
  expect(process.exitCode).toBeUndefined();
1689
1930
  });
1931
+ it('finds elements by semantic role/name without requiring CSS', async () => {
1932
+ browserState.page.evaluate.mockResolvedValueOnce({
1933
+ matches_n: 1,
1934
+ entries: [
1935
+ { nth: 0, ref: 9, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
1936
+ ],
1937
+ });
1938
+ const program = createProgram('', '');
1939
+ await program.parseAsync(['node', 'opencli', 'browser', 'find', '--role', 'button', '--name', 'Save']);
1940
+ const js = browserState.page.evaluate.mock.calls[0][0];
1941
+ expect(js).toContain('CRITERIA');
1942
+ expect(js).toContain('function accessibleName');
1943
+ expect(lastJsonLog()).toEqual({
1944
+ matches_n: 1,
1945
+ entries: [
1946
+ { nth: 0, ref: 9, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
1947
+ ],
1948
+ });
1949
+ });
1690
1950
  it('forwards --limit / --text-max into the generated JS', async () => {
1691
1951
  browserState.page.evaluate.mockResolvedValueOnce({ matches_n: 0, entries: [] });
1692
1952
  const program = createProgram('', '');
@@ -1742,6 +2002,40 @@ describe('browser get text/value/attributes commands', () => {
1742
2002
  await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '7']);
1743
2003
  expect(lastJsonLog()).toEqual({ value: 'Hello world', matches_n: 1, match_level: 'exact' });
1744
2004
  });
2005
+ it('resolves a semantic locator to a ref before get text', async () => {
2006
+ const evalMock = browserState.page.evaluate;
2007
+ evalMock.mockResolvedValueOnce({
2008
+ matches_n: 1,
2009
+ entries: [
2010
+ { nth: 0, ref: 12, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
2011
+ ],
2012
+ });
2013
+ evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
2014
+ evalMock.mockResolvedValueOnce('Save');
2015
+ const program = createProgram('', '');
2016
+ await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '--role', 'button', '--name', 'Save']);
2017
+ expect(evalMock.mock.calls[0][0]).toContain('function accessibleName');
2018
+ expect(evalMock.mock.calls[1][0]).toContain('const ref = "12"');
2019
+ expect(lastJsonLog()).toEqual({ value: 'Save', matches_n: 1, match_level: 'exact' });
2020
+ });
2021
+ it('reports total_matches when semantic get reads the first of multiple matches', async () => {
2022
+ const evalMock = browserState.page.evaluate;
2023
+ evalMock.mockResolvedValueOnce({
2024
+ matches_n: 3,
2025
+ entries: [
2026
+ { nth: 0, ref: 12, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
2027
+ { nth: 1, ref: 13, tag: 'button', role: 'button', text: 'Save draft', attrs: {}, visible: true },
2028
+ { nth: 2, ref: 14, tag: 'button', role: 'button', text: 'Save copy', attrs: {}, visible: true },
2029
+ ],
2030
+ });
2031
+ evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
2032
+ evalMock.mockResolvedValueOnce('Save');
2033
+ const program = createProgram('', '');
2034
+ await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '--role', 'button', '--name', 'Save']);
2035
+ expect(evalMock.mock.calls[0][0]).toContain('const LIMIT = 6');
2036
+ expect(evalMock.mock.calls[1][0]).toContain('const ref = "12"');
2037
+ expect(lastJsonLog()).toEqual({ value: 'Save', matches_n: 1, match_level: 'exact', total_matches: 3 });
2038
+ });
1745
2039
  it('reports matches_n on multi-match CSS (read path: first match wins)', async () => {
1746
2040
  const evalMock = browserState.page.evaluate;
1747
2041
  evalMock.mockResolvedValueOnce({ ok: true, matches_n: 3, match_level: 'exact' });
@@ -1797,6 +2091,28 @@ describe('browser click/type commands', () => {
1797
2091
  const { lastJsonLog } = installSelectorFirstTestHarness('click-type', () => ({
1798
2092
  evaluate: vi.fn().mockResolvedValue(false),
1799
2093
  click: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
2094
+ dblClick: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
2095
+ hover: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
2096
+ focus: vi.fn().mockResolvedValue({ focused: true, matches_n: 1, match_level: 'exact' }),
2097
+ setChecked: vi.fn().mockResolvedValue({ checked: true, changed: true, matches_n: 1, match_level: 'exact', kind: 'checkbox' }),
2098
+ uploadFiles: vi.fn().mockResolvedValue({
2099
+ uploaded: true,
2100
+ files: 1,
2101
+ file_names: ['receipt.pdf'],
2102
+ target: '#file',
2103
+ matches_n: 1,
2104
+ match_level: 'exact',
2105
+ multiple: false,
2106
+ }),
2107
+ drag: vi.fn().mockResolvedValue({
2108
+ dragged: true,
2109
+ source: '#card',
2110
+ target: '#lane',
2111
+ source_matches_n: 1,
2112
+ target_matches_n: 1,
2113
+ source_match_level: 'exact',
2114
+ target_match_level: 'exact',
2115
+ }),
1800
2116
  typeText: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
1801
2117
  fillText: vi.fn().mockResolvedValue({
1802
2118
  filled: true,
@@ -1817,6 +2133,163 @@ describe('browser click/type commands', () => {
1817
2133
  expect(browserState.page.click).toHaveBeenCalledWith('#save', {});
1818
2134
  expect(lastJsonLog()).toEqual({ clicked: true, target: '#save', matches_n: 1, match_level: 'exact' });
1819
2135
  });
2136
+ it('clicks a unique semantic locator without a prior state call', async () => {
2137
+ browserState.page.evaluate.mockResolvedValueOnce({
2138
+ matches_n: 1,
2139
+ entries: [
2140
+ { nth: 0, ref: 23, tag: 'button', role: 'button', text: 'Submit', attrs: {}, visible: true },
2141
+ ],
2142
+ });
2143
+ browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
2144
+ const program = createProgram('', '');
2145
+ await program.parseAsync(['node', 'opencli', 'browser', 'click', '--role', 'button', '--name', 'Submit']);
2146
+ expect(browserState.page.click).toHaveBeenCalledWith('23', {});
2147
+ expect(lastJsonLog()).toEqual({ clicked: true, target: '23', matches_n: 1, match_level: 'exact' });
2148
+ });
2149
+ it('rejects ambiguous semantic locators before write actions', async () => {
2150
+ browserState.page.evaluate.mockResolvedValueOnce({
2151
+ matches_n: 2,
2152
+ entries: [
2153
+ { nth: 0, ref: 1, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
2154
+ { nth: 1, ref: 2, tag: 'button', role: 'button', text: 'Save draft', attrs: {}, visible: true },
2155
+ ],
2156
+ });
2157
+ const program = createProgram('', '');
2158
+ await program.parseAsync(['node', 'opencli', 'browser', 'click', '--role', 'button', '--name', 'Save']);
2159
+ const err = lastJsonLog().error;
2160
+ expect(err.code).toBe('semantic_ambiguous');
2161
+ expect(err.matches_n).toBe(2);
2162
+ expect(browserState.page.click).not.toHaveBeenCalled();
2163
+ expect(process.exitCode).toBeDefined();
2164
+ });
2165
+ it('hover: resolves a semantic locator before moving the mouse', async () => {
2166
+ browserState.page.evaluate.mockResolvedValueOnce({
2167
+ matches_n: 1,
2168
+ entries: [
2169
+ { nth: 0, ref: 31, tag: 'button', role: 'button', text: 'Settings', attrs: {}, visible: true },
2170
+ ],
2171
+ });
2172
+ const program = createProgram('', '');
2173
+ await program.parseAsync(['node', 'opencli', 'browser', 'hover', '--role', 'button', '--name', 'Settings']);
2174
+ expect(browserState.page.hover).toHaveBeenCalledWith('31', {});
2175
+ expect(lastJsonLog()).toEqual({ hovered: true, target: '31', matches_n: 1, match_level: 'exact' });
2176
+ });
2177
+ it('check: resolves a semantic locator before setting checked state', async () => {
2178
+ browserState.page.evaluate.mockResolvedValueOnce({
2179
+ matches_n: 1,
2180
+ entries: [
2181
+ { nth: 0, ref: 32, tag: 'input', role: 'checkbox', text: 'Accept', attrs: {}, visible: true },
2182
+ ],
2183
+ });
2184
+ browserState.page.setChecked.mockResolvedValueOnce({ checked: true, changed: false, matches_n: 1, match_level: 'exact', kind: 'checkbox' });
2185
+ const program = createProgram('', '');
2186
+ await program.parseAsync(['node', 'opencli', 'browser', 'check', '--role', 'checkbox', '--name', 'Accept']);
2187
+ expect(browserState.page.setChecked).toHaveBeenCalledWith('32', true, {});
2188
+ expect(lastJsonLog()).toEqual({ checked: true, changed: false, target: '32', matches_n: 1, match_level: 'exact', kind: 'checkbox' });
2189
+ });
2190
+ it('upload: treats the first positional as a file when using semantic locator flags', async () => {
2191
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-upload-semantic-'));
2192
+ const file = path.join(dir, 'receipt.pdf');
2193
+ fs.writeFileSync(file, 'pdf');
2194
+ browserState.page.evaluate.mockResolvedValueOnce({
2195
+ matches_n: 1,
2196
+ entries: [
2197
+ { nth: 0, ref: 33, tag: 'input', role: 'button', text: 'Upload receipt', attrs: {}, visible: true },
2198
+ ],
2199
+ });
2200
+ browserState.page.uploadFiles.mockResolvedValueOnce({
2201
+ uploaded: true,
2202
+ files: 1,
2203
+ file_names: ['receipt.pdf'],
2204
+ target: '33',
2205
+ matches_n: 1,
2206
+ match_level: 'exact',
2207
+ multiple: false,
2208
+ });
2209
+ const program = createProgram('', '');
2210
+ await program.parseAsync(['node', 'opencli', 'browser', 'upload', '--role', 'button', '--name', 'Upload receipt', file]);
2211
+ expect(browserState.page.uploadFiles).toHaveBeenCalledWith('33', [file], {});
2212
+ expect(lastJsonLog()).toMatchObject({ uploaded: true, target: '33', files: 1 });
2213
+ });
2214
+ it('type: treats the first positional as text when using semantic locator flags', async () => {
2215
+ browserState.page.evaluate
2216
+ .mockResolvedValueOnce({
2217
+ matches_n: 1,
2218
+ entries: [
2219
+ { nth: 0, ref: 34, tag: 'input', role: 'textbox', text: '', attrs: {}, visible: true },
2220
+ ],
2221
+ })
2222
+ .mockResolvedValueOnce(false);
2223
+ browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
2224
+ browserState.page.typeText.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
2225
+ const program = createProgram('', '');
2226
+ await program.parseAsync(['node', 'opencli', 'browser', 'type', '--label', 'Email', 'me@example.com']);
2227
+ expect(browserState.page.click).toHaveBeenCalledWith('34', {});
2228
+ expect(browserState.page.typeText).toHaveBeenCalledWith('34', 'me@example.com', {});
2229
+ expect(lastJsonLog()).toMatchObject({ typed: true, target: '34', text: 'me@example.com' });
2230
+ });
2231
+ it('fill: treats the first positional as text when using semantic locator flags', async () => {
2232
+ browserState.page.evaluate.mockResolvedValueOnce({
2233
+ matches_n: 1,
2234
+ entries: [
2235
+ { nth: 0, ref: 35, tag: 'input', role: 'textbox', text: '', attrs: {}, visible: true },
2236
+ ],
2237
+ });
2238
+ browserState.page.fillText.mockResolvedValueOnce({
2239
+ filled: true,
2240
+ verified: true,
2241
+ expected: 'me@example.com',
2242
+ actual: 'me@example.com',
2243
+ length: 14,
2244
+ matches_n: 1,
2245
+ match_level: 'exact',
2246
+ });
2247
+ const program = createProgram('', '');
2248
+ await program.parseAsync(['node', 'opencli', 'browser', 'fill', '--label', 'Email', 'me@example.com']);
2249
+ expect(browserState.page.fillText).toHaveBeenCalledWith('35', 'me@example.com', {});
2250
+ expect(lastJsonLog()).toMatchObject({ filled: true, verified: true, target: '35', text: 'me@example.com' });
2251
+ });
2252
+ it('drag: resolves source and target from prefixed semantic locators', async () => {
2253
+ browserState.page.evaluate
2254
+ .mockResolvedValueOnce({
2255
+ matches_n: 1,
2256
+ entries: [
2257
+ { nth: 0, ref: 40, tag: 'div', role: 'button', text: 'Card A', attrs: {}, visible: true },
2258
+ ],
2259
+ })
2260
+ .mockResolvedValueOnce({
2261
+ matches_n: 1,
2262
+ entries: [
2263
+ { nth: 0, ref: 41, tag: 'div', role: 'region', text: 'Done', attrs: {}, visible: true },
2264
+ ],
2265
+ });
2266
+ browserState.page.drag.mockResolvedValueOnce({
2267
+ dragged: true,
2268
+ source: '40',
2269
+ target: '41',
2270
+ source_matches_n: 1,
2271
+ target_matches_n: 1,
2272
+ source_match_level: 'exact',
2273
+ target_match_level: 'exact',
2274
+ });
2275
+ const program = createProgram('', '');
2276
+ await program.parseAsync([
2277
+ 'node',
2278
+ 'opencli',
2279
+ 'browser',
2280
+ 'drag',
2281
+ '--from-role',
2282
+ 'button',
2283
+ '--from-name',
2284
+ 'Card A',
2285
+ '--to-role',
2286
+ 'region',
2287
+ '--to-name',
2288
+ 'Done',
2289
+ ]);
2290
+ expect(browserState.page.drag).toHaveBeenCalledWith('40', '41', { from: {}, to: {} });
2291
+ expect(lastJsonLog()).toMatchObject({ dragged: true, source: '40', target: '41' });
2292
+ });
1820
2293
  it('surfaces match_level=stable when resolver falls back to fingerprint match', async () => {
1821
2294
  browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'stable' });
1822
2295
  const program = createProgram('', '');
@@ -1863,6 +2336,116 @@ describe('browser click/type commands', () => {
1863
2336
  expect(browserState.page.click).not.toHaveBeenCalled();
1864
2337
  expect(process.exitCode).toBeDefined();
1865
2338
  });
2339
+ it('hover: delegates to page.hover and emits a structured envelope', async () => {
2340
+ browserState.page.hover.mockResolvedValueOnce({ matches_n: 2, match_level: 'exact' });
2341
+ const program = createProgram('', '');
2342
+ await program.parseAsync(['node', 'opencli', 'browser', 'hover', '.menu', '--nth', '1']);
2343
+ expect(browserState.page.hover).toHaveBeenCalledWith('.menu', { nth: 1 });
2344
+ expect(lastJsonLog()).toEqual({ hovered: true, target: '.menu', matches_n: 2, match_level: 'exact' });
2345
+ });
2346
+ it('focus: delegates to page.focus and reports whether the element took focus', async () => {
2347
+ browserState.page.focus.mockResolvedValueOnce({ focused: true, matches_n: 1, match_level: 'stable' });
2348
+ const program = createProgram('', '');
2349
+ await program.parseAsync(['node', 'opencli', 'browser', 'focus', '7']);
2350
+ expect(browserState.page.focus).toHaveBeenCalledWith('7', {});
2351
+ expect(lastJsonLog()).toEqual({ focused: true, target: '7', matches_n: 1, match_level: 'stable' });
2352
+ });
2353
+ it('dblclick: delegates to page.dblClick and emits a structured envelope', async () => {
2354
+ browserState.page.dblClick.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
2355
+ const program = createProgram('', '');
2356
+ await program.parseAsync(['node', 'opencli', 'browser', 'dblclick', '#row']);
2357
+ expect(browserState.page.dblClick).toHaveBeenCalledWith('#row', {});
2358
+ expect(lastJsonLog()).toEqual({ dblclicked: true, target: '#row', matches_n: 1, match_level: 'exact' });
2359
+ });
2360
+ it('check: ensures target is checked through page.setChecked', async () => {
2361
+ browserState.page.setChecked.mockResolvedValueOnce({
2362
+ checked: true,
2363
+ changed: true,
2364
+ matches_n: 2,
2365
+ match_level: 'exact',
2366
+ kind: 'checkbox',
2367
+ });
2368
+ const program = createProgram('', '');
2369
+ await program.parseAsync(['node', 'opencli', 'browser', 'check', '.todo', '--nth', '1']);
2370
+ expect(browserState.page.setChecked).toHaveBeenCalledWith('.todo', true, { nth: 1 });
2371
+ expect(lastJsonLog()).toEqual({ checked: true, changed: true, target: '.todo', matches_n: 2, match_level: 'exact', kind: 'checkbox' });
2372
+ });
2373
+ it('uncheck: ensures target is unchecked through page.setChecked', async () => {
2374
+ browserState.page.setChecked.mockResolvedValueOnce({
2375
+ checked: false,
2376
+ changed: false,
2377
+ matches_n: 1,
2378
+ match_level: 'stable',
2379
+ kind: 'checkbox',
2380
+ });
2381
+ const program = createProgram('', '');
2382
+ await program.parseAsync(['node', 'opencli', 'browser', 'uncheck', '#subscribe']);
2383
+ expect(browserState.page.setChecked).toHaveBeenCalledWith('#subscribe', false, {});
2384
+ expect(lastJsonLog()).toEqual({ checked: false, changed: false, target: '#subscribe', matches_n: 1, match_level: 'stable', kind: 'checkbox' });
2385
+ });
2386
+ it('upload: validates local files and delegates to page.uploadFiles', async () => {
2387
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-upload-'));
2388
+ const file = path.join(dir, 'receipt.pdf');
2389
+ fs.writeFileSync(file, 'pdf');
2390
+ browserState.page.uploadFiles.mockResolvedValueOnce({
2391
+ uploaded: true,
2392
+ files: 1,
2393
+ file_names: ['receipt.pdf'],
2394
+ target: '#file',
2395
+ matches_n: 1,
2396
+ match_level: 'exact',
2397
+ multiple: false,
2398
+ });
2399
+ const program = createProgram('', '');
2400
+ await program.parseAsync(['node', 'opencli', 'browser', 'upload', '#file', file]);
2401
+ expect(browserState.page.uploadFiles).toHaveBeenCalledWith('#file', [file], {});
2402
+ expect(lastJsonLog()).toEqual({
2403
+ uploaded: true,
2404
+ files: 1,
2405
+ file_names: ['receipt.pdf'],
2406
+ target: '#file',
2407
+ matches_n: 1,
2408
+ match_level: 'exact',
2409
+ multiple: false,
2410
+ });
2411
+ });
2412
+ it('upload: rejects missing files before touching the page', async () => {
2413
+ const program = createProgram('', '');
2414
+ await program.parseAsync(['node', 'opencli', 'browser', 'upload', '#file', '/tmp/opencli-missing-file']);
2415
+ expect(lastJsonLog().error.code).toBe('file_not_found');
2416
+ expect(browserState.page.uploadFiles).not.toHaveBeenCalled();
2417
+ expect(process.exitCode).toBeDefined();
2418
+ });
2419
+ it('drag: delegates to page.drag and forwards source/target nth options', async () => {
2420
+ browserState.page.drag.mockResolvedValueOnce({
2421
+ dragged: true,
2422
+ source: '.card',
2423
+ target: '.lane',
2424
+ source_matches_n: 3,
2425
+ target_matches_n: 2,
2426
+ source_match_level: 'exact',
2427
+ target_match_level: 'stable',
2428
+ });
2429
+ const program = createProgram('', '');
2430
+ await program.parseAsync(['node', 'opencli', 'browser', 'drag', '.card', '.lane', '--from-nth', '2', '--to-nth', '1']);
2431
+ expect(browserState.page.drag).toHaveBeenCalledWith('.card', '.lane', { from: { nth: 2 }, to: { nth: 1 } });
2432
+ expect(lastJsonLog()).toEqual({
2433
+ dragged: true,
2434
+ source: '.card',
2435
+ target: '.lane',
2436
+ source_matches_n: 3,
2437
+ target_matches_n: 2,
2438
+ source_match_level: 'exact',
2439
+ target_match_level: 'stable',
2440
+ });
2441
+ });
2442
+ it('drag: rejects malformed --from-nth before touching the page', async () => {
2443
+ const program = createProgram('', '');
2444
+ await program.parseAsync(['node', 'opencli', 'browser', 'drag', '.card', '.lane', '--from-nth', 'abc']);
2445
+ expect(lastJsonLog().error.code).toBe('usage_error');
2446
+ expect(browserState.page.drag).not.toHaveBeenCalled();
2447
+ expect(process.exitCode).toBeDefined();
2448
+ });
1866
2449
  it('type: clicks, waits, then typeText — emits {typed, text, target, matches_n, match_level, autocomplete}', async () => {
1867
2450
  browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
1868
2451
  browserState.page.typeText.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
@@ -1996,6 +2579,21 @@ describe('browser select command', () => {
1996
2579
  expect(err.available).toEqual(['US', 'CA']);
1997
2580
  expect(process.exitCode).toBeDefined();
1998
2581
  });
2582
+ it('select: treats the first positional as option when using semantic locator flags', async () => {
2583
+ const evalMock = browserState.page.evaluate;
2584
+ evalMock
2585
+ .mockResolvedValueOnce({
2586
+ matches_n: 1,
2587
+ entries: [
2588
+ { nth: 0, ref: 36, tag: 'select', role: 'combobox', text: 'Country', attrs: {}, visible: true },
2589
+ ],
2590
+ })
2591
+ .mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' })
2592
+ .mockResolvedValueOnce({ selected: 'Uruguay' });
2593
+ const program = createProgram('', '');
2594
+ await program.parseAsync(['node', 'opencli', 'browser', 'select', '--label', 'Country', 'Uruguay']);
2595
+ expect(lastJsonLog()).toEqual({ selected: 'Uruguay', target: '36', matches_n: 1, match_level: 'exact' });
2596
+ });
1999
2597
  it('surfaces selector_ambiguous from the resolver before calling selectResolvedJs', async () => {
2000
2598
  browserState.page.evaluate.mockResolvedValueOnce({
2001
2599
  ok: false,