diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30ce8791..3d069d3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,24 @@ jobs: run: npm ci - name: Run unit tests (Node ${{ matrix.node-version }}, shard ${{ matrix.shard }}/2) - run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2 + run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2 + + adapter-test: + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run focused adapter tests + run: npm run test:adapter -- --reporter=verbose # ── Smoke tests (scheduled / manual only) ── smoke-test: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e402729b..1cd64975 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,8 @@ npm run build # 4. Run a few checks npx tsc --noEmit -npx vitest run src/ +npm test +npm run test:adapter # 5. Link globally (optional, for testing `opencli` command) npm link @@ -161,7 +162,8 @@ args: [ See [TESTING.md](./TESTING.md) for the full guide and exact test locations. ```bash -npx vitest run src/ # Unit tests +npm test # Core unit tests (non-adapter) +npm run test:adapter # Focused adapter tests: zhihu/twitter/reddit/bilibili npx vitest run tests/e2e/ # E2E tests npx vitest run # All tests ``` @@ -194,7 +196,8 @@ Common scopes: site name (`twitter`, `reddit`) or module name (`browser`, `pipel 3. Run the checks that apply: ```bash npx tsc --noEmit # Type check - npx vitest run src/ # Unit tests + npm test # Core unit tests + npm run test:adapter # Focused adapter tests (if you touched adapter logic) opencli validate # YAML validation (if applicable) ``` 4. Commit using conventional commit format diff --git a/docs/developer/contributing.md b/docs/developer/contributing.md index 848345b5..456a01ca 100644 --- a/docs/developer/contributing.md +++ b/docs/developer/contributing.md @@ -17,7 +17,8 @@ npm run build # 4. Run a few checks npx tsc --noEmit -npx vitest run src/ +npm test +npm run test:adapter # 5. Link globally (optional, for testing `opencli` command) npm link @@ -129,7 +130,8 @@ chore: bump vitest to v4 3. Run the checks: ```bash npx tsc --noEmit # Type check - npx vitest run src/ # Unit tests + npm test # Core unit tests + npm run test:adapter # Focused adapter tests (if adapter logic changed) opencli validate # YAML validation (if applicable) ``` 4. Commit using conventional commit format diff --git a/docs/developer/testing.md b/docs/developer/testing.md index 9c98a8d6..730b6f30 100644 --- a/docs/developer/testing.md +++ b/docs/developer/testing.md @@ -30,12 +30,14 @@ tests/ ├── smoke/ │ └── api-health.test.ts # 外部 API、adapter 定义、命令注册健康检查 src/ -└── **/*.test.ts # 单元测试(当前 31 个文件) +├── **/*.test.ts # 核心单元测试(默认 `unit` project) +└── clis/{zhihu,twitter,reddit,bilibili}/**/*.test.ts # 聚焦 adapter tests ``` | 层 | 位置 | 当前文件数 | 运行方式 | 用途 | |---|---|---:|---|---| -| 单元测试 | `src/**/*.test.ts` | 31 | `npx vitest run src/` | 内部模块、pipeline、adapter 工具函数 | +| 单元测试 | `src/**/*.test.ts`(排除 `src/clis/**`) | - | `npm test` | 内部模块、pipeline、runtime | +| Adapter 测试 | `src/clis/{zhihu,twitter,reddit,bilibili}/**/*.test.ts` | - | `npm run test:adapter` | 保留 4 个重点站点的 adapter 覆盖 | | E2E 测试 | `tests/e2e/*.test.ts` | 5 | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 | | 烟雾测试 | `tests/smoke/*.test.ts` | 1 | `npx vitest run tests/smoke/` | 外部 API 与注册完整性 | @@ -43,13 +45,13 @@ src/ ## 当前覆盖范围 -### 单元测试(31 个文件) +### 单元测试与 Adapter 测试 | 领域 | 文件 | |---|---| | 核心运行时与输出 | `src/browser.test.ts`, `src/browser/dom-snapshot.test.ts`, `src/build-manifest.test.ts`, `src/capabilityRouting.test.ts`, `src/doctor.test.ts`, `src/engine.test.ts`, `src/interceptor.test.ts`, `src/output.test.ts`, `src/plugin.test.ts`, `src/registry.test.ts`, `src/snapshotFormatter.test.ts` | | pipeline 与下载 | `src/download/index.test.ts`, `src/pipeline/executor.test.ts`, `src/pipeline/template.test.ts`, `src/pipeline/transform.test.ts` | -| 站点 / adapter 逻辑 | `src/clis/apple-podcasts/commands.test.ts`, `src/clis/apple-podcasts/utils.test.ts`, `src/clis/bloomberg/utils.test.ts`, `src/clis/chaoxing/utils.test.ts`, `src/clis/coupang/utils.test.ts`, `src/clis/google/utils.test.ts`, `src/clis/grok/ask.test.ts`, `src/clis/twitter/timeline.test.ts`, `src/clis/weread/utils.test.ts`, `src/clis/xiaohongshu/creator-note-detail.test.ts`, `src/clis/xiaohongshu/creator-notes-summary.test.ts`, `src/clis/xiaohongshu/creator-notes.test.ts`, `src/clis/xiaohongshu/user-helpers.test.ts`, `src/clis/xiaoyuzhou/utils.test.ts`, `src/clis/youtube/transcript-group.test.ts`, `src/clis/zhihu/download.test.ts` | +| 聚焦 adapter 逻辑 | `src/clis/zhihu/download.test.ts`, `src/clis/twitter/timeline.test.ts`, `src/clis/reddit/read.test.ts`, `src/clis/bilibili/dynamic.test.ts` | 这些测试覆盖的重点包括: @@ -99,8 +101,11 @@ npm run build # 编译(E2E / smoke 测试需要 dist/main.js) ### 运行命令 ```bash -# 全部单元测试 -npx vitest run src/ +# 默认核心单元测试(不含大多数 adapter tests) +npm test + +# 聚焦 adapter tests(只保留 4 个重点站点) +npm run test:adapter # 全部 E2E 测试(会真实调用外部 API / 浏览器) npx vitest run tests/e2e/ @@ -192,7 +197,8 @@ it('producthunt me fails gracefully without login', async () => { | Job | 触发条件 | 内容 | |---|---|---| | `build` | push/PR 到 `main`,`dev` | `tsc --noEmit` + `npm run build` | -| `unit-test` | push/PR 到 `main`,`dev` | Node `20` 与 `22` 双版本运行 `src/` 单元测试,按 `2` shard 并行 | +| `unit-test` | push/PR 到 `main`,`dev` | Node `20` 与 `22` 双版本运行核心 `unit` tests,按 `2` shard 并行 | +| `adapter-test` | push/PR 到 `main`,`dev` | Node `22` 运行聚焦的 `zhihu/twitter/reddit/bilibili` adapter tests | | `smoke-test` | `schedule` 或 `workflow_dispatch` | 安装真实 Chrome,`xvfb-run` 执行 `tests/smoke/` | ### `e2e-headed.yml` @@ -214,7 +220,7 @@ strategy: node-version: ['20', '22'] shard: [1, 2] steps: - - run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2 + - run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2 ``` ::: diff --git a/package.json b/package.json index e74908bf..9d201a24 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "lint": "tsc --noEmit", "prepublishOnly": "npm run build", "test": "vitest run --project unit", + "test:adapter": "vitest run --project adapter", "test:all": "vitest run", "test:e2e": "vitest run --project e2e", "docs:dev": "vitepress dev docs", diff --git a/src/clis/bilibili/dynamic.test.ts b/src/clis/bilibili/dynamic.test.ts new file mode 100644 index 00000000..c935dbe0 --- /dev/null +++ b/src/clis/bilibili/dynamic.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockApiGet } = vi.hoisted(() => ({ + mockApiGet: vi.fn(), +})); + +vi.mock('./utils.js', () => ({ + apiGet: mockApiGet, +})); + +import { getRegistry } from '../../registry.js'; +import './dynamic.js'; + +describe('bilibili dynamic adapter', () => { + const command = getRegistry().get('bilibili/dynamic'); + + beforeEach(() => { + mockApiGet.mockReset(); + }); + + it('maps desc text rows from the dynamic feed payload', async () => { + mockApiGet.mockResolvedValue({ + data: { + items: [ + { + id_str: '123', + modules: { + module_author: { name: 'Alice' }, + module_dynamic: { desc: { text: 'hello world' } }, + module_stat: { like: { count: 9 } }, + }, + }, + ], + }, + }); + + const result = await command!.func!({} as any, { limit: 5 }); + + expect(mockApiGet).toHaveBeenCalledWith({}, '/x/polymer/web-dynamic/v1/feed/all', { params: {}, signed: false }); + expect(result).toEqual([ + { + id: '123', + author: 'Alice', + text: 'hello world', + likes: 9, + url: 'https://t.bilibili.com/123', + }, + ]); + }); + + it('falls back to archive title when desc text is absent', async () => { + mockApiGet.mockResolvedValue({ + data: { + items: [ + { + id_str: '456', + modules: { + module_author: { name: 'Bob' }, + module_dynamic: { major: { archive: { title: 'Video title' } } }, + module_stat: { like: { count: 3 } }, + }, + }, + ], + }, + }); + + const result = await command!.func!({} as any, { limit: 5 }); + + expect(result).toEqual([ + { + id: '456', + author: 'Bob', + text: 'Video title', + likes: 3, + url: 'https://t.bilibili.com/456', + }, + ]); + }); +}); diff --git a/src/clis/reddit/read.test.ts b/src/clis/reddit/read.test.ts new file mode 100644 index 00000000..019ad016 --- /dev/null +++ b/src/clis/reddit/read.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '../../registry.js'; +import './read.js'; + +describe('reddit read adapter', () => { + const command = getRegistry().get('reddit/read'); + + it('returns threaded rows from the browser-evaluated payload', async () => { + const page = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue([ + { type: 'POST', author: 'alice', score: 10, text: 'Title' }, + { type: 'L0', author: 'bob', score: 5, text: 'Comment' }, + ]), + } as any; + + const result = await command!.func!(page, { 'post-id': 'abc123', limit: 5 }); + + expect(page.goto).toHaveBeenCalledWith('https://www.reddit.com'); + expect(result).toEqual([ + { type: 'POST', author: 'alice', score: 10, text: 'Title' }, + { type: 'L0', author: 'bob', score: 5, text: 'Comment' }, + ]); + }); + + it('surfaces adapter-level API errors clearly', async () => { + const page = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue({ error: 'Reddit API returned HTTP 403' }), + } as any; + + await expect(command!.func!(page, { 'post-id': 'abc123' })).rejects.toThrow('Reddit API returned HTTP 403'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8d3e1e7d..fb2d57fa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,19 +7,34 @@ export default defineConfig({ test: { name: 'unit', include: ['src/**/*.test.ts'], + exclude: ['src/clis/**/*.test.ts'], // Run unit tests before e2e tests to avoid project-level contention in CI. sequence: { groupOrder: 0, }, }, }, + { + test: { + name: 'adapter', + include: [ + 'src/clis/zhihu/**/*.test.ts', + 'src/clis/twitter/**/*.test.ts', + 'src/clis/reddit/**/*.test.ts', + 'src/clis/bilibili/**/*.test.ts', + ], + sequence: { + groupOrder: 1, + }, + }, + }, { test: { name: 'e2e', include: ['tests/**/*.test.ts'], maxWorkers: 2, sequence: { - groupOrder: 1, + groupOrder: 2, }, }, },