Compare commits
3 Commits
instagram-post
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ae4929e73d | |||
| 92408e7ee9 | |||
| 257e9ec4f8 |
@@ -0,0 +1,26 @@
|
||||
name: Setup Chrome + xvfb
|
||||
description: Install real Chrome and xvfb virtual display for headed browser testing
|
||||
|
||||
outputs:
|
||||
chrome-path:
|
||||
description: Path to the installed Chrome binary
|
||||
value: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install real Chrome (stable)
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
id: setup-chrome
|
||||
with:
|
||||
chrome-version: stable
|
||||
|
||||
- name: Verify Chrome installation
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Chrome path: ${{ steps.setup-chrome.outputs.chrome-path }}"
|
||||
${{ steps.setup-chrome.outputs.chrome-path }} --version
|
||||
|
||||
- name: Install xvfb for headed mode
|
||||
shell: bash
|
||||
run: sudo apt-get install -y xvfb
|
||||
@@ -2,12 +2,16 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
schedule:
|
||||
- cron: '0 8 * * 1' # Weekly Monday 08:00 UTC — smoke tests
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
# ── Fast gate: typecheck + build ──
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -15,6 +19,7 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -24,3 +29,54 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# ── Unit tests (vitest shard) ──
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests (shard ${{ matrix.shard }}/2)
|
||||
run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
|
||||
# ── Smoke tests (scheduled / manual only) ──
|
||||
smoke-test:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Chrome + xvfb
|
||||
uses: ./.github/actions/setup-chrome
|
||||
id: setup-chrome
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run smoke tests
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
|
||||
npx vitest run tests/smoke/ --reporter=verbose
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
timeout-minutes: 15
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: E2E Headed Chrome
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e-headed:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Chrome + xvfb
|
||||
uses: ./.github/actions/setup-chrome
|
||||
id: setup-chrome
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run E2E tests (headed Chrome + xvfb)
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
|
||||
npx vitest run tests/e2e/ --reporter=verbose
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.opencli/
|
||||
.mcp.json
|
||||
|
||||
@@ -21,6 +21,7 @@ A CLI tool that turns **any website** into a command-line interface. **59 comman
|
||||
- [Built-in Commands](#built-in-commands)
|
||||
- [Output Formats](#output-formats)
|
||||
- [For AI Agents (Developer Guide)](#for-ai-agents-developer-guide)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Releasing New Versions](#releasing-new-versions)
|
||||
- [License](#license)
|
||||
@@ -176,6 +177,24 @@ opencli cascade https://api.example.com/data
|
||||
|
||||
Explore outputs to `.opencli/explore/<site>/` (manifest.json, endpoints.json, capabilities.json, auth.json).
|
||||
|
||||
## Testing
|
||||
|
||||
See **[TESTING.md](./TESTING.md)** for the full testing guide, including:
|
||||
|
||||
- Current test coverage (unit + ~52 E2E tests across all 18 sites)
|
||||
- How to run tests locally
|
||||
- How to add tests when creating new adapters
|
||||
- CI/CD pipeline with sharding
|
||||
- Headless browser mode (`OPENCLI_HEADLESS=1`)
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
npm run build
|
||||
npx vitest run # All tests
|
||||
npx vitest run src/ # Unit tests only
|
||||
npx vitest run tests/e2e/ # E2E tests
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Failed to connect to Playwright MCP Bridge"**
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
# Testing Guide
|
||||
|
||||
> 面向开发者和 AI Agent 的测试参考手册。
|
||||
|
||||
## 目录
|
||||
|
||||
- [测试架构](#测试架构)
|
||||
- [当前覆盖范围](#当前覆盖范围)
|
||||
- [本地运行测试](#本地运行测试)
|
||||
- [如何添加新测试](#如何添加新测试)
|
||||
- [CI/CD 流水线](#cicd-流水线)
|
||||
- [浏览器模式](#浏览器模式)
|
||||
- [站点兼容性](#站点兼容性)
|
||||
|
||||
---
|
||||
|
||||
## 测试架构
|
||||
|
||||
测试分为三层,全部使用 **vitest** 运行:
|
||||
|
||||
```
|
||||
tests/
|
||||
├── e2e/ # E2E 集成测试(子进程运行真实 CLI)
|
||||
│ ├── helpers.ts # runCli() 共享工具
|
||||
│ ├── public-commands.test.ts # 公开 API 命令(无需浏览器)
|
||||
│ ├── browser-public.test.ts # 浏览器命令(公开数据)
|
||||
│ ├── browser-auth.test.ts # 需登录命令(graceful failure 测试)
|
||||
│ ├── management.test.ts # 管理命令(list, validate, verify, help)
|
||||
│ └── output-formats.test.ts # 输出格式(json/yaml/csv/md)
|
||||
├── smoke/ # 烟雾测试(仅定时 / 手动触发)
|
||||
│ └── api-health.test.ts # 外部 API 可用性检测
|
||||
src/
|
||||
├── *.test.ts # 单元测试(已有 8 个)
|
||||
```
|
||||
|
||||
| 层 | 位置 | 运行方式 | 用途 |
|
||||
|---|---|---|---|
|
||||
| 单元测试 | `src/**/*.test.ts` | `npx vitest run src/` | 内部模块逻辑 |
|
||||
| E2E 测试 | `tests/e2e/*.test.ts` | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
|
||||
| 烟雾测试 | `tests/smoke/*.test.ts` | `npx vitest run tests/smoke/` | 外部 API 健康 |
|
||||
|
||||
---
|
||||
|
||||
## 当前覆盖范围
|
||||
|
||||
### 单元测试(8 个文件)
|
||||
|
||||
| 文件 | 覆盖内容 |
|
||||
|---|---|
|
||||
| `browser.test.ts` | JSON-RPC、tab 管理、extension/standalone 模式切换 |
|
||||
| `engine.test.ts` | 命令发现与执行 |
|
||||
| `registry.test.ts` | 命令注册与策略分配 |
|
||||
| `output.test.ts` | 输出格式渲染 |
|
||||
| `doctor.test.ts` | Token 诊断 |
|
||||
| `coupang.test.ts` | 数据归一化 |
|
||||
| `pipeline/template.test.ts` | 模板表达式求值 |
|
||||
| `pipeline/transform.test.ts` | 数据变换步骤 |
|
||||
|
||||
### E2E 测试(~52 个用例)
|
||||
|
||||
| 文件 | 覆盖站点/功能 | 测试数 |
|
||||
|---|---|---|
|
||||
| `public-commands.test.ts` | hackernews/top, v2ex/hot, v2ex/latest, v2ex/topic | 5 |
|
||||
| `browser-public.test.ts` | bbc, bilibili×3, weibo, zhihu×2, reddit×2, twitter, xueqiu×2, reuters, youtube, smzdm, boss, ctrip, coupang, xiaohongshu, yahoo-finance, v2ex/daily | 21 |
|
||||
| `browser-auth.test.ts` | bilibili/me,dynamic,favorite,history,following + twitter/bookmarks,timeline,notifications + v2ex/me,notifications + xueqiu/feed,watchlist + xiaohongshu/feed,notifications | 14 |
|
||||
| `management.test.ts` | list×5 格式, validate×3 级别, verify, --version, --help, unknown cmd | 12 |
|
||||
| `output-formats.test.ts` | json, yaml, csv, md 格式验证 | 5 |
|
||||
|
||||
### 烟雾测试
|
||||
|
||||
公开 API 可用性(hackernews, v2ex×2, v2ex/topic)+ 全站点注册完整性检查。
|
||||
|
||||
---
|
||||
|
||||
## 本地运行测试
|
||||
|
||||
### 前置条件
|
||||
|
||||
```bash
|
||||
npm ci # 安装依赖
|
||||
npm run build # 编译(E2E 测试需要 dist/main.js)
|
||||
```
|
||||
|
||||
### 运行命令
|
||||
|
||||
```bash
|
||||
# 全部单元测试
|
||||
npx vitest run src/
|
||||
|
||||
# 全部 E2E 测试(会真实调用外部 API)
|
||||
npx vitest run tests/e2e/
|
||||
|
||||
# 单个测试文件
|
||||
npx vitest run tests/e2e/management.test.ts
|
||||
|
||||
# 全部测试(单元 + E2E)
|
||||
npx vitest run
|
||||
|
||||
# 烟雾测试
|
||||
npx vitest run tests/smoke/
|
||||
|
||||
# watch 模式(开发时推荐)
|
||||
npx vitest src/
|
||||
```
|
||||
|
||||
### 浏览器命令本地测试须知
|
||||
|
||||
- 无 `PLAYWRIGHT_MCP_EXTENSION_TOKEN` 时,opencli 自动启动一个独立浏览器实例
|
||||
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬导致空数据时 warn + pass
|
||||
- `browser-auth.test.ts` 验证 **graceful failure**(不 crash 不 hang 即通过)
|
||||
- 如需测试完整登录态,保持 Chrome 登录态 + 设置 `PLAYWRIGHT_MCP_EXTENSION_TOKEN`,手动跑对应测试
|
||||
|
||||
---
|
||||
|
||||
## 如何添加新测试
|
||||
|
||||
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`)
|
||||
|
||||
1. **无需额外操作**:`validate` 测试会自动覆盖 YAML 结构验证
|
||||
2. 根据 adapter 类型,在对应文件加一个 `it()` block:
|
||||
|
||||
```typescript
|
||||
// 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts
|
||||
it('producthunt trending returns data', async () => {
|
||||
const { stdout, code } = await runCli(['producthunt', 'trending', '--limit', '3', '-f', 'json']);
|
||||
expect(code).toBe(0);
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(data[0]).toHaveProperty('title');
|
||||
}, 30_000);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 如果 browser: true 但可公开访问 → tests/e2e/browser-public.test.ts
|
||||
it('producthunt trending returns data', async () => {
|
||||
const data = await tryBrowserCommand(['producthunt', 'trending', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'producthunt trending');
|
||||
}, 60_000);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 如果 browser: true 且需登录 → tests/e2e/browser-auth.test.ts
|
||||
it('producthunt me fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['producthunt', 'me', '-f', 'json'], 'producthunt me');
|
||||
}, 60_000);
|
||||
```
|
||||
|
||||
### 新增管理命令(如 `opencli export`)
|
||||
|
||||
在 `tests/e2e/management.test.ts` 添加测试。
|
||||
|
||||
### 新增内部模块
|
||||
|
||||
在 `src/` 下对应位置创建 `*.test.ts`。
|
||||
|
||||
### 决策流程图
|
||||
|
||||
```
|
||||
新增功能 → 是内部模块? → 是 → src/ 下加 *.test.ts
|
||||
↓ 否
|
||||
是 CLI 命令? → browser: false? → tests/e2e/public-commands.test.ts
|
||||
↓ true
|
||||
公开数据? → tests/e2e/browser-public.test.ts
|
||||
↓ 需登录
|
||||
tests/e2e/browser-auth.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD 流水线
|
||||
|
||||
### ci.yml(主流水线)
|
||||
|
||||
| Job | 触发条件 | 内容 |
|
||||
|---|---|---|
|
||||
| **build** | push/PR to main,dev | typecheck + build |
|
||||
| **unit-test** | push/PR to main,dev | 单元测试,2 shard 并行 |
|
||||
| **smoke-test** | 每周一 08:00 UTC / 手动 | xvfb + real Chrome,外部 API 健康检查 |
|
||||
|
||||
### e2e-headed.yml(E2E 测试)
|
||||
|
||||
| Job | 触发条件 | 内容 |
|
||||
|---|---|---|
|
||||
| **e2e-headed** | push/PR to main,dev | xvfb + real Chrome,全部 E2E 测试 |
|
||||
|
||||
E2E 使用 `browser-actions/setup-chrome` 安装真实 Chrome,配合 `xvfb-run` 提供虚拟显示器,以 headed 模式运行浏览器。
|
||||
|
||||
### Sharding
|
||||
|
||||
单元测试使用 vitest 内置 shard:
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- run: npx vitest run src/ --shard=${{ matrix.shard }}/2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 浏览器模式
|
||||
|
||||
opencli 根据 `PLAYWRIGHT_MCP_EXTENSION_TOKEN` 环境变量自动选择模式:
|
||||
|
||||
| 条件 | 模式 | MCP 参数 | 使用场景 |
|
||||
|---|---|---|---|
|
||||
| Token 已设置 | Extension 模式 | `--extension` | 本地用户,连接已登录的 Chrome |
|
||||
| Token 未设置 | Standalone 模式 | (无特殊 flag) | CI 或无扩展环境,自启浏览器 |
|
||||
|
||||
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 站点兼容性
|
||||
|
||||
在 GitHub Actions 美国 runner 上,部分站点因地域限制或登录要求返回空数据。E2E 测试对这些站点使用 warn + pass 策略,不影响 CI 绿灯。
|
||||
|
||||
| 站点 | CI 状态 | 限制原因 |
|
||||
|---|---|---|
|
||||
| hackernews, bbc, v2ex | ✅ 返回数据 | 无限制 |
|
||||
| yahoo-finance | ✅ 返回数据 | 无限制 |
|
||||
| bilibili, zhihu, weibo, xiaohongshu | ⚠️ 空数据 | 地域限制(中国站点) |
|
||||
| reddit, twitter, youtube | ⚠️ 空数据 | 需登录或 cookie |
|
||||
| smzdm, boss, ctrip, coupang, xueqiu | ⚠️ 空数据 | 地域限制 / 需登录 |
|
||||
|
||||
> 使用 self-hosted runner(国内服务器)可解决地域限制问题。
|
||||
+54
-16
@@ -49,23 +49,61 @@ describe('browser helpers', () => {
|
||||
expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
|
||||
});
|
||||
|
||||
it('builds Playwright MCP args with kebab-case executable path', () => {
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--extension',
|
||||
'--executable-path',
|
||||
'/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
]);
|
||||
it('builds extension MCP args in local mode (no CI)', () => {
|
||||
const savedCI = process.env.CI;
|
||||
delete process.env.CI;
|
||||
try {
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--extension',
|
||||
'--executable-path',
|
||||
'/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
]);
|
||||
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--extension',
|
||||
]);
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--extension',
|
||||
]);
|
||||
} finally {
|
||||
if (savedCI !== undefined) {
|
||||
process.env.CI = savedCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('builds standalone MCP args in CI mode', () => {
|
||||
const savedCI = process.env.CI;
|
||||
process.env.CI = 'true';
|
||||
try {
|
||||
// CI mode: no --extension — browser launches in standalone headed mode
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
]);
|
||||
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
executablePath: '/usr/bin/chromium',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--executable-path',
|
||||
'/usr/bin/chromium',
|
||||
]);
|
||||
} finally {
|
||||
if (savedCI !== undefined) {
|
||||
process.env.CI = savedCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('times out slow promises', async () => {
|
||||
|
||||
+10
-2
@@ -349,6 +349,7 @@ export class PlaywrightMCP {
|
||||
return new Promise<Page>((resolve, reject) => {
|
||||
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
|
||||
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
|
||||
const useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
||||
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
||||
const tokenFingerprint = getTokenFingerprint(extensionToken);
|
||||
let stderrBuffer = '';
|
||||
@@ -392,7 +393,8 @@ export class PlaywrightMCP {
|
||||
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
|
||||
});
|
||||
if (process.env.OPENCLI_VERBOSE) {
|
||||
console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
|
||||
console.error(`[opencli] Mode: ${useExtension ? 'extension' : 'standalone'}`);
|
||||
if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
|
||||
}
|
||||
debugLog(`Spawning node ${mcpArgs.join(' ')}`);
|
||||
|
||||
@@ -610,7 +612,13 @@ function appendLimited(current: string, chunk: string, limit: number): string {
|
||||
}
|
||||
|
||||
function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
|
||||
const args = [input.mcpPath, '--extension'];
|
||||
const args = [input.mcpPath];
|
||||
if (!process.env.CI) {
|
||||
// Local: always connect to user's running Chrome via MCP Bridge extension
|
||||
args.push('--extension');
|
||||
}
|
||||
// CI: standalone mode — @playwright/mcp launches its own browser (headed by default).
|
||||
// xvfb provides a virtual display for headed mode in GitHub Actions.
|
||||
if (input.executablePath) {
|
||||
args.push('--executable-path', input.executablePath);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* E2E tests for login-required browser commands.
|
||||
* These commands REQUIRE authentication (cookie/session).
|
||||
* In CI (headless, no login), they should fail gracefully — NOT crash.
|
||||
*
|
||||
* These tests verify the error handling path, not the data extraction.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runCli } from './helpers.js';
|
||||
|
||||
/**
|
||||
* Verify a login-required command fails gracefully (no crash, no hang).
|
||||
* Acceptable outcomes: exit code 1 with error message, OR timeout handled.
|
||||
*/
|
||||
async function expectGracefulAuthFailure(args: string[], label: string) {
|
||||
const { stdout, stderr, code } = await runCli(args, { timeout: 60_000 });
|
||||
// Should either fail with exit code 1 (error message) or succeed with empty data
|
||||
// The key assertion: it should NOT hang forever or crash with unhandled exception
|
||||
if (code !== 0) {
|
||||
// Verify stderr has a meaningful error, not an unhandled crash
|
||||
const output = stderr + stdout;
|
||||
expect(output.length).toBeGreaterThan(0);
|
||||
}
|
||||
// If it somehow succeeds (e.g., partial public data), that's fine too
|
||||
}
|
||||
|
||||
describe('login-required commands — graceful failure', () => {
|
||||
|
||||
// ── bilibili (requires cookie session) ──
|
||||
it('bilibili me fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['bilibili', 'me', '-f', 'json'], 'bilibili me');
|
||||
}, 60_000);
|
||||
|
||||
it('bilibili dynamic fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['bilibili', 'dynamic', '--limit', '3', '-f', 'json'], 'bilibili dynamic');
|
||||
}, 60_000);
|
||||
|
||||
it('bilibili favorite fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['bilibili', 'favorite', '--limit', '3', '-f', 'json'], 'bilibili favorite');
|
||||
}, 60_000);
|
||||
|
||||
it('bilibili history fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['bilibili', 'history', '--limit', '3', '-f', 'json'], 'bilibili history');
|
||||
}, 60_000);
|
||||
|
||||
it('bilibili following fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['bilibili', 'following', '--limit', '3', '-f', 'json'], 'bilibili following');
|
||||
}, 60_000);
|
||||
|
||||
// ── twitter (requires login) ──
|
||||
it('twitter bookmarks fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['twitter', 'bookmarks', '--limit', '3', '-f', 'json'], 'twitter bookmarks');
|
||||
}, 60_000);
|
||||
|
||||
it('twitter timeline fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['twitter', 'timeline', '--limit', '3', '-f', 'json'], 'twitter timeline');
|
||||
}, 60_000);
|
||||
|
||||
it('twitter notifications fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['twitter', 'notifications', '--limit', '3', '-f', 'json'], 'twitter notifications');
|
||||
}, 60_000);
|
||||
|
||||
// ── v2ex (requires login) ──
|
||||
it('v2ex me fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['v2ex', 'me', '-f', 'json'], 'v2ex me');
|
||||
}, 60_000);
|
||||
|
||||
it('v2ex notifications fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['v2ex', 'notifications', '--limit', '3', '-f', 'json'], 'v2ex notifications');
|
||||
}, 60_000);
|
||||
|
||||
// ── xueqiu (requires login) ──
|
||||
it('xueqiu feed fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['xueqiu', 'feed', '--limit', '3', '-f', 'json'], 'xueqiu feed');
|
||||
}, 60_000);
|
||||
|
||||
it('xueqiu watchlist fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['xueqiu', 'watchlist', '-f', 'json'], 'xueqiu watchlist');
|
||||
}, 60_000);
|
||||
|
||||
// ── xiaohongshu (requires login) ──
|
||||
it('xiaohongshu feed fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['xiaohongshu', 'feed', '--limit', '3', '-f', 'json'], 'xiaohongshu feed');
|
||||
}, 60_000);
|
||||
|
||||
it('xiaohongshu notifications fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['xiaohongshu', 'notifications', '--limit', '3', '-f', 'json'], 'xiaohongshu notifications');
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* E2E tests for browser commands that access PUBLIC data (no login required).
|
||||
* These use OPENCLI_HEADLESS=1 to launch a headless Chromium.
|
||||
*
|
||||
* NOTE: Some sites may block headless browsers with bot detection.
|
||||
* Tests are wrapped with tryBrowserCommand() which allows graceful failure.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runCli, parseJsonOutput } from './helpers.js';
|
||||
|
||||
/**
|
||||
* Run a browser command — returns parsed data or null on failure.
|
||||
*/
|
||||
async function tryBrowserCommand(args: string[]): Promise<any[] | null> {
|
||||
const { stdout, code } = await runCli(args, { timeout: 60_000 });
|
||||
if (code !== 0) return null;
|
||||
try {
|
||||
const data = parseJsonOutput(stdout);
|
||||
return Array.isArray(data) ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert browser command returns data OR log a warning if blocked.
|
||||
* Empty results (bot detection, geo-blocking) are treated as a warning, not a failure.
|
||||
*/
|
||||
function expectDataOrSkip(data: any[] | null, label: string) {
|
||||
if (data === null || data.length === 0) {
|
||||
console.warn(`${label}: skipped — no data returned (likely bot detection or geo-blocking)`);
|
||||
return;
|
||||
}
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
describe('browser public-data commands E2E', () => {
|
||||
|
||||
// ── bbc (browser: true, strategy: public) ──
|
||||
it('bbc news returns headlines', async () => {
|
||||
const data = await tryBrowserCommand(['bbc', 'news', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'bbc news');
|
||||
if (data) {
|
||||
expect(data[0]).toHaveProperty('title');
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// ── v2ex daily (browser: true) ──
|
||||
it('v2ex daily returns topics', async () => {
|
||||
const data = await tryBrowserCommand(['v2ex', 'daily', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'v2ex daily');
|
||||
}, 60_000);
|
||||
|
||||
// ── bilibili (browser: true, cookie strategy) ──
|
||||
it('bilibili hot returns trending videos', async () => {
|
||||
const data = await tryBrowserCommand(['bilibili', 'hot', '--limit', '5', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'bilibili hot');
|
||||
if (data) {
|
||||
expect(data[0]).toHaveProperty('title');
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('bilibili ranking returns ranked videos', async () => {
|
||||
const data = await tryBrowserCommand(['bilibili', 'ranking', '--limit', '5', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'bilibili ranking');
|
||||
}, 60_000);
|
||||
|
||||
it('bilibili search returns results', async () => {
|
||||
const data = await tryBrowserCommand(['bilibili', 'search', 'typescript', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'bilibili search');
|
||||
}, 60_000);
|
||||
|
||||
// ── weibo (browser: true, cookie strategy) ──
|
||||
it('weibo hot returns trending topics', async () => {
|
||||
const data = await tryBrowserCommand(['weibo', 'hot', '--limit', '5', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'weibo hot');
|
||||
}, 60_000);
|
||||
|
||||
// ── zhihu (browser: true, cookie strategy) ──
|
||||
it('zhihu hot returns trending questions', async () => {
|
||||
const data = await tryBrowserCommand(['zhihu', 'hot', '--limit', '5', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'zhihu hot');
|
||||
if (data) {
|
||||
expect(data[0]).toHaveProperty('title');
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('zhihu search returns results', async () => {
|
||||
const data = await tryBrowserCommand(['zhihu', 'search', 'playwright', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'zhihu search');
|
||||
}, 60_000);
|
||||
|
||||
// ── reddit (browser: true, cookie strategy) ──
|
||||
it('reddit hot returns posts', async () => {
|
||||
const data = await tryBrowserCommand(['reddit', 'hot', '--limit', '5', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'reddit hot');
|
||||
}, 60_000);
|
||||
|
||||
it('reddit frontpage returns posts', async () => {
|
||||
const data = await tryBrowserCommand(['reddit', 'frontpage', '--limit', '5', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'reddit frontpage');
|
||||
}, 60_000);
|
||||
|
||||
// ── twitter (browser: true) ──
|
||||
it('twitter trending returns trends', async () => {
|
||||
const data = await tryBrowserCommand(['twitter', 'trending', '--limit', '5', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'twitter trending');
|
||||
}, 60_000);
|
||||
|
||||
// ── xueqiu (browser: true, cookie strategy) ──
|
||||
it('xueqiu hot returns hot posts', async () => {
|
||||
const data = await tryBrowserCommand(['xueqiu', 'hot', '--limit', '5', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'xueqiu hot');
|
||||
}, 60_000);
|
||||
|
||||
it('xueqiu hot-stock returns stocks', async () => {
|
||||
const data = await tryBrowserCommand(['xueqiu', 'hot-stock', '--limit', '5', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'xueqiu hot-stock');
|
||||
}, 60_000);
|
||||
|
||||
// ── reuters (browser: true) ──
|
||||
it('reuters search returns articles', async () => {
|
||||
const data = await tryBrowserCommand(['reuters', 'search', 'technology', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'reuters search');
|
||||
}, 60_000);
|
||||
|
||||
// ── youtube (browser: true) ──
|
||||
it('youtube search returns videos', async () => {
|
||||
const data = await tryBrowserCommand(['youtube', 'search', 'typescript tutorial', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'youtube search');
|
||||
}, 60_000);
|
||||
|
||||
// ── smzdm (browser: true) ──
|
||||
it('smzdm search returns deals', async () => {
|
||||
const data = await tryBrowserCommand(['smzdm', 'search', '键盘', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'smzdm search');
|
||||
}, 60_000);
|
||||
|
||||
// ── boss (browser: true) ──
|
||||
it('boss search returns jobs', async () => {
|
||||
const data = await tryBrowserCommand(['boss', 'search', 'golang', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'boss search');
|
||||
}, 60_000);
|
||||
|
||||
// ── ctrip (browser: true) ──
|
||||
it('ctrip search returns flights', async () => {
|
||||
const data = await tryBrowserCommand(['ctrip', 'search', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'ctrip search');
|
||||
}, 60_000);
|
||||
|
||||
// ── coupang (browser: true) ──
|
||||
it('coupang search returns products', async () => {
|
||||
const data = await tryBrowserCommand(['coupang', 'search', 'laptop', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'coupang search');
|
||||
}, 60_000);
|
||||
|
||||
// ── xiaohongshu (browser: true) ──
|
||||
it('xiaohongshu search returns notes', async () => {
|
||||
const data = await tryBrowserCommand(['xiaohongshu', 'search', '美食', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'xiaohongshu search');
|
||||
}, 60_000);
|
||||
|
||||
// ── yahoo-finance (browser: true) ──
|
||||
it('yahoo-finance quote returns stock data', async () => {
|
||||
const data = await tryBrowserCommand(['yahoo-finance', 'quote', '--symbol', 'AAPL', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'yahoo-finance quote');
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Shared helpers for E2E tests.
|
||||
* Runs the built opencli binary as a subprocess.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const MAIN = path.join(ROOT, 'dist', 'main.js');
|
||||
|
||||
export interface CliResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `opencli` as a child process with the given arguments.
|
||||
* Without PLAYWRIGHT_MCP_EXTENSION_TOKEN, opencli auto-launches its own browser.
|
||||
*/
|
||||
export async function runCli(
|
||||
args: string[],
|
||||
opts: { timeout?: number; env?: Record<string, string> } = {},
|
||||
): Promise<CliResult> {
|
||||
const timeout = opts.timeout ?? 30_000;
|
||||
try {
|
||||
const { stdout, stderr } = await exec('node', [MAIN, ...args], {
|
||||
cwd: ROOT,
|
||||
timeout,
|
||||
env: {
|
||||
...process.env,
|
||||
// Prevent chalk colors from polluting test assertions
|
||||
FORCE_COLOR: '0',
|
||||
NO_COLOR: '1',
|
||||
...opts.env,
|
||||
},
|
||||
});
|
||||
return { stdout, stderr, code: 0 };
|
||||
} catch (err: any) {
|
||||
return {
|
||||
stdout: err.stdout ?? '',
|
||||
stderr: err.stderr ?? '',
|
||||
code: err.code ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON output from a CLI command.
|
||||
* Throws a descriptive error if parsing fails.
|
||||
*/
|
||||
export function parseJsonOutput(stdout: string): any {
|
||||
try {
|
||||
return JSON.parse(stdout.trim());
|
||||
} catch {
|
||||
throw new Error(`Failed to parse CLI JSON output:\n${stdout.slice(0, 500)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* E2E tests for management/built-in commands.
|
||||
* These commands require no external network access (except verify --smoke).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runCli, parseJsonOutput } from './helpers.js';
|
||||
|
||||
describe('management commands E2E', () => {
|
||||
|
||||
// ── list ──
|
||||
it('list shows all registered commands', async () => {
|
||||
const { stdout, code } = await runCli(['list', '-f', 'json']);
|
||||
expect(code).toBe(0);
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
// Should have 50+ commands across 18 sites
|
||||
expect(data.length).toBeGreaterThan(50);
|
||||
// Each entry should have the standard fields
|
||||
expect(data[0]).toHaveProperty('command');
|
||||
expect(data[0]).toHaveProperty('site');
|
||||
expect(data[0]).toHaveProperty('name');
|
||||
expect(data[0]).toHaveProperty('strategy');
|
||||
expect(data[0]).toHaveProperty('browser');
|
||||
});
|
||||
|
||||
it('list default table format renders sites', async () => {
|
||||
const { stdout, code } = await runCli(['list']);
|
||||
expect(code).toBe(0);
|
||||
// Should contain site names
|
||||
expect(stdout).toContain('hackernews');
|
||||
expect(stdout).toContain('bilibili');
|
||||
expect(stdout).toContain('twitter');
|
||||
expect(stdout).toContain('commands across');
|
||||
});
|
||||
|
||||
it('list -f yaml produces valid yaml', async () => {
|
||||
const { stdout, code } = await runCli(['list', '-f', 'yaml']);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('command:');
|
||||
expect(stdout).toContain('site:');
|
||||
});
|
||||
|
||||
it('list -f csv produces valid csv', async () => {
|
||||
const { stdout, code } = await runCli(['list', '-f', 'csv']);
|
||||
expect(code).toBe(0);
|
||||
const lines = stdout.trim().split('\n');
|
||||
expect(lines.length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('list -f md produces markdown table', async () => {
|
||||
const { stdout, code } = await runCli(['list', '-f', 'md']);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('|');
|
||||
expect(stdout).toContain('command');
|
||||
});
|
||||
|
||||
// ── validate ──
|
||||
it('validate passes for all built-in adapters', async () => {
|
||||
const { stdout, code } = await runCli(['validate']);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('PASS');
|
||||
expect(stdout).not.toContain('❌');
|
||||
});
|
||||
|
||||
it('validate works for specific site', async () => {
|
||||
const { stdout, code } = await runCli(['validate', 'hackernews']);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('PASS');
|
||||
});
|
||||
|
||||
it('validate works for specific command', async () => {
|
||||
const { stdout, code } = await runCli(['validate', 'hackernews/top']);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('PASS');
|
||||
});
|
||||
|
||||
// ── verify ──
|
||||
it('verify runs validation without smoke tests', async () => {
|
||||
const { stdout, code } = await runCli(['verify']);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('PASS');
|
||||
});
|
||||
|
||||
// ── version ──
|
||||
it('--version shows version number', async () => {
|
||||
const { stdout, code } = await runCli(['--version']);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
// ── help ──
|
||||
it('--help shows usage', async () => {
|
||||
const { stdout, code } = await runCli(['--help']);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('opencli');
|
||||
expect(stdout).toContain('list');
|
||||
expect(stdout).toContain('validate');
|
||||
});
|
||||
|
||||
// ── unknown command ──
|
||||
it('unknown command shows error', async () => {
|
||||
const { stderr, code } = await runCli(['nonexistent-command-xyz']);
|
||||
expect(code).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* E2E tests for output format rendering.
|
||||
* Uses hackernews (public, fast) as a stable data source.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runCli, parseJsonOutput } from './helpers.js';
|
||||
|
||||
const FORMATS = ['json', 'yaml', 'csv', 'md'] as const;
|
||||
|
||||
describe('output formats E2E', () => {
|
||||
for (const fmt of FORMATS) {
|
||||
it(`hackernews top -f ${fmt} produces valid output`, async () => {
|
||||
const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '2', '-f', fmt]);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout.trim().length).toBeGreaterThan(0);
|
||||
|
||||
if (fmt === 'json') {
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBe(2);
|
||||
}
|
||||
|
||||
if (fmt === 'yaml') {
|
||||
expect(stdout).toContain('title:');
|
||||
}
|
||||
|
||||
if (fmt === 'csv') {
|
||||
// CSV should have a header row + data rows
|
||||
const lines = stdout.trim().split('\n');
|
||||
expect(lines.length).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
|
||||
if (fmt === 'md') {
|
||||
// Markdown table should have pipe characters
|
||||
expect(stdout).toContain('|');
|
||||
}
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
it('list -f csv produces valid csv', async () => {
|
||||
const { stdout, code } = await runCli(['list', '-f', 'csv']);
|
||||
expect(code).toBe(0);
|
||||
const lines = stdout.trim().split('\n');
|
||||
// Header + many data lines
|
||||
expect(lines.length).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* E2E tests for public API commands (browser: false).
|
||||
* These commands use Node.js fetch directly — no browser needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runCli, parseJsonOutput } from './helpers.js';
|
||||
|
||||
describe('public commands E2E', () => {
|
||||
// ── hackernews ──
|
||||
it('hackernews top returns structured data', async () => {
|
||||
const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '3', '-f', 'json']);
|
||||
expect(code).toBe(0);
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBe(3);
|
||||
expect(data[0]).toHaveProperty('title');
|
||||
expect(data[0]).toHaveProperty('score');
|
||||
expect(data[0]).toHaveProperty('rank');
|
||||
}, 30_000);
|
||||
|
||||
it('hackernews top respects --limit', async () => {
|
||||
const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '1', '-f', 'json']);
|
||||
expect(code).toBe(0);
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(data.length).toBe(1);
|
||||
}, 30_000);
|
||||
|
||||
// ── v2ex (public API, browser: false) ──
|
||||
it('v2ex hot returns topics', async () => {
|
||||
const { stdout, code } = await runCli(['v2ex', 'hot', '--limit', '3', '-f', 'json']);
|
||||
expect(code).toBe(0);
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(data[0]).toHaveProperty('title');
|
||||
}, 30_000);
|
||||
|
||||
it('v2ex latest returns topics', async () => {
|
||||
const { stdout, code } = await runCli(['v2ex', 'latest', '--limit', '3', '-f', 'json']);
|
||||
expect(code).toBe(0);
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
}, 30_000);
|
||||
|
||||
it('v2ex topic returns topic detail', async () => {
|
||||
// Topic 1000001 is a well-known V2EX topic
|
||||
const { stdout, code } = await runCli(['v2ex', 'topic', '--id', '1000001', '-f', 'json']);
|
||||
// May fail if V2EX rate-limits, but should return structured data
|
||||
if (code === 0) {
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(data).toBeDefined();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Smoke tests for external API health.
|
||||
* Only run on schedule or manual dispatch — NOT on every push/PR.
|
||||
* These verify that external APIs haven't changed their structure.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runCli, parseJsonOutput } from '../e2e/helpers.js';
|
||||
|
||||
describe('API health smoke tests', () => {
|
||||
|
||||
// ── Public API commands (should always work) ──
|
||||
it('hackernews API is responsive and returns expected structure', async () => {
|
||||
const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '5', '-f', 'json']);
|
||||
expect(code).toBe(0);
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(data.length).toBe(5);
|
||||
for (const item of data) {
|
||||
expect(item).toHaveProperty('title');
|
||||
expect(item).toHaveProperty('score');
|
||||
expect(item).toHaveProperty('author');
|
||||
expect(item).toHaveProperty('rank');
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('v2ex hot API is responsive', async () => {
|
||||
const { stdout, code } = await runCli(['v2ex', 'hot', '--limit', '3', '-f', 'json']);
|
||||
expect(code).toBe(0);
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(data[0]).toHaveProperty('title');
|
||||
}, 30_000);
|
||||
|
||||
it('v2ex latest API is responsive', async () => {
|
||||
const { stdout, code } = await runCli(['v2ex', 'latest', '--limit', '3', '-f', 'json']);
|
||||
expect(code).toBe(0);
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
}, 30_000);
|
||||
|
||||
it('v2ex topic API is responsive', async () => {
|
||||
const { stdout, code } = await runCli(['v2ex', 'topic', '--id', '1000001', '-f', 'json']);
|
||||
if (code === 0) {
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(data).toBeDefined();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// ── Validate all adapters ──
|
||||
it('all adapter definitions are valid', async () => {
|
||||
const { stdout, code } = await runCli(['validate']);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('PASS');
|
||||
});
|
||||
|
||||
// ── Command registry integrity ──
|
||||
it('all expected sites are registered', async () => {
|
||||
const { stdout, code } = await runCli(['list', '-f', 'json']);
|
||||
expect(code).toBe(0);
|
||||
const data = parseJsonOutput(stdout);
|
||||
const sites = new Set(data.map((d: any) => d.site));
|
||||
// Verify all 17 sites are present
|
||||
for (const expected of [
|
||||
'hackernews', 'bbc', 'bilibili', 'v2ex', 'weibo', 'zhihu',
|
||||
'twitter', 'reddit', 'xueqiu', 'reuters', 'youtube',
|
||||
'smzdm', 'boss', 'ctrip', 'coupang', 'xiaohongshu',
|
||||
'yahoo-finance',
|
||||
]) {
|
||||
expect(sites.has(expected)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user