/ tests / e2e / public-commands.test.ts
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  });