public-commands.test.ts
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, expect, it } from 'vitest'; 7 import * as fs from 'node:fs/promises'; 8 import * as os from 'node:os'; 9 import * as path from 'node:path'; 10 import { parseJsonOutput, runCli } from './helpers.js'; 11 12 function isExpectedChineseSiteRestriction(code: number, stderr: string): boolean { 13 if (code === 0) return false; 14 // Overseas CI runners may get HTTP errors, geo-blocks, DNS failures, 15 // or receive mangled HTML that fails parsing. Some runners also fail 16 // without surfacing a useful stderr payload. 17 // Exit code 78 (CONFIG_ERROR) covers adapters that migrated to authenticated 18 // APIs — credentials won't be available in CI. 19 return /Error \[(FETCH_ERROR|PARSE_ERROR|NOT_FOUND)\]/.test(stderr) 20 || /fetch failed/.test(stderr) 21 || /code: CONFIG/.test(stderr) 22 || code === 78 23 || stderr.trim() === ''; 24 } 25 26 function isExpectedApplePodcastsRestriction(code: number, stderr: string): boolean { 27 if (code === 0) return false; 28 return /(?:Error \[FETCH_ERROR\]: )?(Charts API HTTP \d+|Unable to reach Apple Podcasts charts)/.test(stderr) 29 || stderr === ''; // timeout killed the process before any output 30 } 31 32 function isExpectedGoogleRestriction(code: number, stderr: string): boolean { 33 if (code === 0) return false; 34 // Network unreachable (DNS/proxy) or HTTP error from Google 35 return /fetch failed/.test(stderr) || /Error \[FETCH_ERROR\]: HTTP (403|429|451|503)\b/.test(stderr); 36 } 37 38 // Keep old name as alias for existing tests 39 const isExpectedXiaoyuzhouRestriction = isExpectedChineseSiteRestriction; 40 41 describe('public command restriction detectors', () => { 42 it('treats current Apple Podcasts CliError rendering as an expected restriction', () => { 43 expect( 44 isExpectedApplePodcastsRestriction( 45 1, 46 '⚠️ Unable to reach Apple Podcasts charts for US\n→ Apple charts may be temporarily unavailable (ECONNRESET). Try again later.\n', 47 ), 48 ).toBe(true); 49 }); 50 }); 51 52 describe('public commands E2E', () => { 53 // ── apple-podcasts ── 54 it('apple-podcasts search returns structured podcast results', async () => { 55 const { stdout, code } = await runCli(['apple-podcasts', 'search', 'technology', '--limit', '3', '-f', 'json']); 56 expect(code).toBe(0); 57 const data = parseJsonOutput(stdout); 58 expect(Array.isArray(data)).toBe(true); 59 expect(data.length).toBeGreaterThanOrEqual(1); 60 expect(data[0]).toHaveProperty('id'); 61 expect(data[0]).toHaveProperty('title'); 62 expect(data[0]).toHaveProperty('author'); 63 }, 30_000); 64 65 it('apple-podcasts episodes returns episode list from a known show', async () => { 66 const { stdout, code } = await runCli(['apple-podcasts', 'episodes', '275699983', '--limit', '3', '-f', 'json']); 67 expect(code).toBe(0); 68 const data = parseJsonOutput(stdout); 69 expect(Array.isArray(data)).toBe(true); 70 expect(data.length).toBeGreaterThanOrEqual(1); 71 expect(data[0]).toHaveProperty('title'); 72 expect(data[0]).toHaveProperty('duration'); 73 expect(data[0]).toHaveProperty('date'); 74 }, 30_000); 75 76 it('apple-podcasts top returns ranked podcasts', async () => { 77 const { stdout, stderr, code } = await runCli([ 78 'apple-podcasts', 79 'top', 80 '--limit', 81 '3', 82 '--country', 83 'us', 84 '-f', 85 'json', 86 ]); 87 if (isExpectedApplePodcastsRestriction(code, stderr)) { 88 console.warn(`apple-podcasts top skipped: ${stderr.trim()}`); 89 return; 90 } 91 expect(code).toBe(0); 92 const data = parseJsonOutput(stdout); 93 expect(Array.isArray(data)).toBe(true); 94 expect(data.length).toBe(3); 95 expect(data[0]).toHaveProperty('rank'); 96 expect(data[0]).toHaveProperty('title'); 97 expect(data[0]).toHaveProperty('id'); 98 }, 30_000); 99 100 it('paperreview submit dry-run validates a local PDF without remote upload', async () => { 101 const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencli-paperreview-')); 102 const pdfPath = path.join(tempDir, 'sample.pdf'); 103 await fs.writeFile(pdfPath, Buffer.concat([Buffer.from('%PDF-1.4\n'), Buffer.alloc(256, 1)])); 104 105 const { stdout, code } = await runCli([ 106 'paperreview', 107 'submit', 108 pdfPath, 109 '--email', 110 'wang2629651228@gmail.com', 111 '--venue', 112 'RAL', 113 '--dry-run', 114 'true', 115 '-f', 116 'json', 117 ]); 118 119 expect(code).toBe(0); 120 const data = parseJsonOutput(stdout); 121 expect(data).toMatchObject({ 122 status: 'dry-run', 123 file: 'sample.pdf', 124 email: 'wang2629651228@gmail.com', 125 venue: 'RAL', 126 }); 127 }, 30_000); 128 129 // ── hackernews ── 130 it('hackernews top returns structured data', async () => { 131 const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '3', '-f', 'json']); 132 expect(code).toBe(0); 133 const data = parseJsonOutput(stdout); 134 expect(Array.isArray(data)).toBe(true); 135 expect(data.length).toBe(3); 136 expect(data[0]).toHaveProperty('title'); 137 expect(data[0]).toHaveProperty('score'); 138 expect(data[0]).toHaveProperty('rank'); 139 }, 30_000); 140 141 it('hackernews top respects --limit', async () => { 142 const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '1', '-f', 'json']); 143 expect(code).toBe(0); 144 const data = parseJsonOutput(stdout); 145 expect(data.length).toBe(1); 146 }, 30_000); 147 148 it('hackernews new returns newest stories', async () => { 149 const { stdout, code } = await runCli(['hackernews', 'new', '--limit', '3', '-f', 'json']); 150 expect(code).toBe(0); 151 const data = parseJsonOutput(stdout); 152 expect(Array.isArray(data)).toBe(true); 153 expect(data.length).toBeGreaterThanOrEqual(1); 154 expect(data[0]).toHaveProperty('title'); 155 expect(data[0]).toHaveProperty('score'); 156 expect(data[0]).toHaveProperty('rank'); 157 }, 30_000); 158 159 it('hackernews best returns best stories', async () => { 160 const { stdout, code } = await runCli(['hackernews', 'best', '--limit', '3', '-f', 'json']); 161 expect(code).toBe(0); 162 const data = parseJsonOutput(stdout); 163 expect(Array.isArray(data)).toBe(true); 164 expect(data.length).toBeGreaterThanOrEqual(1); 165 expect(data[0]).toHaveProperty('title'); 166 expect(data[0]).toHaveProperty('score'); 167 }, 30_000); 168 169 it('hackernews ask returns Ask HN posts', async () => { 170 const { stdout, code } = await runCli(['hackernews', 'ask', '--limit', '3', '-f', 'json']); 171 expect(code).toBe(0); 172 const data = parseJsonOutput(stdout); 173 expect(Array.isArray(data)).toBe(true); 174 expect(data.length).toBeGreaterThanOrEqual(1); 175 expect(data[0]).toHaveProperty('title'); 176 }, 30_000); 177 178 it('hackernews show returns Show HN posts', async () => { 179 const { stdout, code } = await runCli(['hackernews', 'show', '--limit', '3', '-f', 'json']); 180 expect(code).toBe(0); 181 const data = parseJsonOutput(stdout); 182 expect(Array.isArray(data)).toBe(true); 183 expect(data.length).toBeGreaterThanOrEqual(1); 184 expect(data[0]).toHaveProperty('title'); 185 }, 30_000); 186 187 it('hackernews jobs returns job postings', async () => { 188 const { stdout, code } = await runCli(['hackernews', 'jobs', '--limit', '3', '-f', 'json']); 189 expect(code).toBe(0); 190 const data = parseJsonOutput(stdout); 191 expect(Array.isArray(data)).toBe(true); 192 expect(data.length).toBeGreaterThanOrEqual(1); 193 expect(data[0]).toHaveProperty('title'); 194 expect(data[0]).toHaveProperty('url'); 195 }, 30_000); 196 197 it('hackernews search returns results for query', async () => { 198 const { stdout, code } = await runCli(['hackernews', 'search', 'typescript', '--limit', '3', '-f', 'json']); 199 expect(code).toBe(0); 200 const data = parseJsonOutput(stdout); 201 expect(Array.isArray(data)).toBe(true); 202 expect(data.length).toBe(3); 203 expect(data[0]).toHaveProperty('title'); 204 expect(data[0]).toHaveProperty('score'); 205 expect(data[0]).toHaveProperty('author'); 206 }, 30_000); 207 208 it('hackernews user returns user profile', async () => { 209 const { stdout, code } = await runCli(['hackernews', 'user', 'pg', '-f', 'json']); 210 expect(code).toBe(0); 211 const data = parseJsonOutput(stdout); 212 expect(Array.isArray(data)).toBe(true); 213 expect(data.length).toBe(1); 214 expect(data[0]).toHaveProperty('username', 'pg'); 215 expect(data[0]).toHaveProperty('karma'); 216 }, 30_000); 217 218 // ── v2ex (public API, browser: false) ── 219 it('v2ex hot returns topics', async () => { 220 const { stdout, code } = await runCli(['v2ex', 'hot', '--limit', '3', '-f', 'json']); 221 expect(code).toBe(0); 222 const data = parseJsonOutput(stdout); 223 expect(Array.isArray(data)).toBe(true); 224 expect(data.length).toBeGreaterThanOrEqual(1); 225 expect(data[0]).toHaveProperty('title'); 226 }, 30_000); 227 228 it('v2ex latest returns topics', async () => { 229 const { stdout, code } = await runCli(['v2ex', 'latest', '--limit', '3', '-f', 'json']); 230 expect(code).toBe(0); 231 const data = parseJsonOutput(stdout); 232 expect(Array.isArray(data)).toBe(true); 233 expect(data.length).toBeGreaterThanOrEqual(1); 234 }, 30_000); 235 236 it('v2ex topic returns topic detail', async () => { 237 // Topic 1000001 is a well-known V2EX topic 238 const { stdout, code } = await runCli(['v2ex', 'topic', '1000001', '-f', 'json']); 239 // May fail if V2EX rate-limits, but should return structured data 240 if (code === 0) { 241 const data = parseJsonOutput(stdout); 242 expect(data).toBeDefined(); 243 } 244 }, 30_000); 245 246 it('v2ex node returns topics for a given node', async () => { 247 const { stdout, code } = await runCli(['v2ex', 'node', 'python', '--limit', '3', '-f', 'json']); 248 // V2EX may rate-limit; only assert when successful 249 if (code === 0) { 250 const data = parseJsonOutput(stdout); 251 expect(Array.isArray(data)).toBe(true); 252 expect(data.length).toBeGreaterThanOrEqual(1); 253 expect(data.length).toBeLessThanOrEqual(3); 254 expect(data[0]).toHaveProperty('title'); 255 expect(data[0]).toHaveProperty('author'); 256 expect(data[0]).toHaveProperty('url'); 257 } 258 }, 30_000); 259 260 it('v2ex user returns topics by username', async () => { 261 const { stdout, code } = await runCli(['v2ex', 'user', 'Livid', '--limit', '3', '-f', 'json']); 262 if (code === 0) { 263 const data = parseJsonOutput(stdout); 264 expect(Array.isArray(data)).toBe(true); 265 expect(data.length).toBeGreaterThanOrEqual(1); 266 expect(data.length).toBeLessThanOrEqual(3); 267 expect(data[0]).toHaveProperty('title'); 268 expect(data[0]).toHaveProperty('node'); 269 expect(data[0]).toHaveProperty('url'); 270 } 271 }, 30_000); 272 273 it('v2ex member returns user profile', async () => { 274 const { stdout, code } = await runCli(['v2ex', 'member', 'Livid', '-f', 'json']); 275 if (code === 0) { 276 const data = parseJsonOutput(stdout); 277 expect(Array.isArray(data)).toBe(true); 278 expect(data.length).toBe(1); 279 expect(data[0].username).toBe('Livid'); 280 } 281 }, 30_000); 282 283 it('v2ex replies returns topic replies', async () => { 284 const { stdout, code } = await runCli(['v2ex', 'replies', '1000', '--limit', '3', '-f', 'json']); 285 if (code === 0) { 286 const data = parseJsonOutput(stdout); 287 expect(Array.isArray(data)).toBe(true); 288 expect(data.length).toBeGreaterThanOrEqual(1); 289 expect(data.length).toBeLessThanOrEqual(3); 290 expect(data[0]).toHaveProperty('author'); 291 expect(data[0]).toHaveProperty('content'); 292 } 293 }, 30_000); 294 295 it('v2ex nodes returns node list sorted by topics', async () => { 296 const { stdout, code } = await runCli(['v2ex', 'nodes', '--limit', '5', '-f', 'json']); 297 if (code === 0) { 298 const data = parseJsonOutput(stdout); 299 expect(Array.isArray(data)).toBe(true); 300 expect(data.length).toBe(5); 301 expect(data[0]).toHaveProperty('name'); 302 expect(data[0]).toHaveProperty('title'); 303 expect(data[0]).toHaveProperty('topics'); 304 // Verify descending sort by topic count 305 expect(Number(data[0].topics)).toBeGreaterThanOrEqual(Number(data[data.length - 1].topics)); 306 } 307 }, 30_000); 308 309 // ── xiaoyuzhou (Chinese site — may return empty on overseas CI runners) ── 310 it('xiaoyuzhou podcast returns podcast profile', async () => { 311 const { stdout, stderr, code } = await runCli(['xiaoyuzhou', 'podcast', '6013f9f58e2f7ee375cf4216', '-f', 'json']); 312 if (isExpectedXiaoyuzhouRestriction(code, stderr)) { 313 console.warn(`xiaoyuzhou podcast skipped: ${stderr.trim()}`); 314 return; 315 } 316 expect(code).toBe(0); 317 const data = parseJsonOutput(stdout); 318 expect(Array.isArray(data)).toBe(true); 319 expect(data.length).toBe(1); 320 expect(data[0]).toHaveProperty('title'); 321 expect(data[0]).toHaveProperty('subscribers'); 322 expect(data[0]).toHaveProperty('episodes'); 323 }, 30_000); 324 325 it('xiaoyuzhou podcast-episodes returns episode list', async () => { 326 const { stdout, stderr, code } = await runCli([ 327 'xiaoyuzhou', 328 'podcast-episodes', 329 '6013f9f58e2f7ee375cf4216', 330 '-f', 331 'json', 332 ]); 333 if (isExpectedXiaoyuzhouRestriction(code, stderr)) { 334 console.warn(`xiaoyuzhou podcast-episodes skipped: ${stderr.trim()}`); 335 return; 336 } 337 expect(code).toBe(0); 338 const data = parseJsonOutput(stdout); 339 expect(Array.isArray(data)).toBe(true); 340 expect(data.length).toBeGreaterThanOrEqual(1); 341 expect(data[0]).toHaveProperty('eid'); 342 expect(data[0]).toHaveProperty('title'); 343 expect(data[0]).toHaveProperty('duration'); 344 }, 30_000); 345 346 it('xiaoyuzhou episode returns episode detail', async () => { 347 const { stdout, stderr, code } = await runCli(['xiaoyuzhou', 'episode', '69b3b675772ac2295bfc01d0', '-f', 'json']); 348 if (isExpectedXiaoyuzhouRestriction(code, stderr)) { 349 console.warn(`xiaoyuzhou episode skipped: ${stderr.trim()}`); 350 return; 351 } 352 expect(code).toBe(0); 353 const data = parseJsonOutput(stdout); 354 expect(Array.isArray(data)).toBe(true); 355 expect(data.length).toBe(1); 356 expect(data[0]).toHaveProperty('title'); 357 expect(data[0]).toHaveProperty('podcast'); 358 expect(data[0]).toHaveProperty('plays'); 359 expect(data[0]).toHaveProperty('comments'); 360 }, 30_000); 361 362 it('xiaoyuzhou podcast-episodes rejects invalid limit', async () => { 363 const { stderr, code } = await runCli([ 364 'xiaoyuzhou', 365 'podcast-episodes', 366 '6013f9f58e2f7ee375cf4216', 367 '--limit', 368 'abc', 369 '-f', 370 'json', 371 ]); 372 if (isExpectedXiaoyuzhouRestriction(code, stderr)) { 373 console.warn(`xiaoyuzhou invalid-limit skipped: ${stderr.trim()}`); 374 return; 375 } 376 expect(code).not.toBe(0); 377 expect(stderr).toMatch(/limit must be a positive integer|Argument "limit" must be a valid number/); 378 }, 30_000); 379 380 // ── google suggest (public JSON API) ── 381 it('google suggest returns suggestions', async () => { 382 const { stdout, stderr, code } = await runCli(['google', 'suggest', 'python', '-f', 'json']); 383 if (isExpectedGoogleRestriction(code, stderr)) { 384 console.warn(`google suggest skipped: ${stderr.trim()}`); 385 return; 386 } 387 expect(code).toBe(0); 388 const data = parseJsonOutput(stdout); 389 expect(Array.isArray(data)).toBe(true); 390 expect(data.length).toBeGreaterThanOrEqual(1); 391 expect(data[0]).toHaveProperty('suggestion'); 392 }, 30_000); 393 394 // ── google news (public RSS) ── 395 it('google news returns headlines', async () => { 396 const { stdout, stderr, code } = await runCli(['google', 'news', '--limit', '3', '-f', 'json']); 397 if (isExpectedGoogleRestriction(code, stderr)) { 398 console.warn(`google news skipped: ${stderr.trim()}`); 399 return; 400 } 401 expect(code).toBe(0); 402 const data = parseJsonOutput(stdout); 403 expect(Array.isArray(data)).toBe(true); 404 expect(data.length).toBeGreaterThanOrEqual(1); 405 expect(data[0]).toHaveProperty('title'); 406 expect(data[0]).toHaveProperty('source'); 407 expect(data[0]).toHaveProperty('url'); 408 }, 30_000); 409 410 it('google news search returns results', async () => { 411 const { stdout, stderr, code } = await runCli(['google', 'news', 'AI', '--limit', '3', '-f', 'json']); 412 if (isExpectedGoogleRestriction(code, stderr)) { 413 console.warn(`google news search skipped: ${stderr.trim()}`); 414 return; 415 } 416 expect(code).toBe(0); 417 const data = parseJsonOutput(stdout); 418 expect(Array.isArray(data)).toBe(true); 419 expect(data.length).toBeGreaterThanOrEqual(1); 420 expect(data[0]).toHaveProperty('title'); 421 }, 30_000); 422 423 // ── google trends (public RSS) ── 424 it('google trends returns trending searches', async () => { 425 const { stdout, stderr, code } = await runCli(['google', 'trends', '--region', 'US', '--limit', '3', '-f', 'json']); 426 if (isExpectedGoogleRestriction(code, stderr)) { 427 console.warn(`google trends skipped: ${stderr.trim()}`); 428 return; 429 } 430 expect(code).toBe(0); 431 const data = parseJsonOutput(stdout); 432 expect(Array.isArray(data)).toBe(true); 433 expect(data.length).toBeGreaterThanOrEqual(1); 434 expect(data[0]).toHaveProperty('title'); 435 expect(data[0]).toHaveProperty('traffic'); 436 }, 30_000); 437 438 // ── weread (Chinese site — may return empty on overseas CI runners) ── 439 it('weread search returns books', async () => { 440 const { stdout, stderr, code } = await runCli(['weread', 'search', 'python', '--limit', '3', '-f', 'json']); 441 if (isExpectedChineseSiteRestriction(code, stderr)) { 442 console.warn(`weread search skipped: ${stderr.trim()}`); 443 return; 444 } 445 expect(code).toBe(0); 446 const data = parseJsonOutput(stdout); 447 expect(Array.isArray(data)).toBe(true); 448 expect(data.length).toBeGreaterThanOrEqual(1); 449 expect(data[0]).toHaveProperty('title'); 450 expect(data[0]).toHaveProperty('bookId'); 451 }, 30_000); 452 453 it('weread ranking returns books', async () => { 454 const { stdout, stderr, code } = await runCli(['weread', 'ranking', 'all', '--limit', '3', '-f', 'json']); 455 if (isExpectedChineseSiteRestriction(code, stderr)) { 456 console.warn(`weread ranking skipped: ${stderr.trim()}`); 457 return; 458 } 459 expect(code).toBe(0); 460 const data = parseJsonOutput(stdout); 461 expect(Array.isArray(data)).toBe(true); 462 expect(data.length).toBeGreaterThanOrEqual(1); 463 expect(data[0]).toHaveProperty('title'); 464 expect(data[0]).toHaveProperty('readingCount'); 465 expect(data[0]).toHaveProperty('bookId'); 466 }, 30_000); 467 468 // ── yollomi (browser: false, hardcoded data) ── 469 it('yollomi models returns model list with all types', async () => { 470 const { stdout, code } = await runCli(['yollomi', 'models', '-f', 'json']); 471 expect(code).toBe(0); 472 const data = parseJsonOutput(stdout); 473 expect(Array.isArray(data)).toBe(true); 474 expect(data.length).toBeGreaterThan(10); 475 expect(data[0]).toHaveProperty('type'); 476 expect(data[0]).toHaveProperty('model'); 477 expect(data[0]).toHaveProperty('credits'); 478 expect(data[0]).toHaveProperty('description'); 479 const types = new Set(data.map((d: any) => d.type)); 480 expect(types.has('image')).toBe(true); 481 expect(types.has('video')).toBe(true); 482 expect(types.has('tool')).toBe(true); 483 }, 30_000); 484 485 it('yollomi models --type image filters correctly', async () => { 486 const { stdout, code } = await runCli(['yollomi', 'models', '--type', 'image', '-f', 'json']); 487 expect(code).toBe(0); 488 const data = parseJsonOutput(stdout); 489 expect(data.length).toBeGreaterThan(0); 490 expect(data.every((d: any) => d.type === 'image')).toBe(true); 491 }, 30_000); 492 493 // ── dictionary (public API, browser: false) ── 494 it('dictionary search returns word definitions', async () => { 495 const { stdout, code } = await runCli(['dictionary', 'search', 'serendipity', '-f', 'json']); 496 expect(code).toBe(0); 497 const data = parseJsonOutput(stdout); 498 expect(Array.isArray(data)).toBe(true); 499 expect(data.length).toBeGreaterThanOrEqual(1); 500 expect(data[0]).toHaveProperty('word', 'serendipity'); 501 expect(data[0]).toHaveProperty('phonetic'); 502 expect(data[0]).toHaveProperty('definition'); 503 }, 30_000); 504 505 it('dictionary synonyms returns synonyms', async () => { 506 const { stdout, code } = await runCli(['dictionary', 'synonyms', 'serendipity', '-f', 'json']); 507 expect(code).toBe(0); 508 const data = parseJsonOutput(stdout); 509 expect(Array.isArray(data)).toBe(true); 510 expect(data.length).toBeGreaterThanOrEqual(1); 511 expect(data[0]).toHaveProperty('word', 'serendipity'); 512 expect(data[0]).toHaveProperty('synonyms'); 513 }, 30_000); 514 515 it('dictionary examples returns examples', async () => { 516 const { stdout, code } = await runCli(['dictionary', 'examples', 'perfect', '-f', 'json']); 517 expect(code).toBe(0); 518 const data = parseJsonOutput(stdout); 519 expect(Array.isArray(data)).toBe(true); 520 expect(data.length).toBeGreaterThanOrEqual(1); 521 expect(data[0]).toHaveProperty('word', 'perfect'); 522 expect(data[0]).toHaveProperty('example'); 523 }, 30_000); 524 });