@jackwener/opencli 1.7.13 → 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 (97) hide show
  1. package/cli-manifest.json +326 -44
  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 +167 -0
  41. package/clis/twitter/quote.test.js +194 -0
  42. package/clis/twitter/reply.js +24 -178
  43. package/clis/twitter/reply.test.js +29 -11
  44. package/clis/twitter/retweet.js +94 -0
  45. package/clis/twitter/retweet.test.js +73 -0
  46. package/clis/twitter/search.js +175 -23
  47. package/clis/twitter/search.test.js +266 -1
  48. package/clis/twitter/shared.js +81 -0
  49. package/clis/twitter/shared.test.js +134 -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 +80 -0
  56. package/clis/twitter/unlike.test.js +75 -0
  57. package/clis/twitter/unretweet.js +94 -0
  58. package/clis/twitter/unretweet.test.js +73 -0
  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/bridge.js +47 -45
  69. package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
  70. package/dist/src/browser/cdp-click-fixture.test.js +87 -0
  71. package/dist/src/browser/cdp.js +5 -0
  72. package/dist/src/browser/cdp.test.js +1 -0
  73. package/dist/src/browser/daemon-client.d.ts +3 -1
  74. package/dist/src/browser/find.d.ts +9 -1
  75. package/dist/src/browser/find.js +219 -0
  76. package/dist/src/browser/find.test.js +61 -1
  77. package/dist/src/browser/page.d.ts +2 -1
  78. package/dist/src/browser/page.js +13 -0
  79. package/dist/src/browser/page.test.js +28 -0
  80. package/dist/src/browser/target-errors.d.ts +3 -1
  81. package/dist/src/browser/target-errors.js +2 -0
  82. package/dist/src/browser/target-resolver.d.ts +14 -0
  83. package/dist/src/browser/target-resolver.js +28 -0
  84. package/dist/src/browser/visual-refs.d.ts +11 -0
  85. package/dist/src/browser/visual-refs.js +108 -0
  86. package/dist/src/browser.test.js +18 -0
  87. package/dist/src/build-manifest.d.ts +23 -0
  88. package/dist/src/build-manifest.js +34 -0
  89. package/dist/src/build-manifest.test.js +108 -1
  90. package/dist/src/cli.js +560 -58
  91. package/dist/src/cli.test.js +689 -1
  92. package/dist/src/commanderAdapter.js +23 -4
  93. package/dist/src/help.d.ts +36 -0
  94. package/dist/src/help.js +301 -5
  95. package/dist/src/types.d.ts +82 -0
  96. package/package.json +1 -1
  97. package/scripts/typed-error-lint-baseline.json +18 -18
@@ -223,6 +223,7 @@ describe('createProgram root help descriptions', () => {
223
223
  strategy: Strategy.PUBLIC,
224
224
  browser: false,
225
225
  args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }],
226
+ columns: ['title', 'url'],
226
227
  });
227
228
  const program = createProgram('', '');
228
229
  const site = program.commands.find(cmd => cmd.name() === 'bilibili');
@@ -235,10 +236,13 @@ describe('createProgram root help descriptions', () => {
235
236
  name: 'hot',
236
237
  access: 'read',
237
238
  description: 'Bilibili hot videos',
239
+ browser: false,
238
240
  example: 'opencli bilibili hot -f yaml',
239
- args: [{ name: 'limit', type: 'int', default: 20 }],
241
+ command_options: [{ name: 'limit', type: 'int', default: 20 }],
242
+ columns: ['title', 'url'],
240
243
  },
241
244
  ]);
245
+ expect(data.commands[0]).not.toHaveProperty('args');
242
246
  }
243
247
  finally {
244
248
  process.argv = argv;
@@ -247,6 +251,212 @@ describe('createProgram root help descriptions', () => {
247
251
  registry.set(key, value);
248
252
  }
249
253
  });
254
+ it('renders per-site text help without per-command common option noise', () => {
255
+ const registry = getRegistry();
256
+ const snapshot = new Map(registry);
257
+ registry.clear();
258
+ try {
259
+ cli({
260
+ site: 'bilibili',
261
+ name: 'hot',
262
+ access: 'read',
263
+ description: 'Bilibili hot videos',
264
+ strategy: Strategy.PUBLIC,
265
+ browser: false,
266
+ args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }],
267
+ });
268
+ cli({
269
+ site: 'bilibili',
270
+ name: 'video',
271
+ access: 'read',
272
+ description: 'Read one video',
273
+ domain: 'www.bilibili.com',
274
+ strategy: Strategy.PUBLIC,
275
+ browser: true,
276
+ args: [{ name: 'bvid', positional: true, required: true, help: 'Video id' }],
277
+ });
278
+ const program = createProgram('', '');
279
+ const site = program.commands.find(cmd => cmd.name() === 'bilibili');
280
+ expect(site).toBeTruthy();
281
+ const help = site.helpInformation();
282
+ expect(help).toContain('hot [options] [read] Bilibili hot videos');
283
+ expect(help).toContain('video <bvid> [read] Read one video');
284
+ expect(help).toContain('hot [options]');
285
+ expect(help).not.toContain('video <bvid> [options]');
286
+ expect(help).not.toContain('\nOptions:');
287
+ expect(help).toContain('Common options:');
288
+ expect(help).toContain('-f, --format <fmt>');
289
+ expect(help).toContain('--trace <mode>');
290
+ expect(help).toContain('get all command args/options in one structured response');
291
+ }
292
+ finally {
293
+ registry.clear();
294
+ for (const [key, value] of snapshot)
295
+ registry.set(key, value);
296
+ }
297
+ });
298
+ it('separates command args from common options in structured help', () => {
299
+ const registry = getRegistry();
300
+ const snapshot = new Map(registry);
301
+ const argv = process.argv;
302
+ registry.clear();
303
+ try {
304
+ cli({
305
+ site: 'bilibili',
306
+ name: 'video',
307
+ access: 'read',
308
+ description: 'Read one video',
309
+ strategy: Strategy.PUBLIC,
310
+ domain: 'www.bilibili.com',
311
+ browser: true,
312
+ args: [
313
+ { name: 'bvid', positional: true, required: true, help: 'Video id' },
314
+ { name: 'with-comments', type: 'boolean', default: false, help: 'Include comments' },
315
+ ],
316
+ columns: ['title', 'url'],
317
+ });
318
+ const program = createProgram('', '');
319
+ const site = program.commands.find(cmd => cmd.name() === 'bilibili');
320
+ const command = site.commands.find(cmd => cmd.name() === 'video');
321
+ expect(command).toBeTruthy();
322
+ process.argv = ['node', 'opencli', 'bilibili', 'video', '--help', '-f', 'yaml'];
323
+ const data = yaml.load(command.helpInformation());
324
+ expect(data.usage).toBe('opencli bilibili video <bvid> [options]');
325
+ expect(data.browser).toBe(true);
326
+ expect(data.domain).toBe('www.bilibili.com');
327
+ expect(data.positionals).toMatchObject([{ name: 'bvid', positional: true, required: true }]);
328
+ expect(data.command_options).toMatchObject([{ name: 'with-comments', default: false }]);
329
+ expect(data.common_options.map((option) => option.name)).toEqual(['format', 'trace', 'verbose', 'help']);
330
+ expect(data.columns).toEqual(['title', 'url']);
331
+ expect(data).not.toHaveProperty('args');
332
+ }
333
+ finally {
334
+ process.argv = argv;
335
+ registry.clear();
336
+ for (const [key, value] of snapshot)
337
+ registry.set(key, value);
338
+ }
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
+ });
250
460
  });
251
461
  describe('resolveBrowserVerifyInvocation', () => {
252
462
  it('prefers the built entry declared in package metadata', () => {
@@ -546,7 +756,16 @@ describe('browser tab targeting commands', () => {
546
756
  { index: 0, frameId: 'frame-1', url: 'https://x.example/embed', name: 'x-embed' },
547
757
  ]),
548
758
  evaluateInFrame: vi.fn().mockResolvedValue('inside frame'),
759
+ screenshot: vi.fn().mockResolvedValue('base64-shot'),
760
+ annotatedScreenshot: vi.fn().mockResolvedValue('annotated-base64-shot'),
549
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
+ }),
550
769
  };
551
770
  });
552
771
  function lastJsonLog() {
@@ -585,6 +804,69 @@ describe('browser tab targeting commands', () => {
585
804
  expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 30, workspace: 'bound:default' });
586
805
  expect(browserState.page?.snapshot).toHaveBeenCalled();
587
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
+ });
588
870
  it('blocks history navigation on bound workspaces unless explicitly allowed', async () => {
589
871
  browserState.page = {
590
872
  ...browserState.page,
@@ -946,6 +1228,55 @@ describe('browser tab targeting commands', () => {
946
1228
  expect(bufferReads).toBe(2);
947
1229
  expect(out.matched.url).toBe('https://target.example/api/target');
948
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
+ });
949
1280
  });
950
1281
  describe('browser network command', () => {
951
1282
  const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
@@ -1597,6 +1928,25 @@ describe('browser find command', () => {
1597
1928
  expect(out.entries[1].ref).toBe(17);
1598
1929
  expect(process.exitCode).toBeUndefined();
1599
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
+ });
1600
1950
  it('forwards --limit / --text-max into the generated JS', async () => {
1601
1951
  browserState.page.evaluate.mockResolvedValueOnce({ matches_n: 0, entries: [] });
1602
1952
  const program = createProgram('', '');
@@ -1652,6 +2002,40 @@ describe('browser get text/value/attributes commands', () => {
1652
2002
  await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '7']);
1653
2003
  expect(lastJsonLog()).toEqual({ value: 'Hello world', matches_n: 1, match_level: 'exact' });
1654
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
+ });
1655
2039
  it('reports matches_n on multi-match CSS (read path: first match wins)', async () => {
1656
2040
  const evalMock = browserState.page.evaluate;
1657
2041
  evalMock.mockResolvedValueOnce({ ok: true, matches_n: 3, match_level: 'exact' });
@@ -1707,6 +2091,28 @@ describe('browser click/type commands', () => {
1707
2091
  const { lastJsonLog } = installSelectorFirstTestHarness('click-type', () => ({
1708
2092
  evaluate: vi.fn().mockResolvedValue(false),
1709
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
+ }),
1710
2116
  typeText: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
1711
2117
  fillText: vi.fn().mockResolvedValue({
1712
2118
  filled: true,
@@ -1727,6 +2133,163 @@ describe('browser click/type commands', () => {
1727
2133
  expect(browserState.page.click).toHaveBeenCalledWith('#save', {});
1728
2134
  expect(lastJsonLog()).toEqual({ clicked: true, target: '#save', matches_n: 1, match_level: 'exact' });
1729
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
+ });
1730
2293
  it('surfaces match_level=stable when resolver falls back to fingerprint match', async () => {
1731
2294
  browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'stable' });
1732
2295
  const program = createProgram('', '');
@@ -1773,6 +2336,116 @@ describe('browser click/type commands', () => {
1773
2336
  expect(browserState.page.click).not.toHaveBeenCalled();
1774
2337
  expect(process.exitCode).toBeDefined();
1775
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
+ });
1776
2449
  it('type: clicks, waits, then typeText — emits {typed, text, target, matches_n, match_level, autocomplete}', async () => {
1777
2450
  browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
1778
2451
  browserState.page.typeText.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
@@ -1906,6 +2579,21 @@ describe('browser select command', () => {
1906
2579
  expect(err.available).toEqual(['US', 'CA']);
1907
2580
  expect(process.exitCode).toBeDefined();
1908
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
+ });
1909
2597
  it('surfaces selector_ambiguous from the resolver before calling selectResolvedJs', async () => {
1910
2598
  browserState.page.evaluate.mockResolvedValueOnce({
1911
2599
  ok: false,