@jackwener/opencli 0.6.1 → 0.6.2

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 (39) hide show
  1. package/.github/actions/setup-chrome/action.yml +26 -0
  2. package/.github/workflows/ci.yml +59 -3
  3. package/.github/workflows/e2e-headed.yml +37 -0
  4. package/README.md +19 -0
  5. package/TESTING.md +233 -0
  6. package/dist/bilibili.js +2 -2
  7. package/dist/browser.d.ts +1 -1
  8. package/dist/browser.js +12 -3
  9. package/dist/browser.test.js +56 -16
  10. package/dist/interceptor.test.d.ts +4 -0
  11. package/dist/interceptor.test.js +81 -0
  12. package/dist/output.test.d.ts +3 -0
  13. package/dist/output.test.js +60 -0
  14. package/dist/pipeline/executor.js +0 -6
  15. package/dist/pipeline/executor.test.d.ts +4 -0
  16. package/dist/pipeline/executor.test.js +145 -0
  17. package/dist/pipeline/steps/fetch.js +4 -3
  18. package/dist/registry.d.ts +2 -2
  19. package/package.json +4 -4
  20. package/src/bilibili.ts +2 -2
  21. package/src/browser.test.ts +54 -16
  22. package/src/browser.ts +11 -3
  23. package/src/clis/twitter/notifications.ts +1 -1
  24. package/src/engine.ts +2 -2
  25. package/src/interceptor.test.ts +94 -0
  26. package/src/output.test.ts +69 -4
  27. package/src/pipeline/executor.test.ts +161 -0
  28. package/src/pipeline/executor.ts +0 -5
  29. package/src/pipeline/steps/fetch.ts +4 -3
  30. package/src/registry.ts +2 -2
  31. package/tests/e2e/browser-auth.test.ts +90 -0
  32. package/tests/e2e/browser-public.test.ts +169 -0
  33. package/tests/e2e/helpers.ts +63 -0
  34. package/tests/e2e/management.test.ts +106 -0
  35. package/tests/e2e/output-formats.test.ts +48 -0
  36. package/tests/e2e/public-commands.test.ts +56 -0
  37. package/tests/smoke/api-health.test.ts +72 -0
  38. package/tsconfig.json +1 -0
  39. package/vitest.config.ts +1 -1
@@ -1,9 +1,63 @@
1
+ /**
2
+ * Tests for output.ts: render function format coverage.
3
+ */
1
4
  import { describe, it, expect, vi, afterEach } from 'vitest';
2
5
  import { render } from './output.js';
3
6
  afterEach(() => {
4
7
  vi.restoreAllMocks();
5
8
  });
6
9
  describe('render', () => {
10
+ it('renders JSON output', () => {
11
+ const log = vi.spyOn(console, 'log').mockImplementation(() => { });
12
+ render([{ title: 'Hello', rank: 1 }], { fmt: 'json' });
13
+ expect(log).toHaveBeenCalledOnce();
14
+ const output = log.mock.calls[0]?.[0];
15
+ const parsed = JSON.parse(output);
16
+ expect(parsed).toEqual([{ title: 'Hello', rank: 1 }]);
17
+ });
18
+ it('renders Markdown table output', () => {
19
+ const log = vi.spyOn(console, 'log').mockImplementation(() => { });
20
+ render([{ name: 'Alice', score: 100 }], { fmt: 'md', columns: ['name', 'score'] });
21
+ const calls = log.mock.calls.map(c => c[0]);
22
+ expect(calls[0]).toContain('| name | score |');
23
+ expect(calls[1]).toContain('| --- | --- |');
24
+ expect(calls[2]).toContain('| Alice | 100 |');
25
+ });
26
+ it('renders CSV output with proper quoting', () => {
27
+ const log = vi.spyOn(console, 'log').mockImplementation(() => { });
28
+ render([{ name: 'Alice, Bob', value: 'say "hi"' }], { fmt: 'csv' });
29
+ const calls = log.mock.calls.map(c => c[0]);
30
+ // Header
31
+ expect(calls[0]).toBe('name,value');
32
+ // Values with commas/quotes are quoted
33
+ expect(calls[1]).toContain('"Alice, Bob"');
34
+ expect(calls[1]).toContain('"say ""hi"""');
35
+ });
36
+ it('handles null and undefined data', () => {
37
+ const log = vi.spyOn(console, 'log').mockImplementation(() => { });
38
+ render(null, { fmt: 'json' });
39
+ expect(log).toHaveBeenCalledWith(null);
40
+ });
41
+ it('renders single object as single-row table', () => {
42
+ const log = vi.spyOn(console, 'log').mockImplementation(() => { });
43
+ render({ title: 'Test' }, { fmt: 'json' });
44
+ const output = log.mock.calls[0]?.[0];
45
+ const parsed = JSON.parse(output);
46
+ expect(parsed).toEqual({ title: 'Test' });
47
+ });
48
+ it('handles empty array gracefully', () => {
49
+ const log = vi.spyOn(console, 'log').mockImplementation(() => { });
50
+ render([], { fmt: 'table' });
51
+ // Should show "(no data)" for empty arrays
52
+ expect(log).toHaveBeenCalled();
53
+ });
54
+ it('uses custom columns for CSV', () => {
55
+ const log = vi.spyOn(console, 'log').mockImplementation(() => { });
56
+ render([{ a: 1, b: 2, c: 3 }], { fmt: 'csv', columns: ['a', 'c'] });
57
+ const calls = log.mock.calls.map(c => c[0]);
58
+ expect(calls[0]).toBe('a,c');
59
+ expect(calls[1]).toBe('1,3');
60
+ });
7
61
  it('renders YAML output', () => {
8
62
  const log = vi.spyOn(console, 'log').mockImplementation(() => { });
9
63
  render([{ title: 'Hello', rank: 1 }], { fmt: 'yaml' });
@@ -17,4 +71,10 @@ describe('render', () => {
17
71
  expect(log).toHaveBeenCalledOnce();
18
72
  expect(log.mock.calls[0]?.[0]).toContain('title: Hello');
19
73
  });
74
+ it('handles null values in CSV cells', () => {
75
+ const log = vi.spyOn(console, 'log').mockImplementation(() => { });
76
+ render([{ name: 'test', value: null }], { fmt: 'csv' });
77
+ const calls = log.mock.calls.map(c => c[0]);
78
+ expect(calls[1]).toBe('test,');
79
+ });
20
80
  });
@@ -45,12 +45,6 @@ export async function executePipeline(page, pipeline, ctx = {}) {
45
45
  if (debug)
46
46
  process.stderr.write(` ${chalk.yellow('⚠')} Unknown step: ${op}\n`);
47
47
  }
48
- // Detect error objects returned by steps (e.g. tap store not found)
49
- if (data && typeof data === 'object' && !Array.isArray(data) && data.error) {
50
- process.stderr.write(` ${chalk.yellow('⚠')} ${chalk.yellow(op)}: ${data.error}\n`);
51
- if (data.hint)
52
- process.stderr.write(` ${chalk.dim('💡')} ${chalk.dim(data.hint)}\n`);
53
- }
54
48
  if (debug)
55
49
  debugStepResult(op, data);
56
50
  }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for pipeline/executor.ts: pipeline execution with mock page.
3
+ */
4
+ export {};
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Tests for pipeline/executor.ts: pipeline execution with mock page.
3
+ */
4
+ import { describe, it, expect, vi } from 'vitest';
5
+ import { executePipeline } from './index.js';
6
+ /** Create a minimal mock page for testing */
7
+ function createMockPage(overrides = {}) {
8
+ return {
9
+ goto: vi.fn(),
10
+ evaluate: vi.fn().mockResolvedValue(null),
11
+ snapshot: vi.fn().mockResolvedValue(''),
12
+ click: vi.fn(),
13
+ typeText: vi.fn(),
14
+ pressKey: vi.fn(),
15
+ wait: vi.fn(),
16
+ tabs: vi.fn().mockResolvedValue([]),
17
+ closeTab: vi.fn(),
18
+ newTab: vi.fn(),
19
+ selectTab: vi.fn(),
20
+ networkRequests: vi.fn().mockResolvedValue([]),
21
+ consoleMessages: vi.fn().mockResolvedValue(''),
22
+ scroll: vi.fn(),
23
+ autoScroll: vi.fn(),
24
+ installInterceptor: vi.fn(),
25
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
26
+ ...overrides,
27
+ };
28
+ }
29
+ describe('executePipeline', () => {
30
+ it('returns null for empty pipeline', async () => {
31
+ const result = await executePipeline(null, []);
32
+ expect(result).toBeNull();
33
+ });
34
+ it('skips null/invalid steps', async () => {
35
+ const result = await executePipeline(null, [null, undefined, 42]);
36
+ expect(result).toBeNull();
37
+ });
38
+ it('executes navigate step', async () => {
39
+ const page = createMockPage();
40
+ await executePipeline(page, [
41
+ { navigate: 'https://example.com' },
42
+ ]);
43
+ expect(page.goto).toHaveBeenCalledWith('https://example.com');
44
+ });
45
+ it('executes evaluate + select pipeline', async () => {
46
+ const page = createMockPage({
47
+ evaluate: vi.fn().mockResolvedValue({ data: { list: [{ name: 'a' }, { name: 'b' }] } }),
48
+ });
49
+ const result = await executePipeline(page, [
50
+ { evaluate: '() => ({ data: { list: [{name: "a"}, {name: "b"}] } })' },
51
+ { select: 'data.list' },
52
+ ]);
53
+ expect(result).toEqual([{ name: 'a' }, { name: 'b' }]);
54
+ });
55
+ it('executes map step to transform items', async () => {
56
+ const page = createMockPage({
57
+ evaluate: vi.fn().mockResolvedValue([
58
+ { title: 'Hello', count: 10 },
59
+ { title: 'World', count: 20 },
60
+ ]),
61
+ });
62
+ const result = await executePipeline(page, [
63
+ { evaluate: 'test' },
64
+ { map: { name: '${{ item.title }}', score: '${{ item.count }}' } },
65
+ ]);
66
+ expect(result).toEqual([
67
+ { name: 'Hello', score: 10 },
68
+ { name: 'World', score: 20 },
69
+ ]);
70
+ });
71
+ it('executes limit step', async () => {
72
+ const page = createMockPage({
73
+ evaluate: vi.fn().mockResolvedValue([1, 2, 3, 4, 5]),
74
+ });
75
+ const result = await executePipeline(page, [
76
+ { evaluate: 'test' },
77
+ { limit: '3' },
78
+ ]);
79
+ expect(result).toEqual([1, 2, 3]);
80
+ });
81
+ it('executes sort step', async () => {
82
+ const page = createMockPage({
83
+ evaluate: vi.fn().mockResolvedValue([{ n: 3 }, { n: 1 }, { n: 2 }]),
84
+ });
85
+ const result = await executePipeline(page, [
86
+ { evaluate: 'test' },
87
+ { sort: { by: 'n', order: 'asc' } },
88
+ ]);
89
+ expect(result).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]);
90
+ });
91
+ it('executes sort step with desc order', async () => {
92
+ const page = createMockPage({
93
+ evaluate: vi.fn().mockResolvedValue([{ n: 1 }, { n: 3 }, { n: 2 }]),
94
+ });
95
+ const result = await executePipeline(page, [
96
+ { evaluate: 'test' },
97
+ { sort: { by: 'n', order: 'desc' } },
98
+ ]);
99
+ expect(result).toEqual([{ n: 3 }, { n: 2 }, { n: 1 }]);
100
+ });
101
+ it('executes wait step with number', async () => {
102
+ const page = createMockPage();
103
+ await executePipeline(page, [
104
+ { wait: 2 },
105
+ ]);
106
+ expect(page.wait).toHaveBeenCalledWith(2);
107
+ });
108
+ it('handles unknown steps gracefully in debug mode', async () => {
109
+ const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
110
+ await executePipeline(null, [
111
+ { unknownStep: 'test' },
112
+ ], { debug: true });
113
+ expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Unknown step'));
114
+ stderr.mockRestore();
115
+ });
116
+ it('passes args through template rendering', async () => {
117
+ const page = createMockPage({
118
+ evaluate: vi.fn().mockResolvedValue([1, 2, 3, 4, 5]),
119
+ });
120
+ const result = await executePipeline(page, [
121
+ { evaluate: 'test' },
122
+ { limit: '${{ args.count }}' },
123
+ ], { args: { count: 2 } });
124
+ expect(result).toEqual([1, 2]);
125
+ });
126
+ it('click step calls page.click', async () => {
127
+ const page = createMockPage();
128
+ await executePipeline(page, [
129
+ { click: '@5' },
130
+ ]);
131
+ expect(page.click).toHaveBeenCalledWith('5');
132
+ });
133
+ it('navigate preserves existing data through pipeline', async () => {
134
+ const page = createMockPage({
135
+ evaluate: vi.fn().mockResolvedValue([{ a: 1 }]),
136
+ });
137
+ const result = await executePipeline(page, [
138
+ { evaluate: 'test' },
139
+ { navigate: 'https://example.com' },
140
+ ]);
141
+ // navigate should preserve existing data
142
+ expect(result).toEqual([{ a: 1 }]);
143
+ expect(page.goto).toHaveBeenCalledWith('https://example.com');
144
+ });
145
+ });
@@ -34,11 +34,12 @@ async function fetchSingle(page, url, method, queryParams, headers, args, data)
34
34
  return resp.json();
35
35
  }
36
36
  const headersJs = JSON.stringify(renderedHeaders);
37
- const escapedUrl = finalUrl.replace(/"/g, '\\"');
37
+ const urlJs = JSON.stringify(finalUrl);
38
+ const methodJs = JSON.stringify(method.toUpperCase());
38
39
  return page.evaluate(`
39
40
  async () => {
40
- const resp = await fetch("${escapedUrl}", {
41
- method: "${method}", headers: ${headersJs}, credentials: "include"
41
+ const resp = await fetch(${urlJs}, {
42
+ method: ${methodJs}, headers: ${headersJs}, credentials: "include"
42
43
  });
43
44
  return await resp.json();
44
45
  }
@@ -26,7 +26,7 @@ export interface CliCommand {
26
26
  browser?: boolean;
27
27
  args: Arg[];
28
28
  columns?: string[];
29
- func?: (page: IPage | null, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
29
+ func?: (page: IPage, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
30
30
  pipeline?: any[];
31
31
  timeoutSeconds?: number;
32
32
  source?: string;
@@ -45,7 +45,7 @@ export interface CliOptions {
45
45
  browser?: boolean;
46
46
  args?: Arg[];
47
47
  columns?: string[];
48
- func?: (page: IPage | null, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
48
+ func?: (page: IPage, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
49
49
  pipeline?: any[];
50
50
  timeoutSeconds?: number;
51
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -16,9 +16,9 @@
16
16
  "scripts": {
17
17
  "dev": "tsx src/main.ts",
18
18
  "build": "tsc && npm run clean-yaml && npm run copy-yaml && npm run build-manifest",
19
- "build-manifest": "node dist/build-manifest.js || true",
20
- "clean-yaml": "find dist/clis -name '*.yaml' -o -name '*.yml' 2>/dev/null | xargs rm -f",
21
- "copy-yaml": "find src/clis -name '*.yaml' -o -name '*.yml' | while read f; do d=\"dist/${f#src/}\"; mkdir -p \"$(dirname \"$d\")\"; cp \"$f\" \"$d\"; done",
19
+ "build-manifest": "node dist/build-manifest.js",
20
+ "clean-yaml": "node -e \"const{readdirSync:r,rmSync:d,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(dir){if(!e(dir))return;for(const f of r(dir)){const fp=p.join(dir,f);s(fp).isDirectory()?w(fp):/\\.ya?ml$/.test(f)&&d(fp)}}w('dist/clis')\"",
21
+ "copy-yaml": "node -e \"const{readdirSync:r,copyFileSync:c,mkdirSync:m,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(src,dst){if(!e(src))return;for(const f of r(src)){const sp=p.join(src,f),dp=p.join(dst,f);s(sp).isDirectory()?w(sp,dp):/\\.ya?ml$/.test(f)&&(m(p.dirname(dp),{recursive:!0}),c(sp,dp))}}w('src/clis','dist/clis')\"",
22
22
  "start": "node dist/main.js",
23
23
  "typecheck": "tsc --noEmit",
24
24
  "lint": "tsc --noEmit",
package/src/bilibili.ts CHANGED
@@ -84,10 +84,10 @@ export async function apiGet(
84
84
  }
85
85
 
86
86
  export async function fetchJson(page: IPage, url: string): Promise<any> {
87
- const escapedUrl = url.replace(/"/g, '\\"');
87
+ const urlJs = JSON.stringify(url);
88
88
  return page.evaluate(`
89
89
  async () => {
90
- const res = await fetch("${escapedUrl}", { credentials: "include" });
90
+ const res = await fetch(${urlJs}, { credentials: "include" });
91
91
  return await res.json();
92
92
  }
93
93
  `);
@@ -49,23 +49,61 @@ describe('browser helpers', () => {
49
49
  expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
50
50
  });
51
51
 
52
- it('builds Playwright MCP args with kebab-case executable path', () => {
53
- expect(__test__.buildMcpArgs({
54
- mcpPath: '/tmp/cli.js',
55
- executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
56
- })).toEqual([
57
- '/tmp/cli.js',
58
- '--extension',
59
- '--executable-path',
60
- '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
61
- ]);
52
+ it('builds extension MCP args in local mode (no CI)', () => {
53
+ const savedCI = process.env.CI;
54
+ delete process.env.CI;
55
+ try {
56
+ expect(__test__.buildMcpArgs({
57
+ mcpPath: '/tmp/cli.js',
58
+ executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
59
+ })).toEqual([
60
+ '/tmp/cli.js',
61
+ '--extension',
62
+ '--executable-path',
63
+ '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
64
+ ]);
65
+
66
+ expect(__test__.buildMcpArgs({
67
+ mcpPath: '/tmp/cli.js',
68
+ })).toEqual([
69
+ '/tmp/cli.js',
70
+ '--extension',
71
+ ]);
72
+ } finally {
73
+ if (savedCI !== undefined) {
74
+ process.env.CI = savedCI;
75
+ } else {
76
+ delete process.env.CI;
77
+ }
78
+ }
79
+ });
62
80
 
63
- expect(__test__.buildMcpArgs({
64
- mcpPath: '/tmp/cli.js',
65
- })).toEqual([
66
- '/tmp/cli.js',
67
- '--extension',
68
- ]);
81
+ it('builds standalone MCP args in CI mode', () => {
82
+ const savedCI = process.env.CI;
83
+ process.env.CI = 'true';
84
+ try {
85
+ // CI mode: no --extension — browser launches in standalone headed mode
86
+ expect(__test__.buildMcpArgs({
87
+ mcpPath: '/tmp/cli.js',
88
+ })).toEqual([
89
+ '/tmp/cli.js',
90
+ ]);
91
+
92
+ expect(__test__.buildMcpArgs({
93
+ mcpPath: '/tmp/cli.js',
94
+ executablePath: '/usr/bin/chromium',
95
+ })).toEqual([
96
+ '/tmp/cli.js',
97
+ '--executable-path',
98
+ '/usr/bin/chromium',
99
+ ]);
100
+ } finally {
101
+ if (savedCI !== undefined) {
102
+ process.env.CI = savedCI;
103
+ } else {
104
+ delete process.env.CI;
105
+ }
106
+ }
69
107
  });
70
108
 
71
109
  it('times out slow promises', async () => {
package/src/browser.ts CHANGED
@@ -214,7 +214,7 @@ export class Page implements IPage {
214
214
  return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
215
215
  }
216
216
 
217
- async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
217
+ async scroll(direction: string = 'down', _amount: number = 500): Promise<void> {
218
218
  await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
219
219
  }
220
220
 
@@ -349,6 +349,7 @@ export class PlaywrightMCP {
349
349
  return new Promise<Page>((resolve, reject) => {
350
350
  const isDebug = process.env.DEBUG?.includes('opencli:mcp');
351
351
  const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
352
+ const useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
352
353
  const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
353
354
  const tokenFingerprint = getTokenFingerprint(extensionToken);
354
355
  let stderrBuffer = '';
@@ -392,7 +393,8 @@ export class PlaywrightMCP {
392
393
  executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
393
394
  });
394
395
  if (process.env.OPENCLI_VERBOSE) {
395
- console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
396
+ console.error(`[opencli] Mode: ${useExtension ? 'extension' : 'standalone'}`);
397
+ if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
396
398
  }
397
399
  debugLog(`Spawning node ${mcpArgs.join(' ')}`);
398
400
 
@@ -610,7 +612,13 @@ function appendLimited(current: string, chunk: string, limit: number): string {
610
612
  }
611
613
 
612
614
  function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
613
- const args = [input.mcpPath, '--extension'];
615
+ const args = [input.mcpPath];
616
+ if (!process.env.CI) {
617
+ // Local: always connect to user's running Chrome via MCP Bridge extension
618
+ args.push('--extension');
619
+ }
620
+ // CI: standalone mode — @playwright/mcp launches its own browser (headed by default).
621
+ // xvfb provides a virtual display for headed mode in GitHub Actions.
614
622
  if (input.executablePath) {
615
623
  args.push('--executable-path', input.executablePath);
616
624
  }
@@ -30,7 +30,7 @@ cli({
30
30
  let results: any[] = [];
31
31
  for (const req of requests) {
32
32
  try {
33
- let instructions = [];
33
+ let instructions: any[] = [];
34
34
  if (req.data?.data?.viewer?.timeline_response?.timeline?.instructions) {
35
35
  instructions = req.data.data.viewer.timeline_response.timeline.instructions;
36
36
  } else if (req.data?.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
package/src/engine.ts CHANGED
@@ -185,7 +185,7 @@ export async function executeCommand(
185
185
  const { getRegistry, fullName } = await import('./registry.js');
186
186
  const updated = getRegistry().get(fullName(cmd));
187
187
  if (updated && updated.func) {
188
- return updated.func(page, kwargs, debug);
188
+ return updated.func(page!, kwargs, debug);
189
189
  }
190
190
  if (updated && updated.pipeline) {
191
191
  return executePipeline(page, updated.pipeline, { args: kwargs, debug });
@@ -193,7 +193,7 @@ export async function executeCommand(
193
193
  }
194
194
 
195
195
  if (cmd.func) {
196
- return cmd.func(page, kwargs, debug);
196
+ return cmd.func(page!, kwargs, debug);
197
197
  }
198
198
  if (cmd.pipeline) {
199
199
  return executePipeline(page, cmd.pipeline, { args: kwargs, debug });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests for interceptor.ts: JavaScript code generators for XHR/Fetch interception.
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { generateInterceptorJs, generateReadInterceptedJs, generateTapInterceptorJs } from './interceptor.js';
7
+
8
+ describe('generateInterceptorJs', () => {
9
+ it('generates valid JavaScript function source', () => {
10
+ const js = generateInterceptorJs('"api/search"');
11
+ expect(js).toContain('window.fetch');
12
+ expect(js).toContain('XMLHttpRequest');
13
+ expect(js).toContain('"api/search"');
14
+ // Should be a function expression wrapping
15
+ expect(js.trim()).toMatch(/^\(\)\s*=>/);
16
+ });
17
+
18
+ it('uses default array name and patch guard', () => {
19
+ const js = generateInterceptorJs('"test"');
20
+ expect(js).toContain('__opencli_intercepted');
21
+ expect(js).toContain('__opencli_interceptor_patched');
22
+ });
23
+
24
+ it('uses custom array name and patch guard', () => {
25
+ const js = generateInterceptorJs('"test"', {
26
+ arrayName: '__my_data',
27
+ patchGuard: '__my_guard',
28
+ });
29
+ expect(js).toContain('__my_data');
30
+ expect(js).toContain('__my_guard');
31
+ expect(js).not.toContain('__opencli_intercepted');
32
+ });
33
+
34
+ it('includes fetch clone and json parsing', () => {
35
+ const js = generateInterceptorJs('"api"');
36
+ expect(js).toContain('response.clone()');
37
+ expect(js).toContain('clone.json()');
38
+ });
39
+
40
+ it('includes XHR open and send patching', () => {
41
+ const js = generateInterceptorJs('"api"');
42
+ expect(js).toContain('XMLHttpRequest.prototype');
43
+ expect(js).toContain('__origOpen');
44
+ expect(js).toContain('__origSend');
45
+ });
46
+ });
47
+
48
+ describe('generateReadInterceptedJs', () => {
49
+ it('generates valid JavaScript to read and clear data', () => {
50
+ const js = generateReadInterceptedJs();
51
+ expect(js).toContain('__opencli_intercepted');
52
+ // Should clear the array after reading
53
+ expect(js).toContain('= []');
54
+ });
55
+
56
+ it('uses custom array name', () => {
57
+ const js = generateReadInterceptedJs('__custom_arr');
58
+ expect(js).toContain('__custom_arr');
59
+ expect(js).not.toContain('__opencli_intercepted');
60
+ });
61
+ });
62
+
63
+ describe('generateTapInterceptorJs', () => {
64
+ it('returns all required fields', () => {
65
+ const tap = generateTapInterceptorJs('"api/data"');
66
+
67
+ expect(tap.setupVar).toBeDefined();
68
+ expect(tap.capturedVar).toBe('captured');
69
+ expect(tap.promiseVar).toBe('capturePromise');
70
+ expect(tap.resolveVar).toBe('captureResolve');
71
+ expect(tap.fetchPatch).toBeDefined();
72
+ expect(tap.xhrPatch).toBeDefined();
73
+ expect(tap.restorePatch).toBeDefined();
74
+ });
75
+
76
+ it('contains the capture pattern in setup', () => {
77
+ const tap = generateTapInterceptorJs('"my-pattern"');
78
+ expect(tap.setupVar).toContain('"my-pattern"');
79
+ });
80
+
81
+ it('restores original fetch and XHR in restorePatch', () => {
82
+ const tap = generateTapInterceptorJs('"test"');
83
+ expect(tap.restorePatch).toContain('origFetch');
84
+ expect(tap.restorePatch).toContain('origXhrOpen');
85
+ expect(tap.restorePatch).toContain('origXhrSend');
86
+ });
87
+
88
+ it('uses first-match capture (only first response)', () => {
89
+ const tap = generateTapInterceptorJs('"test"');
90
+ // Both fetch and xhr patches should check !captured before storing
91
+ expect(tap.fetchPatch).toContain('!captured');
92
+ expect(tap.xhrPatch).toContain('!captured');
93
+ });
94
+ });
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Tests for output.ts: render function format coverage.
3
+ */
4
+
1
5
  import { describe, it, expect, vi, afterEach } from 'vitest';
2
6
  import { render } from './output.js';
3
7
 
@@ -6,11 +10,67 @@ afterEach(() => {
6
10
  });
7
11
 
8
12
  describe('render', () => {
9
- it('renders YAML output', () => {
13
+ it('renders JSON output', () => {
14
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
15
+ render([{ title: 'Hello', rank: 1 }], { fmt: 'json' });
16
+ expect(log).toHaveBeenCalledOnce();
17
+ const output = log.mock.calls[0]?.[0];
18
+ const parsed = JSON.parse(output);
19
+ expect(parsed).toEqual([{ title: 'Hello', rank: 1 }]);
20
+ });
21
+
22
+ it('renders Markdown table output', () => {
10
23
  const log = vi.spyOn(console, 'log').mockImplementation(() => {});
24
+ render([{ name: 'Alice', score: 100 }], { fmt: 'md', columns: ['name', 'score'] });
25
+ const calls = log.mock.calls.map(c => c[0]);
26
+ expect(calls[0]).toContain('| name | score |');
27
+ expect(calls[1]).toContain('| --- | --- |');
28
+ expect(calls[2]).toContain('| Alice | 100 |');
29
+ });
11
30
 
12
- render([{ title: 'Hello', rank: 1 }], { fmt: 'yaml' });
31
+ it('renders CSV output with proper quoting', () => {
32
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
33
+ render([{ name: 'Alice, Bob', value: 'say "hi"' }], { fmt: 'csv' });
34
+ const calls = log.mock.calls.map(c => c[0]);
35
+ // Header
36
+ expect(calls[0]).toBe('name,value');
37
+ // Values with commas/quotes are quoted
38
+ expect(calls[1]).toContain('"Alice, Bob"');
39
+ expect(calls[1]).toContain('"say ""hi"""');
40
+ });
41
+
42
+ it('handles null and undefined data', () => {
43
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
44
+ render(null, { fmt: 'json' });
45
+ expect(log).toHaveBeenCalledWith(null);
46
+ });
47
+
48
+ it('renders single object as single-row table', () => {
49
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
50
+ render({ title: 'Test' }, { fmt: 'json' });
51
+ const output = log.mock.calls[0]?.[0];
52
+ const parsed = JSON.parse(output);
53
+ expect(parsed).toEqual({ title: 'Test' });
54
+ });
55
+
56
+ it('handles empty array gracefully', () => {
57
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
58
+ render([], { fmt: 'table' });
59
+ // Should show "(no data)" for empty arrays
60
+ expect(log).toHaveBeenCalled();
61
+ });
13
62
 
63
+ it('uses custom columns for CSV', () => {
64
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
65
+ render([{ a: 1, b: 2, c: 3 }], { fmt: 'csv', columns: ['a', 'c'] });
66
+ const calls = log.mock.calls.map(c => c[0]);
67
+ expect(calls[0]).toBe('a,c');
68
+ expect(calls[1]).toBe('1,3');
69
+ });
70
+
71
+ it('renders YAML output', () => {
72
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
73
+ render([{ title: 'Hello', rank: 1 }], { fmt: 'yaml' });
14
74
  expect(log).toHaveBeenCalledOnce();
15
75
  expect(log.mock.calls[0]?.[0]).toContain('- title: Hello');
16
76
  expect(log.mock.calls[0]?.[0]).toContain('rank: 1');
@@ -18,10 +78,15 @@ describe('render', () => {
18
78
 
19
79
  it('renders yml alias as YAML output', () => {
20
80
  const log = vi.spyOn(console, 'log').mockImplementation(() => {});
21
-
22
81
  render({ title: 'Hello' }, { fmt: 'yml' });
23
-
24
82
  expect(log).toHaveBeenCalledOnce();
25
83
  expect(log.mock.calls[0]?.[0]).toContain('title: Hello');
26
84
  });
85
+
86
+ it('handles null values in CSV cells', () => {
87
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
88
+ render([{ name: 'test', value: null }], { fmt: 'csv' });
89
+ const calls = log.mock.calls.map(c => c[0]);
90
+ expect(calls[1]).toBe('test,');
91
+ });
27
92
  });