@jackwener/opencli 1.7.5 → 1.7.6
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.
- package/README.md +5 -2
- package/README.zh-CN.md +5 -2
- package/cli-manifest.json +77 -1
- package/clis/bilibili/video.js +61 -0
- package/clis/bilibili/video.test.js +81 -0
- package/clis/deepseek/ask.js +21 -1
- package/clis/deepseek/ask.test.js +73 -0
- package/clis/deepseek/utils.js +84 -1
- package/clis/deepseek/utils.test.js +37 -0
- package/clis/jianyu/search.js +139 -3
- package/clis/jianyu/search.test.js +25 -0
- package/clis/jianyu/shared/procurement-detail.js +15 -0
- package/clis/jianyu/shared/procurement-detail.test.js +12 -0
- package/clis/twitter/shared.js +7 -2
- package/clis/twitter/tweets.js +218 -0
- package/clis/twitter/tweets.test.js +125 -0
- package/clis/youtube/channel.js +35 -0
- package/dist/src/browser/base-page.d.ts +13 -3
- package/dist/src/browser/base-page.js +35 -25
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +12 -3
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.d.ts +1 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/dom-snapshot.d.ts +7 -0
- package/dist/src/browser/dom-snapshot.js +76 -3
- package/dist/src/browser/dom-snapshot.test.js +65 -0
- package/dist/src/browser/extract.d.ts +69 -0
- package/dist/src/browser/extract.js +132 -0
- package/dist/src/browser/extract.test.d.ts +1 -0
- package/dist/src/browser/extract.test.js +129 -0
- package/dist/src/browser/find.d.ts +76 -0
- package/dist/src/browser/find.js +179 -0
- package/dist/src/browser/find.test.d.ts +1 -0
- package/dist/src/browser/find.test.js +120 -0
- package/dist/src/browser/html-tree.d.ts +75 -0
- package/dist/src/browser/html-tree.js +112 -0
- package/dist/src/browser/html-tree.test.d.ts +1 -0
- package/dist/src/browser/html-tree.test.js +181 -0
- package/dist/src/browser/network-cache.d.ts +48 -0
- package/dist/src/browser/network-cache.js +66 -0
- package/dist/src/browser/network-cache.test.d.ts +1 -0
- package/dist/src/browser/network-cache.test.js +58 -0
- package/dist/src/browser/network-key.d.ts +22 -0
- package/dist/src/browser/network-key.js +66 -0
- package/dist/src/browser/network-key.test.d.ts +1 -0
- package/dist/src/browser/network-key.test.js +49 -0
- package/dist/src/browser/shape-filter.d.ts +52 -0
- package/dist/src/browser/shape-filter.js +101 -0
- package/dist/src/browser/shape-filter.test.d.ts +1 -0
- package/dist/src/browser/shape-filter.test.js +101 -0
- package/dist/src/browser/shape.d.ts +23 -0
- package/dist/src/browser/shape.js +95 -0
- package/dist/src/browser/shape.test.d.ts +1 -0
- package/dist/src/browser/shape.test.js +82 -0
- package/dist/src/browser/target-errors.d.ts +14 -1
- package/dist/src/browser/target-errors.js +13 -0
- package/dist/src/browser/target-errors.test.js +39 -6
- package/dist/src/browser/target-resolver.d.ts +57 -10
- package/dist/src/browser/target-resolver.js +195 -75
- package/dist/src/browser/target-resolver.test.js +80 -5
- package/dist/src/cli.js +630 -125
- package/dist/src/cli.test.js +794 -0
- package/dist/src/execution.js +7 -2
- package/dist/src/execution.test.js +54 -0
- package/dist/src/main.js +16 -0
- package/dist/src/types.d.ts +18 -3
- package/package.json +1 -1
package/dist/src/cli.test.js
CHANGED
|
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
|
+
import { TargetError } from './browser/target-errors.js';
|
|
5
6
|
const { mockBrowserConnect, mockBrowserClose, browserState, } = vi.hoisted(() => ({
|
|
6
7
|
mockBrowserConnect: vi.fn(),
|
|
7
8
|
mockBrowserClose: vi.fn(),
|
|
@@ -233,6 +234,799 @@ describe('browser tab targeting commands', () => {
|
|
|
233
234
|
expect(stderrSpy.mock.calls.flat().join('\n')).toContain('Target tab tab-stale is not part of the current browser session');
|
|
234
235
|
});
|
|
235
236
|
});
|
|
237
|
+
describe('browser network command', () => {
|
|
238
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
239
|
+
function getNetworkCachePath(cacheDir) {
|
|
240
|
+
return path.join(cacheDir, 'browser-network', 'browser_default.json');
|
|
241
|
+
}
|
|
242
|
+
function lastJsonLog() {
|
|
243
|
+
const calls = consoleLogSpy.mock.calls;
|
|
244
|
+
if (calls.length === 0)
|
|
245
|
+
throw new Error('Expected at least one console.log call');
|
|
246
|
+
const last = calls[calls.length - 1][0];
|
|
247
|
+
if (typeof last !== 'string')
|
|
248
|
+
throw new Error(`Expected string arg to console.log, got ${typeof last}`);
|
|
249
|
+
return JSON.parse(last);
|
|
250
|
+
}
|
|
251
|
+
beforeEach(() => {
|
|
252
|
+
process.exitCode = undefined;
|
|
253
|
+
process.env.OPENCLI_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-net-'));
|
|
254
|
+
consoleLogSpy.mockClear();
|
|
255
|
+
mockBrowserConnect.mockClear();
|
|
256
|
+
mockBrowserClose.mockReset().mockResolvedValue(undefined);
|
|
257
|
+
browserState.page = {
|
|
258
|
+
setActivePage: vi.fn(),
|
|
259
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
260
|
+
tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]),
|
|
261
|
+
evaluate: vi.fn().mockResolvedValue(''),
|
|
262
|
+
readNetworkCapture: vi.fn().mockResolvedValue([
|
|
263
|
+
{
|
|
264
|
+
url: 'https://x.com/i/api/graphql/qid/UserTweets?v=1',
|
|
265
|
+
method: 'GET',
|
|
266
|
+
responseStatus: 200,
|
|
267
|
+
responseContentType: 'application/json',
|
|
268
|
+
responsePreview: JSON.stringify({ data: { user: { rest_id: '42' } } }),
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
url: 'https://cdn.example.com/app.js',
|
|
272
|
+
method: 'GET',
|
|
273
|
+
responseStatus: 200,
|
|
274
|
+
responseContentType: 'application/javascript',
|
|
275
|
+
responsePreview: '// js',
|
|
276
|
+
},
|
|
277
|
+
]),
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
it('emits JSON with shape previews and persists the capture to disk', async () => {
|
|
281
|
+
const cacheDir = String(process.env.OPENCLI_CACHE_DIR);
|
|
282
|
+
const program = createProgram('', '');
|
|
283
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network']);
|
|
284
|
+
const out = lastJsonLog();
|
|
285
|
+
expect(out.count).toBe(1);
|
|
286
|
+
expect(out.filtered_out).toBe(1);
|
|
287
|
+
expect(out.entries[0].key).toBe('UserTweets');
|
|
288
|
+
expect(out.entries[0].shape['$.data.user.rest_id']).toBe('string');
|
|
289
|
+
expect(out.entries[0]).not.toHaveProperty('body');
|
|
290
|
+
expect(fs.existsSync(getNetworkCachePath(cacheDir))).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
it('--all includes static resources that the default filter drops', async () => {
|
|
293
|
+
const program = createProgram('', '');
|
|
294
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--all']);
|
|
295
|
+
const out = lastJsonLog();
|
|
296
|
+
expect(out.count).toBe(2);
|
|
297
|
+
expect(out.entries.map((e) => e.key)).toContain('UserTweets');
|
|
298
|
+
expect(out.entries.map((e) => e.key)).toContain('GET cdn.example.com/app.js');
|
|
299
|
+
});
|
|
300
|
+
it('--raw emits full bodies inline for every entry', async () => {
|
|
301
|
+
const program = createProgram('', '');
|
|
302
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--raw']);
|
|
303
|
+
const out = lastJsonLog();
|
|
304
|
+
expect(out.entries[0].body).toEqual({ data: { user: { rest_id: '42' } } });
|
|
305
|
+
});
|
|
306
|
+
it('--detail <key> returns the full body for the requested entry', async () => {
|
|
307
|
+
const program = createProgram('', '');
|
|
308
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network']);
|
|
309
|
+
consoleLogSpy.mockClear();
|
|
310
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--detail', 'UserTweets']);
|
|
311
|
+
const out = lastJsonLog();
|
|
312
|
+
expect(out.key).toBe('UserTweets');
|
|
313
|
+
expect(out.body).toEqual({ data: { user: { rest_id: '42' } } });
|
|
314
|
+
expect(out.shape['$.data.user.rest_id']).toBe('string');
|
|
315
|
+
});
|
|
316
|
+
it('--detail reports key_not_found with the list of available keys', async () => {
|
|
317
|
+
const program = createProgram('', '');
|
|
318
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network']);
|
|
319
|
+
consoleLogSpy.mockClear();
|
|
320
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--detail', 'NopeOp']);
|
|
321
|
+
const out = lastJsonLog();
|
|
322
|
+
expect(out.error.code).toBe('key_not_found');
|
|
323
|
+
expect(out.error.available_keys).toContain('UserTweets');
|
|
324
|
+
expect(process.exitCode).toBeDefined();
|
|
325
|
+
});
|
|
326
|
+
it('--detail reports cache_missing when no capture has been persisted yet', async () => {
|
|
327
|
+
const program = createProgram('', '');
|
|
328
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--detail', 'UserTweets']);
|
|
329
|
+
const out = lastJsonLog();
|
|
330
|
+
expect(out.error.code).toBe('cache_missing');
|
|
331
|
+
expect(process.exitCode).toBeDefined();
|
|
332
|
+
});
|
|
333
|
+
it('emits capture_failed when readNetworkCapture throws', async () => {
|
|
334
|
+
browserState.page.readNetworkCapture = vi.fn().mockRejectedValue(new Error('CDP disconnected'));
|
|
335
|
+
const program = createProgram('', '');
|
|
336
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network']);
|
|
337
|
+
const out = lastJsonLog();
|
|
338
|
+
expect(out.error.code).toBe('capture_failed');
|
|
339
|
+
expect(out.error.message).toContain('CDP disconnected');
|
|
340
|
+
expect(process.exitCode).toBeDefined();
|
|
341
|
+
});
|
|
342
|
+
it('surfaces cache_warning in the envelope when persistence fails', async () => {
|
|
343
|
+
const cacheDir = String(process.env.OPENCLI_CACHE_DIR);
|
|
344
|
+
// Pre-create the target path as a file where a directory is expected,
|
|
345
|
+
// forcing the mkdir inside saveNetworkCache to throw.
|
|
346
|
+
const clashDir = path.join(cacheDir, 'browser-network');
|
|
347
|
+
fs.writeFileSync(clashDir, 'not-a-directory');
|
|
348
|
+
const program = createProgram('', '');
|
|
349
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network']);
|
|
350
|
+
const out = lastJsonLog();
|
|
351
|
+
expect(out.cache_warning).toMatch(/Could not persist capture cache/);
|
|
352
|
+
expect(out.count).toBe(1);
|
|
353
|
+
expect(process.exitCode).toBeUndefined();
|
|
354
|
+
});
|
|
355
|
+
describe('--filter', () => {
|
|
356
|
+
function apiResponse(url, body) {
|
|
357
|
+
return {
|
|
358
|
+
url,
|
|
359
|
+
method: 'GET',
|
|
360
|
+
responseStatus: 200,
|
|
361
|
+
responseContentType: 'application/json',
|
|
362
|
+
responsePreview: JSON.stringify(body),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
beforeEach(() => {
|
|
366
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
367
|
+
apiResponse('https://x.com/i/api/graphql/qid/UserTweets?v=1', { data: { items: [{ author: 'a', text: 't', likes: 1 }] } }),
|
|
368
|
+
apiResponse('https://x.com/i/api/graphql/qid/UserProfile?v=1', { data: { user: { id: 'u1', followers: 10 } } }),
|
|
369
|
+
apiResponse('https://x.com/i/api/graphql/qid/Settings?v=1', { config: { theme: 'dark' } }),
|
|
370
|
+
]);
|
|
371
|
+
});
|
|
372
|
+
it('narrows entries to those whose shape has ALL named fields', async () => {
|
|
373
|
+
const program = createProgram('', '');
|
|
374
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'author,text,likes']);
|
|
375
|
+
const out = lastJsonLog();
|
|
376
|
+
expect(out.count).toBe(1);
|
|
377
|
+
expect(out.filter).toEqual(['author', 'text', 'likes']);
|
|
378
|
+
expect(out.filter_dropped).toBe(2);
|
|
379
|
+
expect(out.entries[0].key).toBe('UserTweets');
|
|
380
|
+
});
|
|
381
|
+
it('matches container segments too, not just leaf names (any-segment rule)', async () => {
|
|
382
|
+
const program = createProgram('', '');
|
|
383
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'data,items']);
|
|
384
|
+
const out = lastJsonLog();
|
|
385
|
+
expect(out.count).toBe(1);
|
|
386
|
+
expect(out.entries[0].key).toBe('UserTweets');
|
|
387
|
+
});
|
|
388
|
+
it('drops entries that are missing any required field (AND semantics)', async () => {
|
|
389
|
+
const program = createProgram('', '');
|
|
390
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'author,followers']);
|
|
391
|
+
const out = lastJsonLog();
|
|
392
|
+
expect(out.count).toBe(0);
|
|
393
|
+
expect(out.entries).toEqual([]);
|
|
394
|
+
expect(out.filter).toEqual(['author', 'followers']);
|
|
395
|
+
expect(out.filter_dropped).toBe(3);
|
|
396
|
+
});
|
|
397
|
+
it('returns empty entries (not an error) when nothing matches', async () => {
|
|
398
|
+
const program = createProgram('', '');
|
|
399
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'nonexistent_field']);
|
|
400
|
+
const out = lastJsonLog();
|
|
401
|
+
expect(out.count).toBe(0);
|
|
402
|
+
expect(out.entries).toEqual([]);
|
|
403
|
+
expect(out).not.toHaveProperty('error');
|
|
404
|
+
expect(process.exitCode).toBeUndefined();
|
|
405
|
+
});
|
|
406
|
+
it('is case-sensitive so agents do not conflate `Id` with `id`', async () => {
|
|
407
|
+
const program = createProgram('', '');
|
|
408
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'Data']);
|
|
409
|
+
const out = lastJsonLog();
|
|
410
|
+
expect(out.count).toBe(0);
|
|
411
|
+
});
|
|
412
|
+
it('persists the full (unfiltered) capture so --detail lookups still find filtered-out keys', async () => {
|
|
413
|
+
const program = createProgram('', '');
|
|
414
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'author,text,likes']);
|
|
415
|
+
consoleLogSpy.mockClear();
|
|
416
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--detail', 'UserProfile']);
|
|
417
|
+
const out = lastJsonLog();
|
|
418
|
+
expect(out.key).toBe('UserProfile');
|
|
419
|
+
expect(out.body).toEqual({ data: { user: { id: 'u1', followers: 10 } } });
|
|
420
|
+
});
|
|
421
|
+
it('composes with --raw: entries keep full bodies, filter still narrows', async () => {
|
|
422
|
+
const program = createProgram('', '');
|
|
423
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'author', '--raw']);
|
|
424
|
+
const out = lastJsonLog();
|
|
425
|
+
expect(out.count).toBe(1);
|
|
426
|
+
expect(out.entries[0].body).toEqual({ data: { items: [{ author: 'a', text: 't', likes: 1 }] } });
|
|
427
|
+
});
|
|
428
|
+
it('reports invalid_filter for empty value', async () => {
|
|
429
|
+
const program = createProgram('', '');
|
|
430
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', '']);
|
|
431
|
+
const out = lastJsonLog();
|
|
432
|
+
expect(out.error.code).toBe('invalid_filter');
|
|
433
|
+
expect(process.exitCode).toBeDefined();
|
|
434
|
+
});
|
|
435
|
+
it('reports invalid_filter for commas-only value', async () => {
|
|
436
|
+
const program = createProgram('', '');
|
|
437
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', ',,,']);
|
|
438
|
+
const out = lastJsonLog();
|
|
439
|
+
expect(out.error.code).toBe('invalid_filter');
|
|
440
|
+
expect(process.exitCode).toBeDefined();
|
|
441
|
+
});
|
|
442
|
+
it('rejects --filter combined with --detail as invalid_args', async () => {
|
|
443
|
+
const program = createProgram('', '');
|
|
444
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'author', '--detail', 'UserTweets']);
|
|
445
|
+
const out = lastJsonLog();
|
|
446
|
+
expect(out.error.code).toBe('invalid_args');
|
|
447
|
+
expect(out.error.message).toContain('--filter');
|
|
448
|
+
expect(out.error.message).toContain('--detail');
|
|
449
|
+
expect(process.exitCode).toBeDefined();
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
describe('body truncation signals', () => {
|
|
453
|
+
it('flags body_truncated in list view when the capture layer capped the body', async () => {
|
|
454
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
455
|
+
{
|
|
456
|
+
url: 'https://api.example.com/huge',
|
|
457
|
+
method: 'GET',
|
|
458
|
+
responseStatus: 200,
|
|
459
|
+
responseContentType: 'application/json',
|
|
460
|
+
responsePreview: '{"data":"x"}',
|
|
461
|
+
responseBodyFullSize: 99_999_999,
|
|
462
|
+
responseBodyTruncated: true,
|
|
463
|
+
},
|
|
464
|
+
]);
|
|
465
|
+
const program = createProgram('', '');
|
|
466
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network']);
|
|
467
|
+
const out = lastJsonLog();
|
|
468
|
+
expect(out.body_truncated_count).toBe(1);
|
|
469
|
+
expect(out.entries[0].body_truncated).toBe(true);
|
|
470
|
+
expect(out.entries[0].size).toBe(99_999_999);
|
|
471
|
+
});
|
|
472
|
+
it('--detail surfaces body_truncated + body_full_size when capture had to cap the body', async () => {
|
|
473
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
474
|
+
{
|
|
475
|
+
url: 'https://api.example.com/huge',
|
|
476
|
+
method: 'GET',
|
|
477
|
+
responseStatus: 200,
|
|
478
|
+
responseContentType: 'application/json',
|
|
479
|
+
responsePreview: 'truncated-prefix-not-valid-json',
|
|
480
|
+
responseBodyFullSize: 50_000_000,
|
|
481
|
+
responseBodyTruncated: true,
|
|
482
|
+
},
|
|
483
|
+
]);
|
|
484
|
+
const program = createProgram('', '');
|
|
485
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network']);
|
|
486
|
+
consoleLogSpy.mockClear();
|
|
487
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--detail', 'GET api.example.com/huge']);
|
|
488
|
+
const out = lastJsonLog();
|
|
489
|
+
expect(out.body_truncated).toBe(true);
|
|
490
|
+
expect(out.body_full_size).toBe(50_000_000);
|
|
491
|
+
expect(out.body_truncation_reason).toBe('capture-limit');
|
|
492
|
+
});
|
|
493
|
+
it('--max-body caps the emitted body and marks body_truncation_reason = max-body', async () => {
|
|
494
|
+
const longString = 'x'.repeat(5000);
|
|
495
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
496
|
+
{
|
|
497
|
+
url: 'https://api.example.com/plain',
|
|
498
|
+
method: 'GET',
|
|
499
|
+
responseStatus: 200,
|
|
500
|
+
responseContentType: 'text/plain',
|
|
501
|
+
responsePreview: longString,
|
|
502
|
+
},
|
|
503
|
+
]);
|
|
504
|
+
const program = createProgram('', '');
|
|
505
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network']);
|
|
506
|
+
consoleLogSpy.mockClear();
|
|
507
|
+
await program.parseAsync([
|
|
508
|
+
'node', 'opencli', 'browser', 'network',
|
|
509
|
+
'--detail', 'GET api.example.com/plain',
|
|
510
|
+
'--max-body', '100',
|
|
511
|
+
]);
|
|
512
|
+
const out = lastJsonLog();
|
|
513
|
+
expect(typeof out.body).toBe('string');
|
|
514
|
+
expect(out.body).toHaveLength(100);
|
|
515
|
+
expect(out.body_truncated).toBe(true);
|
|
516
|
+
expect(out.body_truncation_reason).toBe('max-body');
|
|
517
|
+
expect(out.body_full_size).toBe(5000);
|
|
518
|
+
});
|
|
519
|
+
it('--max-body leaves parsed JSON bodies untouched (no mid-object cut)', async () => {
|
|
520
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
521
|
+
{
|
|
522
|
+
url: 'https://api.example.com/json',
|
|
523
|
+
method: 'GET',
|
|
524
|
+
responseStatus: 200,
|
|
525
|
+
responseContentType: 'application/json',
|
|
526
|
+
responsePreview: JSON.stringify({ data: { user: { rest_id: 'u1' } } }),
|
|
527
|
+
},
|
|
528
|
+
]);
|
|
529
|
+
const program = createProgram('', '');
|
|
530
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network']);
|
|
531
|
+
consoleLogSpy.mockClear();
|
|
532
|
+
await program.parseAsync([
|
|
533
|
+
'node', 'opencli', 'browser', 'network',
|
|
534
|
+
'--detail', 'GET api.example.com/json',
|
|
535
|
+
'--max-body', '10',
|
|
536
|
+
]);
|
|
537
|
+
const out = lastJsonLog();
|
|
538
|
+
// JSON body already parsed at capture time — --max-body only applies to
|
|
539
|
+
// string bodies (which is where the agent-visible hazard lives).
|
|
540
|
+
expect(out.body).toEqual({ data: { user: { rest_id: 'u1' } } });
|
|
541
|
+
expect(out).not.toHaveProperty('body_truncated');
|
|
542
|
+
});
|
|
543
|
+
it('rejects non-numeric --max-body with invalid_max_body', async () => {
|
|
544
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
545
|
+
{
|
|
546
|
+
url: 'https://api.example.com/x',
|
|
547
|
+
method: 'GET',
|
|
548
|
+
responseStatus: 200,
|
|
549
|
+
responseContentType: 'application/json',
|
|
550
|
+
responsePreview: '{"a":1}',
|
|
551
|
+
},
|
|
552
|
+
]);
|
|
553
|
+
const program = createProgram('', '');
|
|
554
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network']);
|
|
555
|
+
consoleLogSpy.mockClear();
|
|
556
|
+
await program.parseAsync([
|
|
557
|
+
'node', 'opencli', 'browser', 'network',
|
|
558
|
+
'--detail', 'GET api.example.com/x',
|
|
559
|
+
'--max-body', 'abc',
|
|
560
|
+
]);
|
|
561
|
+
expect(lastJsonLog().error.code).toBe('invalid_max_body');
|
|
562
|
+
expect(process.exitCode).toBeDefined();
|
|
563
|
+
});
|
|
564
|
+
it('--raw emits snake_case body_truncated / body_full_size, matching non-raw + detail', async () => {
|
|
565
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
566
|
+
{
|
|
567
|
+
url: 'https://api.example.com/huge',
|
|
568
|
+
method: 'GET',
|
|
569
|
+
responseStatus: 200,
|
|
570
|
+
responseContentType: 'application/json',
|
|
571
|
+
responsePreview: 'truncated-prefix',
|
|
572
|
+
responseBodyFullSize: 20_000_000,
|
|
573
|
+
responseBodyTruncated: true,
|
|
574
|
+
},
|
|
575
|
+
]);
|
|
576
|
+
const program = createProgram('', '');
|
|
577
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--raw']);
|
|
578
|
+
const out = lastJsonLog();
|
|
579
|
+
expect(out.entries).toHaveLength(1);
|
|
580
|
+
const entry = out.entries[0];
|
|
581
|
+
expect(entry.body_truncated).toBe(true);
|
|
582
|
+
expect(entry.body_full_size).toBe(20_000_000);
|
|
583
|
+
// Internal camelCase must not leak into the agent-facing envelope.
|
|
584
|
+
expect(entry).not.toHaveProperty('bodyTruncated');
|
|
585
|
+
expect(entry).not.toHaveProperty('bodyFullSize');
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
describe('browser get html command', () => {
|
|
590
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
591
|
+
function lastLogArg() {
|
|
592
|
+
const calls = consoleLogSpy.mock.calls;
|
|
593
|
+
if (calls.length === 0)
|
|
594
|
+
throw new Error('expected console.log call');
|
|
595
|
+
return calls[calls.length - 1][0];
|
|
596
|
+
}
|
|
597
|
+
function lastJsonLog() {
|
|
598
|
+
const arg = lastLogArg();
|
|
599
|
+
if (typeof arg !== 'string')
|
|
600
|
+
throw new Error(`expected string arg, got ${typeof arg}`);
|
|
601
|
+
return JSON.parse(arg);
|
|
602
|
+
}
|
|
603
|
+
beforeEach(() => {
|
|
604
|
+
process.exitCode = undefined;
|
|
605
|
+
process.env.OPENCLI_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-html-'));
|
|
606
|
+
consoleLogSpy.mockClear();
|
|
607
|
+
mockBrowserConnect.mockClear();
|
|
608
|
+
mockBrowserClose.mockReset().mockResolvedValue(undefined);
|
|
609
|
+
browserState.page = {
|
|
610
|
+
setActivePage: vi.fn(),
|
|
611
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
612
|
+
tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]),
|
|
613
|
+
evaluate: vi.fn(),
|
|
614
|
+
};
|
|
615
|
+
});
|
|
616
|
+
it('returns full outerHTML by default with no truncation', async () => {
|
|
617
|
+
const big = '<div>' + 'x'.repeat(100_000) + '</div>';
|
|
618
|
+
browserState.page.evaluate.mockResolvedValueOnce({ kind: 'ok', html: big });
|
|
619
|
+
const program = createProgram('', '');
|
|
620
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html']);
|
|
621
|
+
expect(lastLogArg()).toBe(big);
|
|
622
|
+
});
|
|
623
|
+
it('caps output with --max and prepends a visible truncation marker', async () => {
|
|
624
|
+
const big = '<div>' + 'x'.repeat(500) + '</div>';
|
|
625
|
+
browserState.page.evaluate.mockResolvedValueOnce({ kind: 'ok', html: big });
|
|
626
|
+
const program = createProgram('', '');
|
|
627
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--max', '100']);
|
|
628
|
+
const out = String(lastLogArg());
|
|
629
|
+
expect(out.startsWith('<!-- opencli: truncated 100 of')).toBe(true);
|
|
630
|
+
expect(out.length).toBeGreaterThan(100);
|
|
631
|
+
expect(out.length).toBeLessThan(big.length);
|
|
632
|
+
});
|
|
633
|
+
it('rejects negative --max with invalid_max error', async () => {
|
|
634
|
+
const program = createProgram('', '');
|
|
635
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--max', '-1']);
|
|
636
|
+
expect(lastJsonLog().error.code).toBe('invalid_max');
|
|
637
|
+
expect(process.exitCode).toBeDefined();
|
|
638
|
+
expect(browserState.page.evaluate).not.toHaveBeenCalled();
|
|
639
|
+
});
|
|
640
|
+
it('rejects fractional --max with invalid_max error', async () => {
|
|
641
|
+
const program = createProgram('', '');
|
|
642
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--max', '1.5']);
|
|
643
|
+
expect(lastJsonLog().error.code).toBe('invalid_max');
|
|
644
|
+
expect(process.exitCode).toBeDefined();
|
|
645
|
+
expect(browserState.page.evaluate).not.toHaveBeenCalled();
|
|
646
|
+
});
|
|
647
|
+
it('rejects non-numeric --max (e.g. "10abc") with invalid_max error', async () => {
|
|
648
|
+
const program = createProgram('', '');
|
|
649
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--max', '10abc']);
|
|
650
|
+
expect(lastJsonLog().error.code).toBe('invalid_max');
|
|
651
|
+
expect(process.exitCode).toBeDefined();
|
|
652
|
+
expect(browserState.page.evaluate).not.toHaveBeenCalled();
|
|
653
|
+
});
|
|
654
|
+
it('--as json returns structured tree envelope', async () => {
|
|
655
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
656
|
+
selector: '.hero',
|
|
657
|
+
matched: 1,
|
|
658
|
+
tree: { tag: 'div', attrs: { class: 'hero' }, text: 'Hi', children: [] },
|
|
659
|
+
});
|
|
660
|
+
const program = createProgram('', '');
|
|
661
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--selector', '.hero', '--as', 'json']);
|
|
662
|
+
const out = lastJsonLog();
|
|
663
|
+
expect(out.matched).toBe(1);
|
|
664
|
+
expect(out.tree.tag).toBe('div');
|
|
665
|
+
expect(out.tree.attrs.class).toBe('hero');
|
|
666
|
+
});
|
|
667
|
+
it('--as json emits selector_not_found when matched is 0', async () => {
|
|
668
|
+
browserState.page.evaluate.mockResolvedValueOnce({ selector: '.missing', matched: 0, tree: null });
|
|
669
|
+
const program = createProgram('', '');
|
|
670
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--selector', '.missing', '--as', 'json']);
|
|
671
|
+
expect(lastJsonLog().error.code).toBe('selector_not_found');
|
|
672
|
+
expect(process.exitCode).toBeDefined();
|
|
673
|
+
});
|
|
674
|
+
it('raw mode emits selector_not_found when the selector matches nothing', async () => {
|
|
675
|
+
browserState.page.evaluate.mockResolvedValueOnce({ kind: 'ok', html: null });
|
|
676
|
+
const program = createProgram('', '');
|
|
677
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--selector', '.missing']);
|
|
678
|
+
expect(lastJsonLog().error.code).toBe('selector_not_found');
|
|
679
|
+
expect(process.exitCode).toBeDefined();
|
|
680
|
+
});
|
|
681
|
+
it('raw mode emits invalid_selector when the page rejects the selector syntax', async () => {
|
|
682
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
683
|
+
kind: 'invalid_selector',
|
|
684
|
+
reason: "'##$@@' is not a valid selector",
|
|
685
|
+
});
|
|
686
|
+
const program = createProgram('', '');
|
|
687
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--selector', '##$@@']);
|
|
688
|
+
const err = lastJsonLog().error;
|
|
689
|
+
expect(err.code).toBe('invalid_selector');
|
|
690
|
+
expect(err.message).toContain('##$@@');
|
|
691
|
+
expect(err.message).toContain('not a valid selector');
|
|
692
|
+
expect(process.exitCode).toBeDefined();
|
|
693
|
+
});
|
|
694
|
+
it('--as json emits invalid_selector when the page rejects the selector syntax', async () => {
|
|
695
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
696
|
+
selector: '##$@@',
|
|
697
|
+
invalidSelector: true,
|
|
698
|
+
reason: "'##$@@' is not a valid selector",
|
|
699
|
+
});
|
|
700
|
+
const program = createProgram('', '');
|
|
701
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--selector', '##$@@', '--as', 'json']);
|
|
702
|
+
const err = lastJsonLog().error;
|
|
703
|
+
expect(err.code).toBe('invalid_selector');
|
|
704
|
+
expect(err.message).toContain('##$@@');
|
|
705
|
+
expect(process.exitCode).toBeDefined();
|
|
706
|
+
});
|
|
707
|
+
it('rejects unknown --as format with invalid_format error', async () => {
|
|
708
|
+
const program = createProgram('', '');
|
|
709
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--as', 'yaml']);
|
|
710
|
+
expect(lastJsonLog().error.code).toBe('invalid_format');
|
|
711
|
+
expect(process.exitCode).toBeDefined();
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
// Shared helper for the selector-first describe blocks below.
|
|
715
|
+
// Each block spies console.log, mocks the IPage surface it touches, and
|
|
716
|
+
// parses the last stringified call to inspect the JSON envelope — the
|
|
717
|
+
// canonical agent-facing contract for the selector-first commands.
|
|
718
|
+
function installSelectorFirstTestHarness(label, pageOverrides) {
|
|
719
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
720
|
+
function lastLogArg() {
|
|
721
|
+
const calls = consoleLogSpy.mock.calls;
|
|
722
|
+
if (calls.length === 0)
|
|
723
|
+
throw new Error('expected console.log call');
|
|
724
|
+
return calls[calls.length - 1][0];
|
|
725
|
+
}
|
|
726
|
+
function lastJsonLog() {
|
|
727
|
+
const arg = lastLogArg();
|
|
728
|
+
if (typeof arg !== 'string')
|
|
729
|
+
throw new Error(`expected string arg, got ${typeof arg}`);
|
|
730
|
+
return JSON.parse(arg);
|
|
731
|
+
}
|
|
732
|
+
beforeEach(() => {
|
|
733
|
+
process.exitCode = undefined;
|
|
734
|
+
process.env.OPENCLI_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), `opencli-${label}-`));
|
|
735
|
+
consoleLogSpy.mockClear();
|
|
736
|
+
mockBrowserConnect.mockClear();
|
|
737
|
+
mockBrowserClose.mockReset().mockResolvedValue(undefined);
|
|
738
|
+
browserState.page = {
|
|
739
|
+
setActivePage: vi.fn(),
|
|
740
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
741
|
+
tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]),
|
|
742
|
+
...pageOverrides(),
|
|
743
|
+
};
|
|
744
|
+
});
|
|
745
|
+
return { lastJsonLog };
|
|
746
|
+
}
|
|
747
|
+
describe('browser find command', () => {
|
|
748
|
+
const { lastJsonLog } = installSelectorFirstTestHarness('find', () => ({
|
|
749
|
+
evaluate: vi.fn(),
|
|
750
|
+
}));
|
|
751
|
+
it('returns a {matches_n, entries} envelope for a matching selector', async () => {
|
|
752
|
+
// `find` always returns numeric refs (existing on snapshot-tagged elements,
|
|
753
|
+
// allocated on the spot for fresh matches) — see reviewer contract in
|
|
754
|
+
// #opencli-browser msg 52c51eb6.
|
|
755
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
756
|
+
matches_n: 2,
|
|
757
|
+
entries: [
|
|
758
|
+
{ nth: 0, ref: 5, tag: 'button', role: '', text: 'OK', attrs: { class: 'btn' }, visible: true },
|
|
759
|
+
{ nth: 1, ref: 17, tag: 'button', role: '', text: 'Cancel', attrs: { class: 'btn' }, visible: true },
|
|
760
|
+
],
|
|
761
|
+
});
|
|
762
|
+
const program = createProgram('', '');
|
|
763
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'find', '--css', '.btn']);
|
|
764
|
+
const out = lastJsonLog();
|
|
765
|
+
expect(out.matches_n).toBe(2);
|
|
766
|
+
expect(out.entries).toHaveLength(2);
|
|
767
|
+
expect(out.entries[0].ref).toBe(5);
|
|
768
|
+
expect(out.entries[1].ref).toBe(17);
|
|
769
|
+
expect(process.exitCode).toBeUndefined();
|
|
770
|
+
});
|
|
771
|
+
it('forwards --limit / --text-max into the generated JS', async () => {
|
|
772
|
+
browserState.page.evaluate.mockResolvedValueOnce({ matches_n: 0, entries: [] });
|
|
773
|
+
const program = createProgram('', '');
|
|
774
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'find', '--css', '.btn', '--limit', '3', '--text-max', '20']);
|
|
775
|
+
const js = browserState.page.evaluate.mock.calls[0][0];
|
|
776
|
+
expect(js).toContain('LIMIT = 3');
|
|
777
|
+
expect(js).toContain('TEXT_MAX = 20');
|
|
778
|
+
});
|
|
779
|
+
it('emits invalid_selector envelope when the page rejects selector syntax', async () => {
|
|
780
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
781
|
+
error: { code: 'invalid_selector', message: 'Invalid CSS selector: ">>>"', hint: 'Check the selector syntax.' },
|
|
782
|
+
});
|
|
783
|
+
const program = createProgram('', '');
|
|
784
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'find', '--css', '>>>']);
|
|
785
|
+
expect(lastJsonLog().error.code).toBe('invalid_selector');
|
|
786
|
+
expect(process.exitCode).toBeDefined();
|
|
787
|
+
});
|
|
788
|
+
it('emits selector_not_found envelope when the selector matches nothing', async () => {
|
|
789
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
790
|
+
error: { code: 'selector_not_found', message: 'CSS selector ".missing" matched 0 elements', hint: 'Use browser state to inspect the page.' },
|
|
791
|
+
});
|
|
792
|
+
const program = createProgram('', '');
|
|
793
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'find', '--css', '.missing']);
|
|
794
|
+
expect(lastJsonLog().error.code).toBe('selector_not_found');
|
|
795
|
+
expect(process.exitCode).toBeDefined();
|
|
796
|
+
});
|
|
797
|
+
it('rejects missing --css with usage_error (no evaluate call)', async () => {
|
|
798
|
+
const program = createProgram('', '');
|
|
799
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'find']);
|
|
800
|
+
expect(lastJsonLog().error.code).toBe('usage_error');
|
|
801
|
+
expect(browserState.page.evaluate).not.toHaveBeenCalled();
|
|
802
|
+
expect(process.exitCode).toBeDefined();
|
|
803
|
+
});
|
|
804
|
+
it('rejects malformed --limit with usage_error (no evaluate call)', async () => {
|
|
805
|
+
const program = createProgram('', '');
|
|
806
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'find', '--css', '.btn', '--limit', 'abc']);
|
|
807
|
+
expect(lastJsonLog().error.code).toBe('usage_error');
|
|
808
|
+
expect(browserState.page.evaluate).not.toHaveBeenCalled();
|
|
809
|
+
expect(process.exitCode).toBeDefined();
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
describe('browser get text/value/attributes commands', () => {
|
|
813
|
+
const { lastJsonLog } = installSelectorFirstTestHarness('get-sel', () => ({
|
|
814
|
+
evaluate: vi.fn(),
|
|
815
|
+
}));
|
|
816
|
+
it('emits {value, matches_n, match_level} envelope for a numeric ref', async () => {
|
|
817
|
+
const evalMock = browserState.page.evaluate;
|
|
818
|
+
// 1st call: resolveTargetJs -> { ok: true, matches_n: 1, match_level: 'exact' }
|
|
819
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
|
|
820
|
+
// 2nd call: getTextResolvedJs -> the element's text
|
|
821
|
+
evalMock.mockResolvedValueOnce('Hello world');
|
|
822
|
+
const program = createProgram('', '');
|
|
823
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '7']);
|
|
824
|
+
expect(lastJsonLog()).toEqual({ value: 'Hello world', matches_n: 1, match_level: 'exact' });
|
|
825
|
+
});
|
|
826
|
+
it('reports matches_n on multi-match CSS (read path: first match wins)', async () => {
|
|
827
|
+
const evalMock = browserState.page.evaluate;
|
|
828
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 3, match_level: 'exact' });
|
|
829
|
+
evalMock.mockResolvedValueOnce('first');
|
|
830
|
+
const program = createProgram('', '');
|
|
831
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '.btn']);
|
|
832
|
+
expect(lastJsonLog()).toEqual({ value: 'first', matches_n: 3, match_level: 'exact' });
|
|
833
|
+
});
|
|
834
|
+
it('parses the attributes payload back into a real object', async () => {
|
|
835
|
+
const evalMock = browserState.page.evaluate;
|
|
836
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
|
|
837
|
+
// getAttributesResolvedJs returns a JSON-encoded string — the CLI must parse it
|
|
838
|
+
evalMock.mockResolvedValueOnce(JSON.stringify({ id: 'nav', class: 'hero' }));
|
|
839
|
+
const program = createProgram('', '');
|
|
840
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'attributes', '#nav']);
|
|
841
|
+
const out = lastJsonLog();
|
|
842
|
+
expect(out.matches_n).toBe(1);
|
|
843
|
+
expect(out.match_level).toBe('exact');
|
|
844
|
+
expect(out.value).toEqual({ id: 'nav', class: 'hero' });
|
|
845
|
+
});
|
|
846
|
+
it('propagates selector_not_found from the resolver as an error envelope', async () => {
|
|
847
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
848
|
+
ok: false,
|
|
849
|
+
code: 'selector_not_found',
|
|
850
|
+
message: 'CSS selector ".missing" matched 0 elements',
|
|
851
|
+
hint: 'Try a less specific selector.',
|
|
852
|
+
});
|
|
853
|
+
const program = createProgram('', '');
|
|
854
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '.missing']);
|
|
855
|
+
expect(lastJsonLog().error.code).toBe('selector_not_found');
|
|
856
|
+
expect(process.exitCode).toBeDefined();
|
|
857
|
+
});
|
|
858
|
+
it('forwards --nth into the resolver opts and reports matches_n', async () => {
|
|
859
|
+
const evalMock = browserState.page.evaluate;
|
|
860
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 4, match_level: 'exact' });
|
|
861
|
+
evalMock.mockResolvedValueOnce('second');
|
|
862
|
+
const program = createProgram('', '');
|
|
863
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'value', '.btn', '--nth', '1']);
|
|
864
|
+
const resolveJs = evalMock.mock.calls[0][0];
|
|
865
|
+
// resolveTargetJs embeds nth as a raw number literal; look for the binding
|
|
866
|
+
expect(resolveJs).toContain('const nth = 1');
|
|
867
|
+
expect(lastJsonLog()).toEqual({ value: 'second', matches_n: 4, match_level: 'exact' });
|
|
868
|
+
});
|
|
869
|
+
it('rejects malformed --nth with usage_error before touching the page', async () => {
|
|
870
|
+
const program = createProgram('', '');
|
|
871
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '.btn', '--nth', 'abc']);
|
|
872
|
+
expect(lastJsonLog().error.code).toBe('usage_error');
|
|
873
|
+
expect(browserState.page.evaluate).not.toHaveBeenCalled();
|
|
874
|
+
expect(process.exitCode).toBeDefined();
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
describe('browser click/type commands', () => {
|
|
878
|
+
const { lastJsonLog } = installSelectorFirstTestHarness('click-type', () => ({
|
|
879
|
+
evaluate: vi.fn().mockResolvedValue(false),
|
|
880
|
+
click: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
|
|
881
|
+
typeText: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
|
|
882
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
883
|
+
}));
|
|
884
|
+
it('emits {clicked, target, matches_n, match_level} on success', async () => {
|
|
885
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
886
|
+
const program = createProgram('', '');
|
|
887
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'click', '#save']);
|
|
888
|
+
expect(browserState.page.click).toHaveBeenCalledWith('#save', {});
|
|
889
|
+
expect(lastJsonLog()).toEqual({ clicked: true, target: '#save', matches_n: 1, match_level: 'exact' });
|
|
890
|
+
});
|
|
891
|
+
it('surfaces match_level=stable when resolver falls back to fingerprint match', async () => {
|
|
892
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'stable' });
|
|
893
|
+
const program = createProgram('', '');
|
|
894
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'click', '7']);
|
|
895
|
+
expect(lastJsonLog()).toEqual({ clicked: true, target: '7', matches_n: 1, match_level: 'stable' });
|
|
896
|
+
});
|
|
897
|
+
it('forwards --nth as ResolveOptions.nth to page.click', async () => {
|
|
898
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 3, match_level: 'exact' });
|
|
899
|
+
const program = createProgram('', '');
|
|
900
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'click', '.btn', '--nth', '2']);
|
|
901
|
+
expect(browserState.page.click).toHaveBeenCalledWith('.btn', { nth: 2 });
|
|
902
|
+
expect(lastJsonLog()).toEqual({ clicked: true, target: '.btn', matches_n: 3, match_level: 'exact' });
|
|
903
|
+
});
|
|
904
|
+
it('surfaces selector_ambiguous from page.click as an error envelope', async () => {
|
|
905
|
+
browserState.page.click.mockRejectedValueOnce(new TargetError({
|
|
906
|
+
code: 'selector_ambiguous',
|
|
907
|
+
message: 'CSS selector ".btn" matched 3 elements; clicks require a unique target.',
|
|
908
|
+
hint: 'Pass --nth <n> to pick one (0-based).',
|
|
909
|
+
matches_n: 3,
|
|
910
|
+
}));
|
|
911
|
+
const program = createProgram('', '');
|
|
912
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'click', '.btn']);
|
|
913
|
+
const err = lastJsonLog().error;
|
|
914
|
+
expect(err.code).toBe('selector_ambiguous');
|
|
915
|
+
expect(err.matches_n).toBe(3);
|
|
916
|
+
expect(process.exitCode).toBeDefined();
|
|
917
|
+
});
|
|
918
|
+
it('surfaces selector_nth_out_of_range from page.click as an error envelope', async () => {
|
|
919
|
+
browserState.page.click.mockRejectedValueOnce(new TargetError({
|
|
920
|
+
code: 'selector_nth_out_of_range',
|
|
921
|
+
message: '--nth 99 is out of range for CSS selector ".btn" (matches_n=3).',
|
|
922
|
+
hint: 'Pick an index in [0, 2].',
|
|
923
|
+
matches_n: 3,
|
|
924
|
+
}));
|
|
925
|
+
const program = createProgram('', '');
|
|
926
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'click', '.btn', '--nth', '99']);
|
|
927
|
+
expect(lastJsonLog().error.code).toBe('selector_nth_out_of_range');
|
|
928
|
+
expect(process.exitCode).toBeDefined();
|
|
929
|
+
});
|
|
930
|
+
it('rejects malformed --nth on click with usage_error before touching the page', async () => {
|
|
931
|
+
const program = createProgram('', '');
|
|
932
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'click', '.btn', '--nth', 'abc']);
|
|
933
|
+
expect(lastJsonLog().error.code).toBe('usage_error');
|
|
934
|
+
expect(browserState.page.click).not.toHaveBeenCalled();
|
|
935
|
+
expect(process.exitCode).toBeDefined();
|
|
936
|
+
});
|
|
937
|
+
it('type: clicks, waits, then typeText — emits {typed, text, target, matches_n, match_level, autocomplete}', async () => {
|
|
938
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
939
|
+
browserState.page.typeText.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
940
|
+
browserState.page.evaluate.mockResolvedValueOnce(false); // isAutocomplete
|
|
941
|
+
const program = createProgram('', '');
|
|
942
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'type', '#q', 'hello']);
|
|
943
|
+
expect(browserState.page.click).toHaveBeenCalledWith('#q', {});
|
|
944
|
+
expect(browserState.page.wait).toHaveBeenCalledWith(0.3);
|
|
945
|
+
expect(browserState.page.typeText).toHaveBeenCalledWith('#q', 'hello', {});
|
|
946
|
+
expect(lastJsonLog()).toEqual({
|
|
947
|
+
typed: true, text: 'hello', target: '#q', matches_n: 1, match_level: 'exact', autocomplete: false,
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
it('type: waits an extra 0.4s when the input reports autocomplete=true', async () => {
|
|
951
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
952
|
+
browserState.page.typeText.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
953
|
+
browserState.page.evaluate.mockResolvedValueOnce(true);
|
|
954
|
+
const program = createProgram('', '');
|
|
955
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'type', '#q', 'hi']);
|
|
956
|
+
const waitCalls = browserState.page.wait.mock.calls;
|
|
957
|
+
expect(waitCalls).toContainEqual([0.3]);
|
|
958
|
+
expect(waitCalls).toContainEqual([0.4]);
|
|
959
|
+
expect(lastJsonLog().autocomplete).toBe(true);
|
|
960
|
+
expect(lastJsonLog().match_level).toBe('exact');
|
|
961
|
+
});
|
|
962
|
+
it('type: surfaces match_level=reidentified when ref had to be reidentified by fingerprint', async () => {
|
|
963
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'reidentified' });
|
|
964
|
+
browserState.page.typeText.mockResolvedValueOnce({ matches_n: 1, match_level: 'reidentified' });
|
|
965
|
+
browserState.page.evaluate.mockResolvedValueOnce(false);
|
|
966
|
+
const program = createProgram('', '');
|
|
967
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'type', '9', 'hi']);
|
|
968
|
+
// The typeText call is the authoritative match_level source for the `type` envelope.
|
|
969
|
+
expect(lastJsonLog().match_level).toBe('reidentified');
|
|
970
|
+
});
|
|
971
|
+
it('type: forwards --nth to both click and typeText', async () => {
|
|
972
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 5, match_level: 'exact' });
|
|
973
|
+
browserState.page.typeText.mockResolvedValueOnce({ matches_n: 5, match_level: 'exact' });
|
|
974
|
+
const program = createProgram('', '');
|
|
975
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'type', '.field', 'x', '--nth', '3']);
|
|
976
|
+
expect(browserState.page.click).toHaveBeenCalledWith('.field', { nth: 3 });
|
|
977
|
+
expect(browserState.page.typeText).toHaveBeenCalledWith('.field', 'x', { nth: 3 });
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
describe('browser select command', () => {
|
|
981
|
+
const { lastJsonLog } = installSelectorFirstTestHarness('select', () => ({
|
|
982
|
+
evaluate: vi.fn(),
|
|
983
|
+
}));
|
|
984
|
+
it('emits {selected, target, matches_n, match_level} on success', async () => {
|
|
985
|
+
const evalMock = browserState.page.evaluate;
|
|
986
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
|
|
987
|
+
evalMock.mockResolvedValueOnce({ selected: 'US' });
|
|
988
|
+
const program = createProgram('', '');
|
|
989
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'select', '#country', 'US']);
|
|
990
|
+
expect(lastJsonLog()).toEqual({ selected: 'US', target: '#country', matches_n: 1, match_level: 'exact' });
|
|
991
|
+
});
|
|
992
|
+
it('maps "Not a <select>" to a not_a_select error envelope', async () => {
|
|
993
|
+
const evalMock = browserState.page.evaluate;
|
|
994
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
|
|
995
|
+
evalMock.mockResolvedValueOnce({ error: 'Not a <select>' });
|
|
996
|
+
const program = createProgram('', '');
|
|
997
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'select', '#not-select', 'US']);
|
|
998
|
+
const err = lastJsonLog().error;
|
|
999
|
+
expect(err.code).toBe('not_a_select');
|
|
1000
|
+
expect(err.matches_n).toBe(1);
|
|
1001
|
+
expect(process.exitCode).toBeDefined();
|
|
1002
|
+
});
|
|
1003
|
+
it('maps missing-option failures to an option_not_found envelope with available list', async () => {
|
|
1004
|
+
const evalMock = browserState.page.evaluate;
|
|
1005
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
|
|
1006
|
+
evalMock.mockResolvedValueOnce({ error: 'Option "XX" not found', available: ['US', 'CA'] });
|
|
1007
|
+
const program = createProgram('', '');
|
|
1008
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'select', '#country', 'XX']);
|
|
1009
|
+
const err = lastJsonLog().error;
|
|
1010
|
+
expect(err.code).toBe('option_not_found');
|
|
1011
|
+
expect(err.available).toEqual(['US', 'CA']);
|
|
1012
|
+
expect(process.exitCode).toBeDefined();
|
|
1013
|
+
});
|
|
1014
|
+
it('surfaces selector_ambiguous from the resolver before calling selectResolvedJs', async () => {
|
|
1015
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
1016
|
+
ok: false,
|
|
1017
|
+
code: 'selector_ambiguous',
|
|
1018
|
+
message: 'CSS selector ".dropdown" matched 2 elements.',
|
|
1019
|
+
hint: 'Pass --nth <n>.',
|
|
1020
|
+
matches_n: 2,
|
|
1021
|
+
});
|
|
1022
|
+
const program = createProgram('', '');
|
|
1023
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'select', '.dropdown', 'US']);
|
|
1024
|
+
expect(lastJsonLog().error.code).toBe('selector_ambiguous');
|
|
1025
|
+
// The select payload JS must not fire when resolution fails
|
|
1026
|
+
expect(browserState.page.evaluate.mock.calls).toHaveLength(1);
|
|
1027
|
+
expect(process.exitCode).toBeDefined();
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
236
1030
|
describe('findPackageRoot', () => {
|
|
237
1031
|
it('walks up from dist/src to the package root', () => {
|
|
238
1032
|
const packageRoot = path.join('repo-root');
|