@jackwener/opencli 1.3.1 → 1.3.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.
- package/CHANGELOG.md +128 -0
- package/README.md +44 -5
- package/README.zh-CN.md +44 -5
- package/SKILL.md +317 -5
- package/TESTING.md +4 -4
- package/dist/browser/errors.d.ts +2 -1
- package/dist/browser/errors.js +9 -10
- package/dist/build-manifest.js +1 -3
- package/dist/cli-manifest.json +2573 -989
- package/dist/cli.js +42 -2
- package/dist/clis/bilibili/download.js +20 -65
- package/dist/clis/bilibili/utils.js +2 -1
- package/dist/clis/chaoxing/assignments.js +2 -1
- package/dist/clis/doubao/ask.d.ts +1 -0
- package/dist/clis/doubao/ask.js +35 -0
- package/dist/clis/doubao/common.d.ts +23 -0
- package/dist/clis/doubao/common.js +564 -0
- package/dist/clis/doubao/new.d.ts +1 -0
- package/dist/clis/doubao/new.js +20 -0
- package/dist/clis/doubao/read.d.ts +1 -0
- package/dist/clis/doubao/read.js +19 -0
- package/dist/clis/doubao/send.d.ts +1 -0
- package/dist/clis/doubao/send.js +22 -0
- package/dist/clis/doubao/status.d.ts +1 -0
- package/dist/clis/doubao/status.js +24 -0
- package/dist/clis/doubao-app/ask.d.ts +1 -0
- package/dist/clis/doubao-app/ask.js +53 -0
- package/dist/clis/doubao-app/common.d.ts +37 -0
- package/dist/clis/doubao-app/common.js +110 -0
- package/dist/clis/doubao-app/dump.d.ts +1 -0
- package/dist/clis/doubao-app/dump.js +24 -0
- package/dist/clis/doubao-app/new.d.ts +1 -0
- package/dist/clis/doubao-app/new.js +20 -0
- package/dist/clis/doubao-app/read.d.ts +1 -0
- package/dist/clis/doubao-app/read.js +18 -0
- package/dist/clis/doubao-app/screenshot.d.ts +1 -0
- package/dist/clis/doubao-app/screenshot.js +18 -0
- package/dist/clis/doubao-app/send.d.ts +1 -0
- package/dist/clis/doubao-app/send.js +27 -0
- package/dist/clis/doubao-app/status.d.ts +1 -0
- package/dist/clis/doubao-app/status.js +16 -0
- package/dist/clis/hackernews/ask.yaml +38 -0
- package/dist/clis/hackernews/best.yaml +38 -0
- package/dist/clis/hackernews/jobs.yaml +36 -0
- package/dist/clis/hackernews/new.yaml +38 -0
- package/dist/clis/hackernews/search.yaml +44 -0
- package/dist/clis/hackernews/show.yaml +38 -0
- package/dist/clis/hackernews/top.yaml +3 -1
- package/dist/clis/hackernews/user.yaml +25 -0
- package/dist/clis/twitter/download.js +13 -97
- package/dist/clis/twitter/thread.js +2 -1
- package/dist/clis/v2ex/member.yaml +29 -0
- package/dist/clis/v2ex/node.yaml +34 -0
- package/dist/clis/v2ex/nodes.yaml +31 -0
- package/dist/clis/v2ex/replies.yaml +32 -0
- package/dist/clis/v2ex/user.yaml +34 -0
- package/dist/clis/weibo/search.d.ts +1 -0
- package/dist/clis/weibo/search.js +73 -0
- package/dist/clis/weixin/download.d.ts +12 -0
- package/dist/clis/weixin/download.js +183 -0
- package/dist/clis/xiaohongshu/download.js +12 -60
- package/dist/clis/xiaohongshu/publish.d.ts +18 -0
- package/dist/clis/xiaohongshu/publish.js +352 -0
- package/dist/clis/xiaohongshu/search.js +47 -15
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/search.test.js +114 -0
- package/dist/clis/yollomi/background.d.ts +4 -0
- package/dist/clis/yollomi/background.js +45 -0
- package/dist/clis/yollomi/edit.d.ts +5 -0
- package/dist/clis/yollomi/edit.js +56 -0
- package/dist/clis/yollomi/face-swap.d.ts +5 -0
- package/dist/clis/yollomi/face-swap.js +43 -0
- package/dist/clis/yollomi/generate.d.ts +9 -0
- package/dist/clis/yollomi/generate.js +100 -0
- package/dist/clis/yollomi/models.d.ts +1 -0
- package/dist/clis/yollomi/models.js +33 -0
- package/dist/clis/yollomi/object-remover.d.ts +4 -0
- package/dist/clis/yollomi/object-remover.js +42 -0
- package/dist/clis/yollomi/remove-bg.d.ts +4 -0
- package/dist/clis/yollomi/remove-bg.js +38 -0
- package/dist/clis/yollomi/restore.d.ts +4 -0
- package/dist/clis/yollomi/restore.js +38 -0
- package/dist/clis/yollomi/try-on.d.ts +4 -0
- package/dist/clis/yollomi/try-on.js +46 -0
- package/dist/clis/yollomi/upload.d.ts +7 -0
- package/dist/clis/yollomi/upload.js +71 -0
- package/dist/clis/yollomi/upscale.d.ts +4 -0
- package/dist/clis/yollomi/upscale.js +53 -0
- package/dist/clis/yollomi/utils.d.ts +45 -0
- package/dist/clis/yollomi/utils.js +180 -0
- package/dist/clis/yollomi/video.d.ts +5 -0
- package/dist/clis/yollomi/video.js +56 -0
- package/dist/clis/zhihu/download.d.ts +1 -5
- package/dist/clis/zhihu/download.js +20 -126
- package/dist/clis/zhihu/download.test.js +7 -5
- package/dist/clis/zhihu/question.js +2 -1
- package/dist/commanderAdapter.js +4 -6
- package/dist/daemon.js +5 -2
- package/dist/discovery.js +10 -10
- package/dist/download/article-download.d.ts +59 -0
- package/dist/download/article-download.js +178 -0
- package/dist/download/media-download.d.ts +49 -0
- package/dist/download/media-download.js +112 -0
- package/dist/errors.d.ts +23 -2
- package/dist/errors.js +58 -2
- package/dist/errors.test.d.ts +1 -0
- package/dist/errors.test.js +59 -0
- package/dist/execution.js +9 -10
- package/dist/explore.js +4 -2
- package/dist/external.d.ts +15 -0
- package/dist/external.js +48 -2
- package/dist/external.test.d.ts +1 -0
- package/dist/external.test.js +64 -0
- package/dist/main.js +10 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.js +45 -23
- package/dist/plugin.test.js +6 -1
- package/dist/record.d.ts +47 -0
- package/dist/record.js +545 -0
- package/dist/registry.d.ts +7 -2
- package/dist/registry.js +2 -6
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +10 -3
- package/dist/validate.js +1 -3
- package/docs/.vitepress/config.mts +1 -0
- package/docs/adapters/browser/doubao.md +35 -0
- package/docs/adapters/browser/hackernews.md +20 -4
- package/docs/adapters/browser/tiktok.md +1 -1
- package/docs/adapters/browser/v2ex.md +31 -10
- package/docs/adapters/browser/weibo.md +4 -0
- package/docs/adapters/browser/weixin.md +33 -0
- package/docs/adapters/browser/xiaohongshu.md +8 -6
- package/docs/adapters/browser/yollomi.md +69 -0
- package/docs/adapters/desktop/doubao-app.md +35 -0
- package/docs/adapters/index.md +16 -5
- package/docs/advanced/download.md +4 -0
- package/package.json +3 -1
- package/src/browser/errors.ts +17 -11
- package/src/build-manifest.ts +2 -3
- package/src/cli.ts +45 -2
- package/src/clis/bilibili/download.ts +25 -83
- package/src/clis/bilibili/utils.ts +2 -1
- package/src/clis/chaoxing/assignments.ts +2 -1
- package/src/clis/doubao/ask.ts +40 -0
- package/src/clis/doubao/common.ts +619 -0
- package/src/clis/doubao/new.ts +22 -0
- package/src/clis/doubao/read.ts +20 -0
- package/src/clis/doubao/send.ts +25 -0
- package/src/clis/doubao/status.ts +27 -0
- package/src/clis/doubao-app/ask.ts +60 -0
- package/src/clis/doubao-app/common.ts +116 -0
- package/src/clis/doubao-app/dump.ts +28 -0
- package/src/clis/doubao-app/new.ts +21 -0
- package/src/clis/doubao-app/read.ts +21 -0
- package/src/clis/doubao-app/screenshot.ts +19 -0
- package/src/clis/doubao-app/send.ts +30 -0
- package/src/clis/doubao-app/status.ts +17 -0
- package/src/clis/hackernews/ask.yaml +38 -0
- package/src/clis/hackernews/best.yaml +38 -0
- package/src/clis/hackernews/jobs.yaml +36 -0
- package/src/clis/hackernews/new.yaml +38 -0
- package/src/clis/hackernews/search.yaml +44 -0
- package/src/clis/hackernews/show.yaml +38 -0
- package/src/clis/hackernews/top.yaml +3 -1
- package/src/clis/hackernews/user.yaml +25 -0
- package/src/clis/twitter/download.ts +13 -111
- package/src/clis/twitter/thread.ts +2 -1
- package/src/clis/v2ex/member.yaml +29 -0
- package/src/clis/v2ex/node.yaml +34 -0
- package/src/clis/v2ex/nodes.yaml +31 -0
- package/src/clis/v2ex/replies.yaml +32 -0
- package/src/clis/v2ex/user.yaml +34 -0
- package/src/clis/weibo/search.ts +78 -0
- package/src/clis/weixin/download.ts +199 -0
- package/src/clis/xiaohongshu/download.ts +12 -71
- package/src/clis/xiaohongshu/publish.ts +392 -0
- package/src/clis/xiaohongshu/search.test.ts +134 -0
- package/src/clis/xiaohongshu/search.ts +49 -15
- package/src/clis/yollomi/background.ts +48 -0
- package/src/clis/yollomi/edit.ts +58 -0
- package/src/clis/yollomi/face-swap.ts +45 -0
- package/src/clis/yollomi/generate.ts +95 -0
- package/src/clis/yollomi/models.ts +38 -0
- package/src/clis/yollomi/object-remover.ts +44 -0
- package/src/clis/yollomi/remove-bg.ts +40 -0
- package/src/clis/yollomi/restore.ts +40 -0
- package/src/clis/yollomi/try-on.ts +48 -0
- package/src/clis/yollomi/upload.ts +78 -0
- package/src/clis/yollomi/upscale.ts +49 -0
- package/src/clis/yollomi/utils.ts +202 -0
- package/src/clis/yollomi/video.ts +61 -0
- package/src/clis/zhihu/download.test.ts +7 -5
- package/src/clis/zhihu/download.ts +23 -158
- package/src/clis/zhihu/question.ts +2 -1
- package/src/commanderAdapter.ts +4 -7
- package/src/daemon.ts +5 -2
- package/src/discovery.ts +26 -26
- package/src/download/article-download.ts +272 -0
- package/src/download/media-download.ts +178 -0
- package/src/errors.test.ts +79 -0
- package/src/errors.ts +92 -2
- package/src/execution.ts +14 -10
- package/src/explore.ts +4 -2
- package/src/external.test.ts +88 -0
- package/src/external.ts +56 -2
- package/src/generate.ts +2 -1
- package/src/main.ts +10 -0
- package/src/plugin.test.ts +7 -1
- package/src/plugin.ts +49 -25
- package/src/record.ts +617 -0
- package/src/registry.ts +9 -5
- package/src/runtime.ts +16 -4
- package/src/validate.ts +2 -3
- package/tests/e2e/browser-auth.test.ts +10 -1
- package/tests/e2e/browser-public.test.ts +13 -8
- package/tests/e2e/public-commands.test.ts +209 -21
- package/tests/smoke/api-health.test.ts +65 -6
package/src/runtime.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { BrowserBridge, CDPBridge } from './browser/index.js';
|
|
2
2
|
import type { IPage } from './types.js';
|
|
3
|
+
import { TimeoutError } from './errors.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Returns the appropriate browser factory based on environment config.
|
|
6
7
|
* Uses CDPBridge when OPENCLI_CDP_ENDPOINT is set, otherwise BrowserBridge.
|
|
7
8
|
*/
|
|
8
9
|
export function getBrowserFactory(): new () => IBrowserFactory {
|
|
9
|
-
return (process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge) as
|
|
10
|
+
return (process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge) as unknown as new () => IBrowserFactory;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export const DEFAULT_BROWSER_CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
|
|
@@ -21,15 +22,26 @@ export async function runWithTimeout<T>(
|
|
|
21
22
|
promise: Promise<T>,
|
|
22
23
|
opts: { timeout: number; label?: string },
|
|
23
24
|
): Promise<T> {
|
|
24
|
-
|
|
25
|
+
const label = opts.label ?? 'Operation';
|
|
26
|
+
return withTimeoutMs(promise, opts.timeout * 1000,
|
|
27
|
+
() => new TimeoutError(label, opts.timeout));
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Timeout with milliseconds unit. Used for low-level internal timeouts.
|
|
32
|
+
* Accepts a factory function to create the rejection error, keeping this
|
|
33
|
+
* utility decoupled from specific error types.
|
|
29
34
|
*/
|
|
30
|
-
export function withTimeoutMs<T>(
|
|
35
|
+
export function withTimeoutMs<T>(
|
|
36
|
+
promise: Promise<T>,
|
|
37
|
+
timeoutMs: number,
|
|
38
|
+
makeError: string | (() => Error) = 'Operation timed out',
|
|
39
|
+
): Promise<T> {
|
|
40
|
+
const reject_ = typeof makeError === 'string'
|
|
41
|
+
? () => new Error(makeError)
|
|
42
|
+
: makeError;
|
|
31
43
|
return new Promise<T>((resolve, reject) => {
|
|
32
|
-
const timer = setTimeout(() => reject(
|
|
44
|
+
const timer = setTimeout(() => reject(reject_()), timeoutMs);
|
|
33
45
|
promise.then(
|
|
34
46
|
(value) => { clearTimeout(timer); resolve(value); },
|
|
35
47
|
(error) => { clearTimeout(timer); reject(error); },
|
package/src/validate.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import yaml from 'js-yaml';
|
|
5
|
+
import { getErrorMessage } from './errors.js';
|
|
5
6
|
|
|
6
7
|
/** All recognized pipeline step names */
|
|
7
8
|
const KNOWN_STEP_NAMES = new Set([
|
|
@@ -37,9 +38,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
37
38
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
return error instanceof Error ? error.message : String(error);
|
|
42
|
-
}
|
|
41
|
+
|
|
43
42
|
|
|
44
43
|
export function validateClisWithTarget(dirs: string[], target?: string): ValidationReport {
|
|
45
44
|
const results: FileValidationResult[] = [];
|
|
@@ -101,7 +101,7 @@ describe('login-required commands — graceful failure', () => {
|
|
|
101
101
|
}, 60_000);
|
|
102
102
|
|
|
103
103
|
it('linux-do search fails gracefully without login', async () => {
|
|
104
|
-
await expectGracefulAuthFailure(['linux-do', 'search', '
|
|
104
|
+
await expectGracefulAuthFailure(['linux-do', 'search', 'test', '--limit', '3', '-f', 'json'], 'linux-do search');
|
|
105
105
|
}, 60_000);
|
|
106
106
|
|
|
107
107
|
// ── xiaohongshu (requires login) ──
|
|
@@ -112,4 +112,13 @@ describe('login-required commands — graceful failure', () => {
|
|
|
112
112
|
it('xiaohongshu notifications fails gracefully without login', async () => {
|
|
113
113
|
await expectGracefulAuthFailure(['xiaohongshu', 'notifications', '--limit', '3', '-f', 'json'], 'xiaohongshu notifications');
|
|
114
114
|
}, 60_000);
|
|
115
|
+
|
|
116
|
+
// ── yollomi (requires login session) ──
|
|
117
|
+
it('yollomi generate fails gracefully without login', async () => {
|
|
118
|
+
await expectGracefulAuthFailure(['yollomi', 'generate', 'a cute cat', '--no-download', '-f', 'json'], 'yollomi generate');
|
|
119
|
+
}, 60_000);
|
|
120
|
+
|
|
121
|
+
it('yollomi video fails gracefully without login', async () => {
|
|
122
|
+
await expectGracefulAuthFailure(['yollomi', 'video', 'a sunset over the ocean', '--no-download', '-f', 'json'], 'yollomi video');
|
|
123
|
+
}, 60_000);
|
|
115
124
|
});
|
|
@@ -92,7 +92,7 @@ describe('browser public-data commands E2E', () => {
|
|
|
92
92
|
}, 60_000);
|
|
93
93
|
|
|
94
94
|
it('bilibili search returns results', async () => {
|
|
95
|
-
const data = await tryBrowserCommand(['bilibili', 'search', '
|
|
95
|
+
const data = await tryBrowserCommand(['bilibili', 'search', 'typescript', '--limit', '3', '-f', 'json']);
|
|
96
96
|
expectDataOrSkip(data, 'bilibili search');
|
|
97
97
|
}, 60_000);
|
|
98
98
|
|
|
@@ -102,6 +102,11 @@ describe('browser public-data commands E2E', () => {
|
|
|
102
102
|
expectDataOrSkip(data, 'weibo hot');
|
|
103
103
|
}, 60_000);
|
|
104
104
|
|
|
105
|
+
it('weibo search returns results', async () => {
|
|
106
|
+
const data = await tryBrowserCommand(['weibo', 'search', 'openai', '--limit', '3', '-f', 'json']);
|
|
107
|
+
expectDataOrSkip(data, 'weibo search');
|
|
108
|
+
}, 60_000);
|
|
109
|
+
|
|
105
110
|
// ── zhihu (browser: true, cookie strategy) ──
|
|
106
111
|
it('zhihu hot returns trending questions', async () => {
|
|
107
112
|
const data = await tryBrowserCommand(['zhihu', 'hot', '--limit', '5', '-f', 'json']);
|
|
@@ -112,7 +117,7 @@ describe('browser public-data commands E2E', () => {
|
|
|
112
117
|
}, 60_000);
|
|
113
118
|
|
|
114
119
|
it('zhihu search returns results', async () => {
|
|
115
|
-
const data = await tryBrowserCommand(['zhihu', 'search', '
|
|
120
|
+
const data = await tryBrowserCommand(['zhihu', 'search', 'playwright', '--limit', '3', '-f', 'json']);
|
|
116
121
|
expectDataOrSkip(data, 'zhihu search');
|
|
117
122
|
}, 60_000);
|
|
118
123
|
|
|
@@ -146,25 +151,25 @@ describe('browser public-data commands E2E', () => {
|
|
|
146
151
|
|
|
147
152
|
// ── reuters (browser: true) ──
|
|
148
153
|
it('reuters search returns articles', async () => {
|
|
149
|
-
const data = await tryBrowserCommand(['reuters', 'search', '
|
|
154
|
+
const data = await tryBrowserCommand(['reuters', 'search', 'technology', '--limit', '3', '-f', 'json']);
|
|
150
155
|
expectDataOrSkip(data, 'reuters search');
|
|
151
156
|
}, 60_000);
|
|
152
157
|
|
|
153
158
|
// ── youtube (browser: true) ──
|
|
154
159
|
it('youtube search returns videos', async () => {
|
|
155
|
-
const data = await tryBrowserCommand(['youtube', 'search', '
|
|
160
|
+
const data = await tryBrowserCommand(['youtube', 'search', 'typescript tutorial', '--limit', '3', '-f', 'json']);
|
|
156
161
|
expectDataOrSkip(data, 'youtube search');
|
|
157
162
|
}, 60_000);
|
|
158
163
|
|
|
159
164
|
// ── smzdm (browser: true) ──
|
|
160
165
|
it('smzdm search returns deals', async () => {
|
|
161
|
-
const data = await tryBrowserCommand(['smzdm', 'search', '
|
|
166
|
+
const data = await tryBrowserCommand(['smzdm', 'search', '键盘', '--limit', '3', '-f', 'json']);
|
|
162
167
|
expectDataOrSkip(data, 'smzdm search');
|
|
163
168
|
}, 60_000);
|
|
164
169
|
|
|
165
170
|
// ── boss (browser: true) ──
|
|
166
171
|
it('boss search returns jobs', async () => {
|
|
167
|
-
const data = await tryBrowserCommand(['boss', 'search', '
|
|
172
|
+
const data = await tryBrowserCommand(['boss', 'search', 'golang', '--limit', '3', '-f', 'json']);
|
|
168
173
|
expectDataOrSkip(data, 'boss search');
|
|
169
174
|
}, 60_000);
|
|
170
175
|
|
|
@@ -176,13 +181,13 @@ describe('browser public-data commands E2E', () => {
|
|
|
176
181
|
|
|
177
182
|
// ── coupang (browser: true) ──
|
|
178
183
|
it('coupang search returns products', async () => {
|
|
179
|
-
const data = await tryBrowserCommand(['coupang', 'search', '
|
|
184
|
+
const data = await tryBrowserCommand(['coupang', 'search', 'laptop', '--limit', '3', '-f', 'json']);
|
|
180
185
|
expectDataOrSkip(data, 'coupang search');
|
|
181
186
|
}, 60_000);
|
|
182
187
|
|
|
183
188
|
// ── xiaohongshu (browser: true) ──
|
|
184
189
|
it('xiaohongshu search returns notes', async () => {
|
|
185
|
-
const data = await tryBrowserCommand(['xiaohongshu', 'search', '
|
|
190
|
+
const data = await tryBrowserCommand(['xiaohongshu', 'search', '美食', '--limit', '3', '-f', 'json']);
|
|
186
191
|
expectDataOrSkip(data, 'xiaohongshu search');
|
|
187
192
|
}, 60_000);
|
|
188
193
|
|
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
* These commands use Node.js fetch directly — no browser needed.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { describe,
|
|
7
|
-
import {
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { parseJsonOutput, runCli } from './helpers.js';
|
|
8
8
|
|
|
9
9
|
function isExpectedChineseSiteRestriction(code: number, stderr: string): boolean {
|
|
10
10
|
if (code === 0) return false;
|
|
11
|
-
|
|
11
|
+
// Overseas CI runners may get HTTP errors, geo-blocks, DNS failures,
|
|
12
|
+
// or receive mangled HTML that fails parsing.
|
|
13
|
+
return /Error \[(FETCH_ERROR|PARSE_ERROR|NOT_FOUND)\]/.test(stderr)
|
|
14
|
+
|| /fetch failed/.test(stderr);
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
function isExpectedApplePodcastsRestriction(code: number, stderr: string): boolean {
|
|
@@ -40,21 +43,25 @@ describe('public commands E2E', () => {
|
|
|
40
43
|
expect(Array.isArray(data[0].mediaLinks)).toBe(true);
|
|
41
44
|
}, 30_000);
|
|
42
45
|
|
|
43
|
-
it.each([
|
|
44
|
-
'
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
46
|
+
it.each([
|
|
47
|
+
'markets',
|
|
48
|
+
'economics',
|
|
49
|
+
'industries',
|
|
50
|
+
'tech',
|
|
51
|
+
'politics',
|
|
52
|
+
'businessweek',
|
|
53
|
+
'opinions',
|
|
54
|
+
])('bloomberg %s returns structured RSS items', async (section) => {
|
|
55
|
+
const { stdout, code } = await runCli(['bloomberg', section, '--limit', '1', '-f', 'json']);
|
|
56
|
+
expect(code).toBe(0);
|
|
57
|
+
const data = parseJsonOutput(stdout);
|
|
58
|
+
expect(Array.isArray(data)).toBe(true);
|
|
59
|
+
expect(data.length).toBe(1);
|
|
60
|
+
expect(data[0]).toHaveProperty('title');
|
|
61
|
+
expect(data[0]).toHaveProperty('summary');
|
|
62
|
+
expect(data[0]).toHaveProperty('link');
|
|
63
|
+
expect(data[0]).toHaveProperty('mediaLinks');
|
|
64
|
+
}, 30_000);
|
|
58
65
|
|
|
59
66
|
it('bloomberg feeds lists the supported RSS aliases', async () => {
|
|
60
67
|
const { stdout, code } = await runCli(['bloomberg', 'feeds', '-f', 'json']);
|
|
@@ -95,7 +102,16 @@ describe('public commands E2E', () => {
|
|
|
95
102
|
}, 30_000);
|
|
96
103
|
|
|
97
104
|
it('apple-podcasts top returns ranked podcasts', async () => {
|
|
98
|
-
const { stdout, stderr, code } = await runCli([
|
|
105
|
+
const { stdout, stderr, code } = await runCli([
|
|
106
|
+
'apple-podcasts',
|
|
107
|
+
'top',
|
|
108
|
+
'--limit',
|
|
109
|
+
'3',
|
|
110
|
+
'--country',
|
|
111
|
+
'us',
|
|
112
|
+
'-f',
|
|
113
|
+
'json',
|
|
114
|
+
]);
|
|
99
115
|
if (isExpectedApplePodcastsRestriction(code, stderr)) {
|
|
100
116
|
console.warn(`apple-podcasts top skipped: ${stderr.trim()}`);
|
|
101
117
|
return;
|
|
@@ -128,6 +144,76 @@ describe('public commands E2E', () => {
|
|
|
128
144
|
expect(data.length).toBe(1);
|
|
129
145
|
}, 30_000);
|
|
130
146
|
|
|
147
|
+
it('hackernews new returns newest stories', async () => {
|
|
148
|
+
const { stdout, code } = await runCli(['hackernews', 'new', '--limit', '3', '-f', 'json']);
|
|
149
|
+
expect(code).toBe(0);
|
|
150
|
+
const data = parseJsonOutput(stdout);
|
|
151
|
+
expect(Array.isArray(data)).toBe(true);
|
|
152
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
153
|
+
expect(data[0]).toHaveProperty('title');
|
|
154
|
+
expect(data[0]).toHaveProperty('score');
|
|
155
|
+
expect(data[0]).toHaveProperty('rank');
|
|
156
|
+
}, 30_000);
|
|
157
|
+
|
|
158
|
+
it('hackernews best returns best stories', async () => {
|
|
159
|
+
const { stdout, code } = await runCli(['hackernews', 'best', '--limit', '3', '-f', 'json']);
|
|
160
|
+
expect(code).toBe(0);
|
|
161
|
+
const data = parseJsonOutput(stdout);
|
|
162
|
+
expect(Array.isArray(data)).toBe(true);
|
|
163
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
164
|
+
expect(data[0]).toHaveProperty('title');
|
|
165
|
+
expect(data[0]).toHaveProperty('score');
|
|
166
|
+
}, 30_000);
|
|
167
|
+
|
|
168
|
+
it('hackernews ask returns Ask HN posts', async () => {
|
|
169
|
+
const { stdout, code } = await runCli(['hackernews', 'ask', '--limit', '3', '-f', 'json']);
|
|
170
|
+
expect(code).toBe(0);
|
|
171
|
+
const data = parseJsonOutput(stdout);
|
|
172
|
+
expect(Array.isArray(data)).toBe(true);
|
|
173
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
174
|
+
expect(data[0]).toHaveProperty('title');
|
|
175
|
+
}, 30_000);
|
|
176
|
+
|
|
177
|
+
it('hackernews show returns Show HN posts', async () => {
|
|
178
|
+
const { stdout, code } = await runCli(['hackernews', 'show', '--limit', '3', '-f', 'json']);
|
|
179
|
+
expect(code).toBe(0);
|
|
180
|
+
const data = parseJsonOutput(stdout);
|
|
181
|
+
expect(Array.isArray(data)).toBe(true);
|
|
182
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
183
|
+
expect(data[0]).toHaveProperty('title');
|
|
184
|
+
}, 30_000);
|
|
185
|
+
|
|
186
|
+
it('hackernews jobs returns job postings', async () => {
|
|
187
|
+
const { stdout, code } = await runCli(['hackernews', 'jobs', '--limit', '3', '-f', 'json']);
|
|
188
|
+
expect(code).toBe(0);
|
|
189
|
+
const data = parseJsonOutput(stdout);
|
|
190
|
+
expect(Array.isArray(data)).toBe(true);
|
|
191
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
192
|
+
expect(data[0]).toHaveProperty('title');
|
|
193
|
+
expect(data[0]).toHaveProperty('url');
|
|
194
|
+
}, 30_000);
|
|
195
|
+
|
|
196
|
+
it('hackernews search returns results for query', async () => {
|
|
197
|
+
const { stdout, code } = await runCli(['hackernews', 'search', 'typescript', '--limit', '3', '-f', 'json']);
|
|
198
|
+
expect(code).toBe(0);
|
|
199
|
+
const data = parseJsonOutput(stdout);
|
|
200
|
+
expect(Array.isArray(data)).toBe(true);
|
|
201
|
+
expect(data.length).toBe(3);
|
|
202
|
+
expect(data[0]).toHaveProperty('title');
|
|
203
|
+
expect(data[0]).toHaveProperty('score');
|
|
204
|
+
expect(data[0]).toHaveProperty('author');
|
|
205
|
+
}, 30_000);
|
|
206
|
+
|
|
207
|
+
it('hackernews user returns user profile', async () => {
|
|
208
|
+
const { stdout, code } = await runCli(['hackernews', 'user', 'pg', '-f', 'json']);
|
|
209
|
+
expect(code).toBe(0);
|
|
210
|
+
const data = parseJsonOutput(stdout);
|
|
211
|
+
expect(Array.isArray(data)).toBe(true);
|
|
212
|
+
expect(data.length).toBe(1);
|
|
213
|
+
expect(data[0]).toHaveProperty('username', 'pg');
|
|
214
|
+
expect(data[0]).toHaveProperty('karma');
|
|
215
|
+
}, 30_000);
|
|
216
|
+
|
|
131
217
|
// ── v2ex (public API, browser: false) ──
|
|
132
218
|
it('v2ex hot returns topics', async () => {
|
|
133
219
|
const { stdout, code } = await runCli(['v2ex', 'hot', '--limit', '3', '-f', 'json']);
|
|
@@ -156,6 +242,69 @@ describe('public commands E2E', () => {
|
|
|
156
242
|
}
|
|
157
243
|
}, 30_000);
|
|
158
244
|
|
|
245
|
+
it('v2ex node returns topics for a given node', async () => {
|
|
246
|
+
const { stdout, code } = await runCli(['v2ex', 'node', 'python', '--limit', '3', '-f', 'json']);
|
|
247
|
+
// V2EX may rate-limit; only assert when successful
|
|
248
|
+
if (code === 0) {
|
|
249
|
+
const data = parseJsonOutput(stdout);
|
|
250
|
+
expect(Array.isArray(data)).toBe(true);
|
|
251
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
252
|
+
expect(data.length).toBeLessThanOrEqual(3);
|
|
253
|
+
expect(data[0]).toHaveProperty('title');
|
|
254
|
+
expect(data[0]).toHaveProperty('author');
|
|
255
|
+
expect(data[0]).toHaveProperty('url');
|
|
256
|
+
}
|
|
257
|
+
}, 30_000);
|
|
258
|
+
|
|
259
|
+
it('v2ex user returns topics by username', async () => {
|
|
260
|
+
const { stdout, code } = await runCli(['v2ex', 'user', 'Livid', '--limit', '3', '-f', 'json']);
|
|
261
|
+
if (code === 0) {
|
|
262
|
+
const data = parseJsonOutput(stdout);
|
|
263
|
+
expect(Array.isArray(data)).toBe(true);
|
|
264
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
265
|
+
expect(data.length).toBeLessThanOrEqual(3);
|
|
266
|
+
expect(data[0]).toHaveProperty('title');
|
|
267
|
+
expect(data[0]).toHaveProperty('node');
|
|
268
|
+
expect(data[0]).toHaveProperty('url');
|
|
269
|
+
}
|
|
270
|
+
}, 30_000);
|
|
271
|
+
|
|
272
|
+
it('v2ex member returns user profile', async () => {
|
|
273
|
+
const { stdout, code } = await runCli(['v2ex', 'member', 'Livid', '-f', 'json']);
|
|
274
|
+
if (code === 0) {
|
|
275
|
+
const data = parseJsonOutput(stdout);
|
|
276
|
+
expect(Array.isArray(data)).toBe(true);
|
|
277
|
+
expect(data.length).toBe(1);
|
|
278
|
+
expect(data[0].username).toBe('Livid');
|
|
279
|
+
}
|
|
280
|
+
}, 30_000);
|
|
281
|
+
|
|
282
|
+
it('v2ex replies returns topic replies', async () => {
|
|
283
|
+
const { stdout, code } = await runCli(['v2ex', 'replies', '1000', '--limit', '3', '-f', 'json']);
|
|
284
|
+
if (code === 0) {
|
|
285
|
+
const data = parseJsonOutput(stdout);
|
|
286
|
+
expect(Array.isArray(data)).toBe(true);
|
|
287
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
288
|
+
expect(data.length).toBeLessThanOrEqual(3);
|
|
289
|
+
expect(data[0]).toHaveProperty('author');
|
|
290
|
+
expect(data[0]).toHaveProperty('content');
|
|
291
|
+
}
|
|
292
|
+
}, 30_000);
|
|
293
|
+
|
|
294
|
+
it('v2ex nodes returns node list sorted by topics', async () => {
|
|
295
|
+
const { stdout, code } = await runCli(['v2ex', 'nodes', '--limit', '5', '-f', 'json']);
|
|
296
|
+
if (code === 0) {
|
|
297
|
+
const data = parseJsonOutput(stdout);
|
|
298
|
+
expect(Array.isArray(data)).toBe(true);
|
|
299
|
+
expect(data.length).toBe(5);
|
|
300
|
+
expect(data[0]).toHaveProperty('name');
|
|
301
|
+
expect(data[0]).toHaveProperty('title');
|
|
302
|
+
expect(data[0]).toHaveProperty('topics');
|
|
303
|
+
// Verify descending sort by topic count
|
|
304
|
+
expect(Number(data[0].topics)).toBeGreaterThanOrEqual(Number(data[data.length - 1].topics));
|
|
305
|
+
}
|
|
306
|
+
}, 30_000);
|
|
307
|
+
|
|
159
308
|
// ── xiaoyuzhou (Chinese site — may return empty on overseas CI runners) ──
|
|
160
309
|
it('xiaoyuzhou podcast returns podcast profile', async () => {
|
|
161
310
|
const { stdout, stderr, code } = await runCli(['xiaoyuzhou', 'podcast', '6013f9f58e2f7ee375cf4216', '-f', 'json']);
|
|
@@ -173,7 +322,13 @@ describe('public commands E2E', () => {
|
|
|
173
322
|
}, 30_000);
|
|
174
323
|
|
|
175
324
|
it('xiaoyuzhou podcast-episodes returns episode list', async () => {
|
|
176
|
-
const { stdout, stderr, code } = await runCli([
|
|
325
|
+
const { stdout, stderr, code } = await runCli([
|
|
326
|
+
'xiaoyuzhou',
|
|
327
|
+
'podcast-episodes',
|
|
328
|
+
'6013f9f58e2f7ee375cf4216',
|
|
329
|
+
'-f',
|
|
330
|
+
'json',
|
|
331
|
+
]);
|
|
177
332
|
if (isExpectedXiaoyuzhouRestriction(code, stderr)) {
|
|
178
333
|
console.warn(`xiaoyuzhou podcast-episodes skipped: ${stderr.trim()}`);
|
|
179
334
|
return;
|
|
@@ -204,7 +359,15 @@ describe('public commands E2E', () => {
|
|
|
204
359
|
}, 30_000);
|
|
205
360
|
|
|
206
361
|
it('xiaoyuzhou podcast-episodes rejects invalid limit', async () => {
|
|
207
|
-
const { stderr, code } = await runCli([
|
|
362
|
+
const { stderr, code } = await runCli([
|
|
363
|
+
'xiaoyuzhou',
|
|
364
|
+
'podcast-episodes',
|
|
365
|
+
'6013f9f58e2f7ee375cf4216',
|
|
366
|
+
'--limit',
|
|
367
|
+
'abc',
|
|
368
|
+
'-f',
|
|
369
|
+
'json',
|
|
370
|
+
]);
|
|
208
371
|
if (isExpectedXiaoyuzhouRestriction(code, stderr)) {
|
|
209
372
|
console.warn(`xiaoyuzhou invalid-limit skipped: ${stderr.trim()}`);
|
|
210
373
|
return;
|
|
@@ -300,4 +463,29 @@ describe('public commands E2E', () => {
|
|
|
300
463
|
expect(data[0]).toHaveProperty('readingCount');
|
|
301
464
|
expect(data[0]).toHaveProperty('bookId');
|
|
302
465
|
}, 30_000);
|
|
466
|
+
|
|
467
|
+
// ── yollomi (browser: false, hardcoded data) ──
|
|
468
|
+
it('yollomi models returns model list with all types', async () => {
|
|
469
|
+
const { stdout, code } = await runCli(['yollomi', 'models', '-f', 'json']);
|
|
470
|
+
expect(code).toBe(0);
|
|
471
|
+
const data = parseJsonOutput(stdout);
|
|
472
|
+
expect(Array.isArray(data)).toBe(true);
|
|
473
|
+
expect(data.length).toBeGreaterThan(10);
|
|
474
|
+
expect(data[0]).toHaveProperty('type');
|
|
475
|
+
expect(data[0]).toHaveProperty('model');
|
|
476
|
+
expect(data[0]).toHaveProperty('credits');
|
|
477
|
+
expect(data[0]).toHaveProperty('description');
|
|
478
|
+
const types = new Set(data.map((d: any) => d.type));
|
|
479
|
+
expect(types.has('image')).toBe(true);
|
|
480
|
+
expect(types.has('video')).toBe(true);
|
|
481
|
+
expect(types.has('tool')).toBe(true);
|
|
482
|
+
}, 30_000);
|
|
483
|
+
|
|
484
|
+
it('yollomi models --type image filters correctly', async () => {
|
|
485
|
+
const { stdout, code } = await runCli(['yollomi', 'models', '--type', 'image', '-f', 'json']);
|
|
486
|
+
expect(code).toBe(0);
|
|
487
|
+
const data = parseJsonOutput(stdout);
|
|
488
|
+
expect(data.length).toBeGreaterThan(0);
|
|
489
|
+
expect(data.every((d: any) => d.type === 'image')).toBe(true);
|
|
490
|
+
}, 30_000);
|
|
303
491
|
});
|
|
@@ -4,11 +4,10 @@
|
|
|
4
4
|
* These verify that external APIs haven't changed their structure.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { describe,
|
|
8
|
-
import {
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import { parseJsonOutput, runCli } from '../e2e/helpers.js';
|
|
9
9
|
|
|
10
10
|
describe('API health smoke tests', () => {
|
|
11
|
-
|
|
12
11
|
// ── Public API commands (should always work) ──
|
|
13
12
|
it('hackernews API is responsive and returns expected structure', async () => {
|
|
14
13
|
const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '5', '-f', 'json']);
|
|
@@ -46,6 +45,53 @@ describe('API health smoke tests', () => {
|
|
|
46
45
|
}
|
|
47
46
|
}, 30_000);
|
|
48
47
|
|
|
48
|
+
it('v2ex node API is responsive', async () => {
|
|
49
|
+
const { stdout, code } = await runCli(['v2ex', 'node', 'python', '--limit', '3', '-f', 'json']);
|
|
50
|
+
if (code === 0) {
|
|
51
|
+
const data = parseJsonOutput(stdout);
|
|
52
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
53
|
+
expect(data[0]).toHaveProperty('title');
|
|
54
|
+
expect(data[0]).toHaveProperty('author');
|
|
55
|
+
}
|
|
56
|
+
}, 30_000);
|
|
57
|
+
|
|
58
|
+
it('v2ex user API is responsive', async () => {
|
|
59
|
+
const { stdout, code } = await runCli(['v2ex', 'user', 'Livid', '--limit', '3', '-f', 'json']);
|
|
60
|
+
if (code === 0) {
|
|
61
|
+
const data = parseJsonOutput(stdout);
|
|
62
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
63
|
+
expect(data[0]).toHaveProperty('title');
|
|
64
|
+
expect(data[0]).toHaveProperty('url');
|
|
65
|
+
}
|
|
66
|
+
}, 30_000);
|
|
67
|
+
|
|
68
|
+
it('v2ex member API is responsive', async () => {
|
|
69
|
+
const { stdout, code } = await runCli(['v2ex', 'member', 'Livid', '-f', 'json']);
|
|
70
|
+
if (code === 0) {
|
|
71
|
+
const data = parseJsonOutput(stdout);
|
|
72
|
+
expect(data.length).toBe(1);
|
|
73
|
+
expect(data[0].username).toBe('Livid');
|
|
74
|
+
}
|
|
75
|
+
}, 30_000);
|
|
76
|
+
|
|
77
|
+
it('v2ex replies API is responsive', async () => {
|
|
78
|
+
const { stdout, code } = await runCli(['v2ex', 'replies', '1000', '--limit', '3', '-f', 'json']);
|
|
79
|
+
if (code === 0) {
|
|
80
|
+
const data = parseJsonOutput(stdout);
|
|
81
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
82
|
+
expect(data[0]).toHaveProperty('author');
|
|
83
|
+
}
|
|
84
|
+
}, 30_000);
|
|
85
|
+
|
|
86
|
+
it('v2ex nodes API is responsive', async () => {
|
|
87
|
+
const { stdout, code } = await runCli(['v2ex', 'nodes', '--limit', '5', '-f', 'json']);
|
|
88
|
+
if (code === 0) {
|
|
89
|
+
const data = parseJsonOutput(stdout);
|
|
90
|
+
expect(data.length).toBe(5);
|
|
91
|
+
expect(data[0]).toHaveProperty('topics');
|
|
92
|
+
}
|
|
93
|
+
}, 30_000);
|
|
94
|
+
|
|
49
95
|
// ── Validate all adapters ──
|
|
50
96
|
it('all adapter definitions are valid', async () => {
|
|
51
97
|
const { stdout, code } = await runCli(['validate']);
|
|
@@ -61,9 +107,22 @@ describe('API health smoke tests', () => {
|
|
|
61
107
|
const sites = new Set(data.map((d: any) => d.site));
|
|
62
108
|
// Verify all 17 sites are present
|
|
63
109
|
for (const expected of [
|
|
64
|
-
'hackernews',
|
|
65
|
-
'
|
|
66
|
-
'
|
|
110
|
+
'hackernews',
|
|
111
|
+
'bbc',
|
|
112
|
+
'bilibili',
|
|
113
|
+
'v2ex',
|
|
114
|
+
'weibo',
|
|
115
|
+
'zhihu',
|
|
116
|
+
'twitter',
|
|
117
|
+
'reddit',
|
|
118
|
+
'xueqiu',
|
|
119
|
+
'reuters',
|
|
120
|
+
'youtube',
|
|
121
|
+
'smzdm',
|
|
122
|
+
'boss',
|
|
123
|
+
'ctrip',
|
|
124
|
+
'coupang',
|
|
125
|
+
'xiaohongshu',
|
|
67
126
|
'yahoo-finance',
|
|
68
127
|
]) {
|
|
69
128
|
expect(sites.has(expected)).toBe(true);
|