@jackwener/opencli 0.6.1 → 0.6.3
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/.github/actions/setup-chrome/action.yml +26 -0
- package/.github/workflows/ci.yml +59 -3
- package/.github/workflows/e2e-headed.yml +37 -0
- package/README.md +20 -1
- package/README.zh-CN.md +1 -1
- package/TESTING.md +233 -0
- package/dist/bilibili.js +2 -2
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +12 -3
- package/dist/browser.test.js +56 -16
- package/dist/cli-manifest.json +39 -0
- package/dist/clis/boss/detail.d.ts +1 -0
- package/dist/clis/boss/detail.js +104 -0
- package/dist/clis/boss/search.js +2 -1
- package/dist/interceptor.test.d.ts +4 -0
- package/dist/interceptor.test.js +81 -0
- package/dist/output.test.d.ts +3 -0
- package/dist/output.test.js +60 -0
- package/dist/pipeline/executor.js +0 -6
- package/dist/pipeline/executor.test.d.ts +4 -0
- package/dist/pipeline/executor.test.js +145 -0
- package/dist/pipeline/steps/fetch.js +4 -3
- package/dist/registry.d.ts +2 -2
- package/package.json +4 -4
- package/src/bilibili.ts +2 -2
- package/src/browser.test.ts +54 -16
- package/src/browser.ts +11 -3
- package/src/clis/boss/detail.ts +115 -0
- package/src/clis/boss/search.ts +2 -1
- package/src/clis/twitter/notifications.ts +1 -1
- package/src/engine.ts +2 -2
- package/src/interceptor.test.ts +94 -0
- package/src/output.test.ts +69 -4
- package/src/pipeline/executor.test.ts +161 -0
- package/src/pipeline/executor.ts +0 -5
- package/src/pipeline/steps/fetch.ts +4 -3
- package/src/registry.ts +2 -2
- package/tests/e2e/browser-auth.test.ts +90 -0
- package/tests/e2e/browser-public.test.ts +169 -0
- package/tests/e2e/helpers.ts +63 -0
- package/tests/e2e/management.test.ts +106 -0
- package/tests/e2e/output-formats.test.ts +48 -0
- package/tests/e2e/public-commands.test.ts +56 -0
- package/tests/smoke/api-health.test.ts +72 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +1 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E tests for output format rendering.
|
|
3
|
+
* Uses hackernews (public, fast) as a stable data source.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { runCli, parseJsonOutput } from './helpers.js';
|
|
8
|
+
|
|
9
|
+
const FORMATS = ['json', 'yaml', 'csv', 'md'] as const;
|
|
10
|
+
|
|
11
|
+
describe('output formats E2E', () => {
|
|
12
|
+
for (const fmt of FORMATS) {
|
|
13
|
+
it(`hackernews top -f ${fmt} produces valid output`, async () => {
|
|
14
|
+
const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '2', '-f', fmt]);
|
|
15
|
+
expect(code).toBe(0);
|
|
16
|
+
expect(stdout.trim().length).toBeGreaterThan(0);
|
|
17
|
+
|
|
18
|
+
if (fmt === 'json') {
|
|
19
|
+
const data = parseJsonOutput(stdout);
|
|
20
|
+
expect(Array.isArray(data)).toBe(true);
|
|
21
|
+
expect(data.length).toBe(2);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (fmt === 'yaml') {
|
|
25
|
+
expect(stdout).toContain('title:');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (fmt === 'csv') {
|
|
29
|
+
// CSV should have a header row + data rows
|
|
30
|
+
const lines = stdout.trim().split('\n');
|
|
31
|
+
expect(lines.length).toBeGreaterThanOrEqual(2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (fmt === 'md') {
|
|
35
|
+
// Markdown table should have pipe characters
|
|
36
|
+
expect(stdout).toContain('|');
|
|
37
|
+
}
|
|
38
|
+
}, 30_000);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
it('list -f csv produces valid csv', async () => {
|
|
42
|
+
const { stdout, code } = await runCli(['list', '-f', 'csv']);
|
|
43
|
+
expect(code).toBe(0);
|
|
44
|
+
const lines = stdout.trim().split('\n');
|
|
45
|
+
// Header + many data lines
|
|
46
|
+
expect(lines.length).toBeGreaterThan(50);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E tests for public API commands (browser: false).
|
|
3
|
+
* These commands use Node.js fetch directly — no browser needed.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { runCli, parseJsonOutput } from './helpers.js';
|
|
8
|
+
|
|
9
|
+
describe('public commands E2E', () => {
|
|
10
|
+
// ── hackernews ──
|
|
11
|
+
it('hackernews top returns structured data', async () => {
|
|
12
|
+
const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '3', '-f', 'json']);
|
|
13
|
+
expect(code).toBe(0);
|
|
14
|
+
const data = parseJsonOutput(stdout);
|
|
15
|
+
expect(Array.isArray(data)).toBe(true);
|
|
16
|
+
expect(data.length).toBe(3);
|
|
17
|
+
expect(data[0]).toHaveProperty('title');
|
|
18
|
+
expect(data[0]).toHaveProperty('score');
|
|
19
|
+
expect(data[0]).toHaveProperty('rank');
|
|
20
|
+
}, 30_000);
|
|
21
|
+
|
|
22
|
+
it('hackernews top respects --limit', async () => {
|
|
23
|
+
const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '1', '-f', 'json']);
|
|
24
|
+
expect(code).toBe(0);
|
|
25
|
+
const data = parseJsonOutput(stdout);
|
|
26
|
+
expect(data.length).toBe(1);
|
|
27
|
+
}, 30_000);
|
|
28
|
+
|
|
29
|
+
// ── v2ex (public API, browser: false) ──
|
|
30
|
+
it('v2ex hot returns topics', async () => {
|
|
31
|
+
const { stdout, code } = await runCli(['v2ex', 'hot', '--limit', '3', '-f', 'json']);
|
|
32
|
+
expect(code).toBe(0);
|
|
33
|
+
const data = parseJsonOutput(stdout);
|
|
34
|
+
expect(Array.isArray(data)).toBe(true);
|
|
35
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
36
|
+
expect(data[0]).toHaveProperty('title');
|
|
37
|
+
}, 30_000);
|
|
38
|
+
|
|
39
|
+
it('v2ex latest returns topics', async () => {
|
|
40
|
+
const { stdout, code } = await runCli(['v2ex', 'latest', '--limit', '3', '-f', 'json']);
|
|
41
|
+
expect(code).toBe(0);
|
|
42
|
+
const data = parseJsonOutput(stdout);
|
|
43
|
+
expect(Array.isArray(data)).toBe(true);
|
|
44
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
45
|
+
}, 30_000);
|
|
46
|
+
|
|
47
|
+
it('v2ex topic returns topic detail', async () => {
|
|
48
|
+
// Topic 1000001 is a well-known V2EX topic
|
|
49
|
+
const { stdout, code } = await runCli(['v2ex', 'topic', '--id', '1000001', '-f', 'json']);
|
|
50
|
+
// May fail if V2EX rate-limits, but should return structured data
|
|
51
|
+
if (code === 0) {
|
|
52
|
+
const data = parseJsonOutput(stdout);
|
|
53
|
+
expect(data).toBeDefined();
|
|
54
|
+
}
|
|
55
|
+
}, 30_000);
|
|
56
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for external API health.
|
|
3
|
+
* Only run on schedule or manual dispatch — NOT on every push/PR.
|
|
4
|
+
* These verify that external APIs haven't changed their structure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { runCli, parseJsonOutput } from '../e2e/helpers.js';
|
|
9
|
+
|
|
10
|
+
describe('API health smoke tests', () => {
|
|
11
|
+
|
|
12
|
+
// ── Public API commands (should always work) ──
|
|
13
|
+
it('hackernews API is responsive and returns expected structure', async () => {
|
|
14
|
+
const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '5', '-f', 'json']);
|
|
15
|
+
expect(code).toBe(0);
|
|
16
|
+
const data = parseJsonOutput(stdout);
|
|
17
|
+
expect(data.length).toBe(5);
|
|
18
|
+
for (const item of data) {
|
|
19
|
+
expect(item).toHaveProperty('title');
|
|
20
|
+
expect(item).toHaveProperty('score');
|
|
21
|
+
expect(item).toHaveProperty('author');
|
|
22
|
+
expect(item).toHaveProperty('rank');
|
|
23
|
+
}
|
|
24
|
+
}, 30_000);
|
|
25
|
+
|
|
26
|
+
it('v2ex hot API is responsive', async () => {
|
|
27
|
+
const { stdout, code } = await runCli(['v2ex', 'hot', '--limit', '3', '-f', 'json']);
|
|
28
|
+
expect(code).toBe(0);
|
|
29
|
+
const data = parseJsonOutput(stdout);
|
|
30
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
31
|
+
expect(data[0]).toHaveProperty('title');
|
|
32
|
+
}, 30_000);
|
|
33
|
+
|
|
34
|
+
it('v2ex latest API is responsive', async () => {
|
|
35
|
+
const { stdout, code } = await runCli(['v2ex', 'latest', '--limit', '3', '-f', 'json']);
|
|
36
|
+
expect(code).toBe(0);
|
|
37
|
+
const data = parseJsonOutput(stdout);
|
|
38
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
39
|
+
}, 30_000);
|
|
40
|
+
|
|
41
|
+
it('v2ex topic API is responsive', async () => {
|
|
42
|
+
const { stdout, code } = await runCli(['v2ex', 'topic', '--id', '1000001', '-f', 'json']);
|
|
43
|
+
if (code === 0) {
|
|
44
|
+
const data = parseJsonOutput(stdout);
|
|
45
|
+
expect(data).toBeDefined();
|
|
46
|
+
}
|
|
47
|
+
}, 30_000);
|
|
48
|
+
|
|
49
|
+
// ── Validate all adapters ──
|
|
50
|
+
it('all adapter definitions are valid', async () => {
|
|
51
|
+
const { stdout, code } = await runCli(['validate']);
|
|
52
|
+
expect(code).toBe(0);
|
|
53
|
+
expect(stdout).toContain('PASS');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── Command registry integrity ──
|
|
57
|
+
it('all expected sites are registered', async () => {
|
|
58
|
+
const { stdout, code } = await runCli(['list', '-f', 'json']);
|
|
59
|
+
expect(code).toBe(0);
|
|
60
|
+
const data = parseJsonOutput(stdout);
|
|
61
|
+
const sites = new Set(data.map((d: any) => d.site));
|
|
62
|
+
// Verify all 17 sites are present
|
|
63
|
+
for (const expected of [
|
|
64
|
+
'hackernews', 'bbc', 'bilibili', 'v2ex', 'weibo', 'zhihu',
|
|
65
|
+
'twitter', 'reddit', 'xueqiu', 'reuters', 'youtube',
|
|
66
|
+
'smzdm', 'boss', 'ctrip', 'coupang', 'xiaohongshu',
|
|
67
|
+
'yahoo-finance',
|
|
68
|
+
]) {
|
|
69
|
+
expect(sites.has(expected)).toBe(true);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
package/tsconfig.json
CHANGED