@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
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Tests for pipeline/executor.ts: pipeline execution with mock page.
3
+ */
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import { executePipeline } from './index.js';
7
+ import type { IPage } from '../types.js';
8
+
9
+ /** Create a minimal mock page for testing */
10
+ function createMockPage(overrides: Partial<IPage> = {}): IPage {
11
+ return {
12
+ goto: vi.fn(),
13
+ evaluate: vi.fn().mockResolvedValue(null),
14
+ snapshot: vi.fn().mockResolvedValue(''),
15
+ click: vi.fn(),
16
+ typeText: vi.fn(),
17
+ pressKey: vi.fn(),
18
+ wait: vi.fn(),
19
+ tabs: vi.fn().mockResolvedValue([]),
20
+ closeTab: vi.fn(),
21
+ newTab: vi.fn(),
22
+ selectTab: vi.fn(),
23
+ networkRequests: vi.fn().mockResolvedValue([]),
24
+ consoleMessages: vi.fn().mockResolvedValue(''),
25
+ scroll: vi.fn(),
26
+ autoScroll: vi.fn(),
27
+ installInterceptor: vi.fn(),
28
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ describe('executePipeline', () => {
34
+ it('returns null for empty pipeline', async () => {
35
+ const result = await executePipeline(null, []);
36
+ expect(result).toBeNull();
37
+ });
38
+
39
+ it('skips null/invalid steps', async () => {
40
+ const result = await executePipeline(null, [null, undefined, 42] as any);
41
+ expect(result).toBeNull();
42
+ });
43
+
44
+ it('executes navigate step', async () => {
45
+ const page = createMockPage();
46
+ await executePipeline(page, [
47
+ { navigate: 'https://example.com' },
48
+ ]);
49
+ expect(page.goto).toHaveBeenCalledWith('https://example.com');
50
+ });
51
+
52
+ it('executes evaluate + select pipeline', async () => {
53
+ const page = createMockPage({
54
+ evaluate: vi.fn().mockResolvedValue({ data: { list: [{ name: 'a' }, { name: 'b' }] } }),
55
+ });
56
+ const result = await executePipeline(page, [
57
+ { evaluate: '() => ({ data: { list: [{name: "a"}, {name: "b"}] } })' },
58
+ { select: 'data.list' },
59
+ ]);
60
+ expect(result).toEqual([{ name: 'a' }, { name: 'b' }]);
61
+ });
62
+
63
+ it('executes map step to transform items', async () => {
64
+ const page = createMockPage({
65
+ evaluate: vi.fn().mockResolvedValue([
66
+ { title: 'Hello', count: 10 },
67
+ { title: 'World', count: 20 },
68
+ ]),
69
+ });
70
+ const result = await executePipeline(page, [
71
+ { evaluate: 'test' },
72
+ { map: { name: '${{ item.title }}', score: '${{ item.count }}' } },
73
+ ]);
74
+ expect(result).toEqual([
75
+ { name: 'Hello', score: 10 },
76
+ { name: 'World', score: 20 },
77
+ ]);
78
+ });
79
+
80
+ it('executes limit step', async () => {
81
+ const page = createMockPage({
82
+ evaluate: vi.fn().mockResolvedValue([1, 2, 3, 4, 5]),
83
+ });
84
+ const result = await executePipeline(page, [
85
+ { evaluate: 'test' },
86
+ { limit: '3' },
87
+ ]);
88
+ expect(result).toEqual([1, 2, 3]);
89
+ });
90
+
91
+ it('executes sort step', async () => {
92
+ const page = createMockPage({
93
+ evaluate: vi.fn().mockResolvedValue([{ n: 3 }, { n: 1 }, { n: 2 }]),
94
+ });
95
+ const result = await executePipeline(page, [
96
+ { evaluate: 'test' },
97
+ { sort: { by: 'n', order: 'asc' } },
98
+ ]);
99
+ expect(result).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]);
100
+ });
101
+
102
+ it('executes sort step with desc order', async () => {
103
+ const page = createMockPage({
104
+ evaluate: vi.fn().mockResolvedValue([{ n: 1 }, { n: 3 }, { n: 2 }]),
105
+ });
106
+ const result = await executePipeline(page, [
107
+ { evaluate: 'test' },
108
+ { sort: { by: 'n', order: 'desc' } },
109
+ ]);
110
+ expect(result).toEqual([{ n: 3 }, { n: 2 }, { n: 1 }]);
111
+ });
112
+
113
+ it('executes wait step with number', async () => {
114
+ const page = createMockPage();
115
+ await executePipeline(page, [
116
+ { wait: 2 },
117
+ ]);
118
+ expect(page.wait).toHaveBeenCalledWith(2);
119
+ });
120
+
121
+ it('handles unknown steps gracefully in debug mode', async () => {
122
+ const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
123
+ await executePipeline(null, [
124
+ { unknownStep: 'test' },
125
+ ], { debug: true });
126
+ expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Unknown step'));
127
+ stderr.mockRestore();
128
+ });
129
+
130
+ it('passes args through template rendering', async () => {
131
+ const page = createMockPage({
132
+ evaluate: vi.fn().mockResolvedValue([1, 2, 3, 4, 5]),
133
+ });
134
+ const result = await executePipeline(page, [
135
+ { evaluate: 'test' },
136
+ { limit: '${{ args.count }}' },
137
+ ], { args: { count: 2 } });
138
+ expect(result).toEqual([1, 2]);
139
+ });
140
+
141
+ it('click step calls page.click', async () => {
142
+ const page = createMockPage();
143
+ await executePipeline(page, [
144
+ { click: '@5' },
145
+ ]);
146
+ expect(page.click).toHaveBeenCalledWith('5');
147
+ });
148
+
149
+ it('navigate preserves existing data through pipeline', async () => {
150
+ const page = createMockPage({
151
+ evaluate: vi.fn().mockResolvedValue([{ a: 1 }]),
152
+ });
153
+ const result = await executePipeline(page, [
154
+ { evaluate: 'test' },
155
+ { navigate: 'https://example.com' },
156
+ ]);
157
+ // navigate should preserve existing data
158
+ expect(result).toEqual([{ a: 1 }]);
159
+ expect(page.goto).toHaveBeenCalledWith('https://example.com');
160
+ });
161
+ });
@@ -60,11 +60,6 @@ export async function executePipeline(
60
60
  if (debug) process.stderr.write(` ${chalk.yellow('⚠')} Unknown step: ${op}\n`);
61
61
  }
62
62
 
63
- // Detect error objects returned by steps (e.g. tap store not found)
64
- if (data && typeof data === 'object' && !Array.isArray(data) && data.error) {
65
- process.stderr.write(` ${chalk.yellow('⚠')} ${chalk.yellow(op)}: ${data.error}\n`);
66
- if (data.hint) process.stderr.write(` ${chalk.dim('💡')} ${chalk.dim(data.hint)}\n`);
67
- }
68
63
  if (debug) debugStepResult(op, data);
69
64
  }
70
65
  }
@@ -45,11 +45,12 @@ async function fetchSingle(
45
45
  }
46
46
 
47
47
  const headersJs = JSON.stringify(renderedHeaders);
48
- const escapedUrl = finalUrl.replace(/"/g, '\\"');
48
+ const urlJs = JSON.stringify(finalUrl);
49
+ const methodJs = JSON.stringify(method.toUpperCase());
49
50
  return page.evaluate(`
50
51
  async () => {
51
- const resp = await fetch("${escapedUrl}", {
52
- method: "${method}", headers: ${headersJs}, credentials: "include"
52
+ const resp = await fetch(${urlJs}, {
53
+ method: ${methodJs}, headers: ${headersJs}, credentials: "include"
53
54
  });
54
55
  return await resp.json();
55
56
  }
package/src/registry.ts CHANGED
@@ -30,7 +30,7 @@ export interface CliCommand {
30
30
  browser?: boolean;
31
31
  args: Arg[];
32
32
  columns?: string[];
33
- func?: (page: IPage | null, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
33
+ func?: (page: IPage, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
34
34
  pipeline?: any[];
35
35
  timeoutSeconds?: number;
36
36
  source?: string;
@@ -50,7 +50,7 @@ export interface CliOptions {
50
50
  browser?: boolean;
51
51
  args?: Arg[];
52
52
  columns?: string[];
53
- func?: (page: IPage | null, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
53
+ func?: (page: IPage, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
54
54
  pipeline?: any[];
55
55
  timeoutSeconds?: number;
56
56
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * E2E tests for login-required browser commands.
3
+ * These commands REQUIRE authentication (cookie/session).
4
+ * In CI (headless, no login), they should fail gracefully — NOT crash.
5
+ *
6
+ * These tests verify the error handling path, not the data extraction.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import { runCli } from './helpers.js';
11
+
12
+ /**
13
+ * Verify a login-required command fails gracefully (no crash, no hang).
14
+ * Acceptable outcomes: exit code 1 with error message, OR timeout handled.
15
+ */
16
+ async function expectGracefulAuthFailure(args: string[], label: string) {
17
+ const { stdout, stderr, code } = await runCli(args, { timeout: 60_000 });
18
+ // Should either fail with exit code 1 (error message) or succeed with empty data
19
+ // The key assertion: it should NOT hang forever or crash with unhandled exception
20
+ if (code !== 0) {
21
+ // Verify stderr has a meaningful error, not an unhandled crash
22
+ const output = stderr + stdout;
23
+ expect(output.length).toBeGreaterThan(0);
24
+ }
25
+ // If it somehow succeeds (e.g., partial public data), that's fine too
26
+ }
27
+
28
+ describe('login-required commands — graceful failure', () => {
29
+
30
+ // ── bilibili (requires cookie session) ──
31
+ it('bilibili me fails gracefully without login', async () => {
32
+ await expectGracefulAuthFailure(['bilibili', 'me', '-f', 'json'], 'bilibili me');
33
+ }, 60_000);
34
+
35
+ it('bilibili dynamic fails gracefully without login', async () => {
36
+ await expectGracefulAuthFailure(['bilibili', 'dynamic', '--limit', '3', '-f', 'json'], 'bilibili dynamic');
37
+ }, 60_000);
38
+
39
+ it('bilibili favorite fails gracefully without login', async () => {
40
+ await expectGracefulAuthFailure(['bilibili', 'favorite', '--limit', '3', '-f', 'json'], 'bilibili favorite');
41
+ }, 60_000);
42
+
43
+ it('bilibili history fails gracefully without login', async () => {
44
+ await expectGracefulAuthFailure(['bilibili', 'history', '--limit', '3', '-f', 'json'], 'bilibili history');
45
+ }, 60_000);
46
+
47
+ it('bilibili following fails gracefully without login', async () => {
48
+ await expectGracefulAuthFailure(['bilibili', 'following', '--limit', '3', '-f', 'json'], 'bilibili following');
49
+ }, 60_000);
50
+
51
+ // ── twitter (requires login) ──
52
+ it('twitter bookmarks fails gracefully without login', async () => {
53
+ await expectGracefulAuthFailure(['twitter', 'bookmarks', '--limit', '3', '-f', 'json'], 'twitter bookmarks');
54
+ }, 60_000);
55
+
56
+ it('twitter timeline fails gracefully without login', async () => {
57
+ await expectGracefulAuthFailure(['twitter', 'timeline', '--limit', '3', '-f', 'json'], 'twitter timeline');
58
+ }, 60_000);
59
+
60
+ it('twitter notifications fails gracefully without login', async () => {
61
+ await expectGracefulAuthFailure(['twitter', 'notifications', '--limit', '3', '-f', 'json'], 'twitter notifications');
62
+ }, 60_000);
63
+
64
+ // ── v2ex (requires login) ──
65
+ it('v2ex me fails gracefully without login', async () => {
66
+ await expectGracefulAuthFailure(['v2ex', 'me', '-f', 'json'], 'v2ex me');
67
+ }, 60_000);
68
+
69
+ it('v2ex notifications fails gracefully without login', async () => {
70
+ await expectGracefulAuthFailure(['v2ex', 'notifications', '--limit', '3', '-f', 'json'], 'v2ex notifications');
71
+ }, 60_000);
72
+
73
+ // ── xueqiu (requires login) ──
74
+ it('xueqiu feed fails gracefully without login', async () => {
75
+ await expectGracefulAuthFailure(['xueqiu', 'feed', '--limit', '3', '-f', 'json'], 'xueqiu feed');
76
+ }, 60_000);
77
+
78
+ it('xueqiu watchlist fails gracefully without login', async () => {
79
+ await expectGracefulAuthFailure(['xueqiu', 'watchlist', '-f', 'json'], 'xueqiu watchlist');
80
+ }, 60_000);
81
+
82
+ // ── xiaohongshu (requires login) ──
83
+ it('xiaohongshu feed fails gracefully without login', async () => {
84
+ await expectGracefulAuthFailure(['xiaohongshu', 'feed', '--limit', '3', '-f', 'json'], 'xiaohongshu feed');
85
+ }, 60_000);
86
+
87
+ it('xiaohongshu notifications fails gracefully without login', async () => {
88
+ await expectGracefulAuthFailure(['xiaohongshu', 'notifications', '--limit', '3', '-f', 'json'], 'xiaohongshu notifications');
89
+ }, 60_000);
90
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * E2E tests for browser commands that access PUBLIC data (no login required).
3
+ * These use OPENCLI_HEADLESS=1 to launch a headless Chromium.
4
+ *
5
+ * NOTE: Some sites may block headless browsers with bot detection.
6
+ * Tests are wrapped with tryBrowserCommand() which allows graceful failure.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import { runCli, parseJsonOutput } from './helpers.js';
11
+
12
+ /**
13
+ * Run a browser command — returns parsed data or null on failure.
14
+ */
15
+ async function tryBrowserCommand(args: string[]): Promise<any[] | null> {
16
+ const { stdout, code } = await runCli(args, { timeout: 60_000 });
17
+ if (code !== 0) return null;
18
+ try {
19
+ const data = parseJsonOutput(stdout);
20
+ return Array.isArray(data) ? data : null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Assert browser command returns data OR log a warning if blocked.
28
+ * Empty results (bot detection, geo-blocking) are treated as a warning, not a failure.
29
+ */
30
+ function expectDataOrSkip(data: any[] | null, label: string) {
31
+ if (data === null || data.length === 0) {
32
+ console.warn(`${label}: skipped — no data returned (likely bot detection or geo-blocking)`);
33
+ return;
34
+ }
35
+ expect(data.length).toBeGreaterThanOrEqual(1);
36
+ }
37
+
38
+ describe('browser public-data commands E2E', () => {
39
+
40
+ // ── bbc (browser: true, strategy: public) ──
41
+ it('bbc news returns headlines', async () => {
42
+ const data = await tryBrowserCommand(['bbc', 'news', '--limit', '3', '-f', 'json']);
43
+ expectDataOrSkip(data, 'bbc news');
44
+ if (data) {
45
+ expect(data[0]).toHaveProperty('title');
46
+ }
47
+ }, 60_000);
48
+
49
+ // ── v2ex daily (browser: true) ──
50
+ it('v2ex daily returns topics', async () => {
51
+ const data = await tryBrowserCommand(['v2ex', 'daily', '--limit', '3', '-f', 'json']);
52
+ expectDataOrSkip(data, 'v2ex daily');
53
+ }, 60_000);
54
+
55
+ // ── bilibili (browser: true, cookie strategy) ──
56
+ it('bilibili hot returns trending videos', async () => {
57
+ const data = await tryBrowserCommand(['bilibili', 'hot', '--limit', '5', '-f', 'json']);
58
+ expectDataOrSkip(data, 'bilibili hot');
59
+ if (data) {
60
+ expect(data[0]).toHaveProperty('title');
61
+ }
62
+ }, 60_000);
63
+
64
+ it('bilibili ranking returns ranked videos', async () => {
65
+ const data = await tryBrowserCommand(['bilibili', 'ranking', '--limit', '5', '-f', 'json']);
66
+ expectDataOrSkip(data, 'bilibili ranking');
67
+ }, 60_000);
68
+
69
+ it('bilibili search returns results', async () => {
70
+ const data = await tryBrowserCommand(['bilibili', 'search', '--keyword', 'typescript', '--limit', '3', '-f', 'json']);
71
+ expectDataOrSkip(data, 'bilibili search');
72
+ }, 60_000);
73
+
74
+ // ── weibo (browser: true, cookie strategy) ──
75
+ it('weibo hot returns trending topics', async () => {
76
+ const data = await tryBrowserCommand(['weibo', 'hot', '--limit', '5', '-f', 'json']);
77
+ expectDataOrSkip(data, 'weibo hot');
78
+ }, 60_000);
79
+
80
+ // ── zhihu (browser: true, cookie strategy) ──
81
+ it('zhihu hot returns trending questions', async () => {
82
+ const data = await tryBrowserCommand(['zhihu', 'hot', '--limit', '5', '-f', 'json']);
83
+ expectDataOrSkip(data, 'zhihu hot');
84
+ if (data) {
85
+ expect(data[0]).toHaveProperty('title');
86
+ }
87
+ }, 60_000);
88
+
89
+ it('zhihu search returns results', async () => {
90
+ const data = await tryBrowserCommand(['zhihu', 'search', '--keyword', 'playwright', '--limit', '3', '-f', 'json']);
91
+ expectDataOrSkip(data, 'zhihu search');
92
+ }, 60_000);
93
+
94
+ // ── reddit (browser: true, cookie strategy) ──
95
+ it('reddit hot returns posts', async () => {
96
+ const data = await tryBrowserCommand(['reddit', 'hot', '--limit', '5', '-f', 'json']);
97
+ expectDataOrSkip(data, 'reddit hot');
98
+ }, 60_000);
99
+
100
+ it('reddit frontpage returns posts', async () => {
101
+ const data = await tryBrowserCommand(['reddit', 'frontpage', '--limit', '5', '-f', 'json']);
102
+ expectDataOrSkip(data, 'reddit frontpage');
103
+ }, 60_000);
104
+
105
+ // ── twitter (browser: true) ──
106
+ it('twitter trending returns trends', async () => {
107
+ const data = await tryBrowserCommand(['twitter', 'trending', '--limit', '5', '-f', 'json']);
108
+ expectDataOrSkip(data, 'twitter trending');
109
+ }, 60_000);
110
+
111
+ // ── xueqiu (browser: true, cookie strategy) ──
112
+ it('xueqiu hot returns hot posts', async () => {
113
+ const data = await tryBrowserCommand(['xueqiu', 'hot', '--limit', '5', '-f', 'json']);
114
+ expectDataOrSkip(data, 'xueqiu hot');
115
+ }, 60_000);
116
+
117
+ it('xueqiu hot-stock returns stocks', async () => {
118
+ const data = await tryBrowserCommand(['xueqiu', 'hot-stock', '--limit', '5', '-f', 'json']);
119
+ expectDataOrSkip(data, 'xueqiu hot-stock');
120
+ }, 60_000);
121
+
122
+ // ── reuters (browser: true) ──
123
+ it('reuters search returns articles', async () => {
124
+ const data = await tryBrowserCommand(['reuters', 'search', '--keyword', 'technology', '--limit', '3', '-f', 'json']);
125
+ expectDataOrSkip(data, 'reuters search');
126
+ }, 60_000);
127
+
128
+ // ── youtube (browser: true) ──
129
+ it('youtube search returns videos', async () => {
130
+ const data = await tryBrowserCommand(['youtube', 'search', '--keyword', 'typescript tutorial', '--limit', '3', '-f', 'json']);
131
+ expectDataOrSkip(data, 'youtube search');
132
+ }, 60_000);
133
+
134
+ // ── smzdm (browser: true) ──
135
+ it('smzdm search returns deals', async () => {
136
+ const data = await tryBrowserCommand(['smzdm', 'search', '--keyword', '键盘', '--limit', '3', '-f', 'json']);
137
+ expectDataOrSkip(data, 'smzdm search');
138
+ }, 60_000);
139
+
140
+ // ── boss (browser: true) ──
141
+ it('boss search returns jobs', async () => {
142
+ const data = await tryBrowserCommand(['boss', 'search', '--keyword', 'golang', '--limit', '3', '-f', 'json']);
143
+ expectDataOrSkip(data, 'boss search');
144
+ }, 60_000);
145
+
146
+ // ── ctrip (browser: true) ──
147
+ it('ctrip search returns flights', async () => {
148
+ const data = await tryBrowserCommand(['ctrip', 'search', '-f', 'json']);
149
+ expectDataOrSkip(data, 'ctrip search');
150
+ }, 60_000);
151
+
152
+ // ── coupang (browser: true) ──
153
+ it('coupang search returns products', async () => {
154
+ const data = await tryBrowserCommand(['coupang', 'search', '--keyword', 'laptop', '--limit', '3', '-f', 'json']);
155
+ expectDataOrSkip(data, 'coupang search');
156
+ }, 60_000);
157
+
158
+ // ── xiaohongshu (browser: true) ──
159
+ it('xiaohongshu search returns notes', async () => {
160
+ const data = await tryBrowserCommand(['xiaohongshu', 'search', '--keyword', '美食', '--limit', '3', '-f', 'json']);
161
+ expectDataOrSkip(data, 'xiaohongshu search');
162
+ }, 60_000);
163
+
164
+ // ── yahoo-finance (browser: true) ──
165
+ it('yahoo-finance quote returns stock data', async () => {
166
+ const data = await tryBrowserCommand(['yahoo-finance', 'quote', '--symbol', 'AAPL', '-f', 'json']);
167
+ expectDataOrSkip(data, 'yahoo-finance quote');
168
+ }, 60_000);
169
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Shared helpers for E2E tests.
3
+ * Runs the built opencli binary as a subprocess.
4
+ */
5
+
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import * as path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const exec = promisify(execFile);
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const ROOT = path.resolve(__dirname, '../..');
14
+ const MAIN = path.join(ROOT, 'dist', 'main.js');
15
+
16
+ export interface CliResult {
17
+ stdout: string;
18
+ stderr: string;
19
+ code: number;
20
+ }
21
+
22
+ /**
23
+ * Run `opencli` as a child process with the given arguments.
24
+ * Without PLAYWRIGHT_MCP_EXTENSION_TOKEN, opencli auto-launches its own browser.
25
+ */
26
+ export async function runCli(
27
+ args: string[],
28
+ opts: { timeout?: number; env?: Record<string, string> } = {},
29
+ ): Promise<CliResult> {
30
+ const timeout = opts.timeout ?? 30_000;
31
+ try {
32
+ const { stdout, stderr } = await exec('node', [MAIN, ...args], {
33
+ cwd: ROOT,
34
+ timeout,
35
+ env: {
36
+ ...process.env,
37
+ // Prevent chalk colors from polluting test assertions
38
+ FORCE_COLOR: '0',
39
+ NO_COLOR: '1',
40
+ ...opts.env,
41
+ },
42
+ });
43
+ return { stdout, stderr, code: 0 };
44
+ } catch (err: any) {
45
+ return {
46
+ stdout: err.stdout ?? '',
47
+ stderr: err.stderr ?? '',
48
+ code: err.code ?? 1,
49
+ };
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Parse JSON output from a CLI command.
55
+ * Throws a descriptive error if parsing fails.
56
+ */
57
+ export function parseJsonOutput(stdout: string): any {
58
+ try {
59
+ return JSON.parse(stdout.trim());
60
+ } catch {
61
+ throw new Error(`Failed to parse CLI JSON output:\n${stdout.slice(0, 500)}`);
62
+ }
63
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * E2E tests for management/built-in commands.
3
+ * These commands require no external network access (except verify --smoke).
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { runCli, parseJsonOutput } from './helpers.js';
8
+
9
+ describe('management commands E2E', () => {
10
+
11
+ // ── list ──
12
+ it('list shows all registered commands', async () => {
13
+ const { stdout, code } = await runCli(['list', '-f', 'json']);
14
+ expect(code).toBe(0);
15
+ const data = parseJsonOutput(stdout);
16
+ expect(Array.isArray(data)).toBe(true);
17
+ // Should have 50+ commands across 18 sites
18
+ expect(data.length).toBeGreaterThan(50);
19
+ // Each entry should have the standard fields
20
+ expect(data[0]).toHaveProperty('command');
21
+ expect(data[0]).toHaveProperty('site');
22
+ expect(data[0]).toHaveProperty('name');
23
+ expect(data[0]).toHaveProperty('strategy');
24
+ expect(data[0]).toHaveProperty('browser');
25
+ });
26
+
27
+ it('list default table format renders sites', async () => {
28
+ const { stdout, code } = await runCli(['list']);
29
+ expect(code).toBe(0);
30
+ // Should contain site names
31
+ expect(stdout).toContain('hackernews');
32
+ expect(stdout).toContain('bilibili');
33
+ expect(stdout).toContain('twitter');
34
+ expect(stdout).toContain('commands across');
35
+ });
36
+
37
+ it('list -f yaml produces valid yaml', async () => {
38
+ const { stdout, code } = await runCli(['list', '-f', 'yaml']);
39
+ expect(code).toBe(0);
40
+ expect(stdout).toContain('command:');
41
+ expect(stdout).toContain('site:');
42
+ });
43
+
44
+ it('list -f csv produces valid csv', async () => {
45
+ const { stdout, code } = await runCli(['list', '-f', 'csv']);
46
+ expect(code).toBe(0);
47
+ const lines = stdout.trim().split('\n');
48
+ expect(lines.length).toBeGreaterThan(50);
49
+ });
50
+
51
+ it('list -f md produces markdown table', async () => {
52
+ const { stdout, code } = await runCli(['list', '-f', 'md']);
53
+ expect(code).toBe(0);
54
+ expect(stdout).toContain('|');
55
+ expect(stdout).toContain('command');
56
+ });
57
+
58
+ // ── validate ──
59
+ it('validate passes for all built-in adapters', async () => {
60
+ const { stdout, code } = await runCli(['validate']);
61
+ expect(code).toBe(0);
62
+ expect(stdout).toContain('PASS');
63
+ expect(stdout).not.toContain('❌');
64
+ });
65
+
66
+ it('validate works for specific site', async () => {
67
+ const { stdout, code } = await runCli(['validate', 'hackernews']);
68
+ expect(code).toBe(0);
69
+ expect(stdout).toContain('PASS');
70
+ });
71
+
72
+ it('validate works for specific command', async () => {
73
+ const { stdout, code } = await runCli(['validate', 'hackernews/top']);
74
+ expect(code).toBe(0);
75
+ expect(stdout).toContain('PASS');
76
+ });
77
+
78
+ // ── verify ──
79
+ it('verify runs validation without smoke tests', async () => {
80
+ const { stdout, code } = await runCli(['verify']);
81
+ expect(code).toBe(0);
82
+ expect(stdout).toContain('PASS');
83
+ });
84
+
85
+ // ── version ──
86
+ it('--version shows version number', async () => {
87
+ const { stdout, code } = await runCli(['--version']);
88
+ expect(code).toBe(0);
89
+ expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
90
+ });
91
+
92
+ // ── help ──
93
+ it('--help shows usage', async () => {
94
+ const { stdout, code } = await runCli(['--help']);
95
+ expect(code).toBe(0);
96
+ expect(stdout).toContain('opencli');
97
+ expect(stdout).toContain('list');
98
+ expect(stdout).toContain('validate');
99
+ });
100
+
101
+ // ── unknown command ──
102
+ it('unknown command shows error', async () => {
103
+ const { stderr, code } = await runCli(['nonexistent-command-xyz']);
104
+ expect(code).toBe(1);
105
+ });
106
+ });