20 KiB
测试手册
catbus 的测试分三层:
| 层 | 命令 | 联网 | 在哪里跑 | 目的 |
|---|---|---|---|---|
| 离线:单元与对拍 | npm test |
禁止 | 本机、CI | 逻辑正确;请求构造和签名与上游逐字节一致 |
| 在线 | npm run test:e2e |
是,用本机登录态 | 只在开发者本机 | 真实平台上只读命令能跑通,归一化字段结构正确 |
| 冒烟 | node scripts/smoke.mjs |
否 | CI 的全部目标系统 | 打好的包在每个系统上都能安装、加载原生依赖、运行 |
规范依据是 AGENTS 7.5「移植与对拍测试」。怎么新增命令、怎么生成对拍数据,见 CONTRIBUTING.md。
1. 准备
npm ci
npm run assets:jd # 京东验证码模型:jd.test.ts 的 JCAP 求解器要用
npm run assets:ocr # ddddocr 模型:bilibili-geetest.test.ts 的极验点选识别要用
模型文件不进 git,取回一次之后就一直在 packages/assets-*/models/ 里。
2. 离线测试:单元与对拍
2.1 运行
npm test # 全部离线测试
npm run typecheck # 类型检查:vitest 不做类型检查,要单独跑
npx vitest run tests/bilibili.test.ts # 只跑一个文件
npx vitest run tests/xhs.test.ts -t 对拍 # 只跑名字匹配的用例
npx vitest # watch 模式
测试直接跑 src/ 下的 TypeScript,不需要先 npm run build。
配置在 vitest.config.ts:
- 只收
tests/**/*.test.ts; - 预加载 tests/setup.ts;
- 单个用例超时 60 秒:对拍用例要在 vm 里跑上游的签名 JS,慢的 CI 机器上会超过默认的 5 秒;
- 时区固定为
Asia/Shanghai:对拍数据在北京时间下生成,签名里常有按本地时间格式化的时间戳(例如京东 h5st)。
2.2 禁止联网
tests/setup.ts 把 core/http 的发送器换成一个直接抛错的函数。离线测试里只要有一条请求漏到真实网络,就会报:
测试里不允许联网:GET https://...
需要请求的测试有两种办法:用 replay() 按对拍数据回放(2.4),或者用 mockSender() 临时换一个按 URL 返回假响应的发送器(2.6)。
2.3 目录与文件
| 路径 | 内容 |
|---|---|
tests/cli.test.ts |
全局命令、四层帮助、命令判定(NOT_IMPLEMENTED / UNSUPPORTED / USAGE)、core 实现的 auth 命令 |
tests/core.test.ts |
errors、日志打码、选项、hints、doctor、原子写入、auth store、config、http、vm、登录轮询、cookie 罐、jsonLoads |
tests/registry.test.ts |
注册表约束、handler 名与命令对应、docs/capabilities.md 是否最新、package.json 是否精确钉住模型包版本 |
tests/runtime.test.ts |
身份与游客态、危险操作确认、分页、长连接、输出格式、发布选项 |
tests/golden-core.test.ts |
core 的对拍:Python 编码函数、curl_cffi 的 URL 拼接、确定性随机数与时钟 |
tests/<p>.test.ts |
每个平台的对拍测试与命令测试 |
tests/bilibili-geetest.test.ts |
B 站极验:w 参数、验证链路、点选识别(ddddocr)、人工兜底页面 |
tests/golden/<p>/*.json |
对拍数据,由 scripts/golden/<p>/gen.py 生成,不手改 |
tests/golden.ts |
对拍工具:loadCase、replay、expectRequests、makeCtx |
tests/helpers.ts |
useTempHome():每个测试一个临时 CATBUS_HOME;cli(...argv):在进程内跑一次 catbus,捕获 stdout / stderr 并解析信封 |
各平台的对拍数据文件数(ls tests/golden/<p> | wc -l,统计于 2026-09-29):
| 平台 | 对拍数据 | 生成脚本 |
|---|---|---|
| core | 3 | scripts/golden/core/gen.py |
| xhs | 56 | scripts/golden/xhs/gen.py |
| douyin | 82 | scripts/golden/douyin/gen.py(末尾再执行 gen_more.py、gen_gap.py) |
| tiktok | 100 | scripts/golden/tiktok/gen.py |
| bilibili | 85 | scripts/golden/bilibili/gen.py |
| kuaishou | 79 | scripts/golden/kuaishou/gen.py |
| 19 | scripts/golden/weibo/gen.py |
|
| xianyu | 20 | scripts/golden/xianyu/gen.py |
| taobao | 12 | scripts/golden/taobao/gen.py |
| jd | 46 | scripts/golden/jd/gen.py |
| x | 60 | scripts/golden/x/gen.py |
| 合计 | 562 |
大多数文件是一条请求序列;也有一些记录的是纯计算的中间结果(例如验证码图像处理、指纹块),由对应的测试逐项比较。
2.4 对拍测试怎么工作
一份对拍数据(tests/golden/<p>/<case>.json)记录了上游 Python 代码在固定条件下的一次运行:
{
"input": { "uid": "1669879400" },
"seed": 20260927,
"now": 1790000000123,
"requests": [{ "method": "GET", "url": "...", "headers": [["accept", "..."]], "cookies": [], "body": null, "multipart": null }],
"responses": [{ "status": 200, "headers": {}, "body": {} }],
"result": {},
"error": null
}
TS 侧的测试:
const c = loadCase('bilibili', 'user_info')
const { requests, result, error } = await replay(c, () => api.userInfo(b, '2'))
if (error) throw error
expectRequests(requests, c.requests)
replay(c, run)用用例的seed和now打开确定性模式(core/rand.ts的deterministic()),把发送器换成按顺序返回c.responses的回放器,然后运行run,返回 TS 实际发出的请求。二进制响应在用例里记成{"base64": ...},回放时按原字节返回。expectRequests(actual, expected)先比较「方法 + URL」的序列,再逐个比较整个请求:header(含顺序)、cookie、body、multipart。makeCtx({ platform, cookies, args, options, ... })造一个测试用的HandlerContext,可以直接调用commands.ts里的 handler,对拍整条命令流程(例如 B 站的游客设备初始化)。
生成对拍数据的框架是 scripts/golden/catbus_golden.py:替换 Python 的 random / secrets / uuid / os.urandom / time(推导公式与 src/core/rand.ts 一一对应),截获 curl_cffi 与 requests 的请求(不联网,按用例给的响应回复),并通过 NODE_OPTIONS 给上游起的 node 子进程预加载 node_determinism.cjs。各平台还有自己的补丁,写在各自 gen.py 开头的说明里。环境搭建和运行方式见 CONTRIBUTING.md 第 5 节。
scripts/golden/core/gen.py 生成的是 core 自己的对拍数据(随机数序列、编码函数、URL 拼接)。它要用 references/BilibiliApis 和 curl_cffi,用任意一个装了 curl_cffi 的平台虚拟环境运行即可,例如 .golden/bilibili。
对拍数据只能用假凭证。
2.5 加一个对拍用例
- 在
scripts/golden/<p>/gen.py里加用例:case('<名字>', <调用上游的函数>, **input),需要的话在respond(req)里按 URL 给出假响应。 - 运行
.golden/<p>/bin/python scripts/golden/<p>/gen.py(Windows 为.golden\<p>\Scripts\python),生成tests/golden/<p>/<名字>.json。 - 在
tests/<p>.test.ts的用例表里加上同名的 TS 调用。多数平台有一张CASES表(用例名 → TS 侧的等价调用,值的具体形状各平台略有不同,照同文件里已有的写),循环里对每个用例做replay+expectRequests。 npx vitest run tests/<p>.test.ts。- 用
git diff tests/golden/<p>确认只有预期的用例变了。
2.6 命令测试(非对拍)
上游没有对应方法、catbus 自己补的逻辑(参数解析、归一化、分页、错误映射、上游没有的请求),用命令测试覆盖:mockSender 按 URL 回假响应,deterministic() 固定时钟,直接调用 handler。例子见 tests/bilibili.test.ts 的 runCommand:
const restoreRand = deterministic({ now: NOW })
const restore = mockSender((p) => fakeResponse(route(p.url, p.body), { headers: [['content-type', 'application/json']], url: p.url }))
try {
const result = await commands.itemRelated(ctx)
// 断言 result 与发出的请求
} finally {
restore()
restoreRand()
}
测 CLI 层的行为(退出码、信封、帮助、确认)用 tests/helpers.ts 的 useTempHome() 和 cli():
useTempHome()
it('planned 端报 NOT_IMPLEMENTED', async () => {
const r = await cli('xianyu', 'item', 'get', 'x', '-e', 'app')
expect(r.code).toBe(4)
expect(r.env.error.code).toBe('NOT_IMPLEMENTED')
})
2.7 常见失败
| 现象 | 原因与处理 |
|---|---|
测试里不允许联网:<方法> <URL> |
有请求没被回放或 mock。检查测试有没有包在 replay() / mockSender() 里,以及 handler 有没有在 replay 返回之后还在发请求(漏了 await) |
TS 实现多发了请求:<方法> <URL> |
TS 比上游多发了请求。对照上游看是多了一步初始化、重试,还是本该复用的缓存(签名密钥、token)没复用 |
expectRequests 第一条断言失败(URL 序列不同) |
请求的顺序或数量与上游不同。先让序列一致,再看单个请求的差异 |
第 N 个请求:... 的 diff |
header 顺序、cookie、body 或签名不一致。header 要按上游顺序显式给出;编码用 core/py.ts;需要键序的 JSON 用 Map |
| 从某个请求起签名、随机串全都不对 | 随机数的消耗顺序和上游错位了:只能经 core/rand.ts 取随机数和时间,调用次数、顺序要和上游一致 |
| 只有时间相关的字段不对 | 时区不是北京时间。测试要经 vitest.config.ts 运行;生成对拍数据时框架会自己设 TZ |
ENOENT ... packages/assets-jd/models/...(或 assets-ocr) |
没取回模型:npm run assets:jd、npm run assets:ocr |
docs/capabilities.md 与注册表一致 失败 |
改了注册表没有重新生成:npm run gen:capabilities |
handler 名是 <resource><Action> 失败 |
注册表里的 handler 名接错了,例如 draft delete 必须对应 draftDelete |
| 用例超时 | 签名 JS 在 vm 里跑得慢。先确认不是死循环(例如翻页游标没变化);机器确实慢时只对那个用例单独放宽超时 |
| 只在某台机器上失败的指纹字段 | 依赖本机字体渲染、CPU 架构或 V8 版本的值(京东 WebM 的 canvas / webgl 哈希、architecture、Math 指纹的末位)不能逐字节比较。现有做法是把这类字段排除在比较之外,或者按有效数字比较,照 tests/jd.test.ts 处理 |
| 重新生成后大量无关用例变了 | references/<repo> 不在 UPSTREAM 记录的 commit 上,或者虚拟环境里的依赖版本不对 |
3. 在线测试
在线测试用真实账号请求真实平台,不在 npm test 和 CI 里跑,只在开发者本机手动运行。
3.1 运行
catbus bilibili auth login # 先登录要测的平台(各平台的登录方式见 docs/capabilities.md)
npm run test:e2e # 所有平台;没登录的平台整组跳过
npm run test:e2e -- -t bilibili # 只跑一个平台
npm run test:e2e -- -t '^x ' # X 的平台 id 太短,-t x 会连 xhs、xianyu 一起匹配,要锚定开头
-t按「平台 id + 命令」的完整用例名做匹配(例如bilibili item get),所以也可以只跑一条命令:-t 'bilibili item get'。不过后面的命令要用前面命令的结果当参数,单独跑时多半会因为缺参数而跳过。- 登录态来自
~/.catbus。想用单独的测试账号,把它们登录到另一个目录,再用CATBUS_HOME指过去:CATBUS_HOME=~/catbus-test npm run test:e2e。 - 配置在 tests/e2e/vitest.config.ts:只收
tests/e2e/**/*.e2e.ts,不加载tests/setup.ts(请求会真的发出去),单个用例超时 180 秒。
3.2 跑什么、怎么判定
入口是 tests/e2e/live.e2e.ts,完全由注册表驱动:
- 对每个平台,从注册表里取出 web 端已实现(
implemented)、非长连接、action 属于只读集合(get、search、list、items、replies、media、categories……)的命令。写操作、item download、listen都不跑;auth login/use/logout也不跑。 - 按四个阶段执行:
auth status→ 不需要对象参数的命令(搜索、列表、推荐流、热词……)→ 需要 item / 用户 / 直播间 / 会话 / 收藏夹的命令 → 需要评论的命令。前面命令返回的对象收进一个池子,它们的url或id当作后面命令的参数。 - 搜索词默认是「猫」;tiktok、x 用
cat,xianyu、taobao、jd 用「键盘」。 - 命令之间间隔 1.5 秒,小红书 4 秒(1.5 秒间隔下评论接口触发过 461 人机验证)。
每条命令的判定:
| 情况 | 结果 |
|---|---|
auth status 返回 logged_in: false |
这个平台后面的命令全部跳过(「没有登录 」) |
| 前面的命令没拿到需要的对象 | 跳过(「前面的命令没有拿到 <参数名>」) |
AUTH_REQUIRED |
跳过:主站已经登录,这是子站点(--scope)没登录 |
RISK_CONTROL |
跳过:风控取决于账号和近期的请求,不是代码问题 |
NOT_IMPLEMENTED |
跳过:默认参数落在了注册表 note 写明「规划中」的取值上 |
UNSUPPORTED,且命令有 note |
跳过:参数不在 note 写明的支持范围内 |
| 其他错误 | 失败 |
| 成功 | 校验:ok、platform、account;data 符合 AGENTS 6.2 的结构(tests/e2e/shapes.ts,zod strict 对象,多一个字段、少一个字段都算错);分页命令的 page 带 has_more,其他命令的 page 为 null;item search、user search、feed list、keyword hot / suggest、live list / search 不能返回空列表(TikTok 的 live list 是「关注的人在播」,允许为空) |
3.3 报告
每个平台跑完写一份 .e2e/<p>.json(不进版本控制)。每条命令一项:
| 字段 | 内容 |
|---|---|
command、argv |
命令与实际用的参数 |
code、error |
退出码与错误 |
count |
列表的条数 |
empty_fields |
在所有元素里都是 null 或 [] 的字段,用来发现归一化漏取的字段 |
page |
分页信息 |
data |
返回的数据(列表只留前 3 条) |
empty_fields 里出现的字段,先对照 各平台说明 的「已知限制」:上游接口本来就不返回的字段属于已知情况,其余的多半是 normalize.ts 取错了键。
报告里是真实账号返回的数据(昵称、ID、私信会话等),不要提交,也不要整份贴到 issue 里。
3.4 加用例
在线测试没有手写的用例表:新实现的只读命令会自动加入。只有下面几种情况要改 tests/e2e/:
- 新的只读 action(例如扩展命令
item subtitles的subtitles):加进live.e2e.ts的READ_ACTIONS; - 新的位置参数名:在
argValue()里说明从池子里取什么; - 新的输出类型:在
shapes.ts的SHAPES里加校验器(否则报「没有 <类型> 的校验器」);想让它的结果给后面的命令用,在harvest()里收进池子; - 某条命令在真实平台上不该为空,或者允许为空:改
NON_EMPTY/MAY_BE_EMPTY。
3.5 注意与常见失败
- 在线测试会用你的账号发大量请求,可能触发风控。真机验证期间京东的测试账号被封过,建议用专门的测试账号,不要频繁连续跑。
- 某个平台的全部命令都跳过:先确认
catbus <p> auth status返回logged_in: true。 - 结构校验失败:错误信息里是 zod 的前 5 条 issue,常见的是时间不是带时区的 ISO 8601、计数是字符串、多了平台私有字段。对照
.e2e/<p>.json里的data修normalize.ts。 - 「真实平台上不该是空列表」:先手动跑一次同样的命令,看是搜索词不合适、账号本身没有数据,还是接口换了字段。
- 各平台目前的已知问题见 各平台说明 的「已知限制」。
4. CI 与冒烟测试
4.1 CI 做了什么
.github/workflows/ci.yml 在推送到 master、提交 PR 时触发;推送 v* tag 时,release.yml 也会先调用它跑完整 CI,再发布 CI 里打好、测过的同一份 tarball,并从 CHANGELOG 生成 GitHub Release(发版步骤见 AGENTS.md 7.2)。
| job | 环境 | 步骤 |
|---|---|---|
test |
ubuntu / windows / macos × Node 22 / 24 / 26 | npm ci → assets:jd → assets:ocr(模型按 key 缓存)→ typecheck → npm test |
pack |
ubuntu,Node 24 | npm pack 打出 catbus-cli、@cv-cat/catbus-assets-jd、@cv-cat/catbus-assets-ocr 三个 tarball(prepack 会先构建、先取回并校验模型),连同 scripts/smoke.mjs 上传为 artifact |
smoke |
8 个目标系统 × Node 22 / 24 | 全局安装三个 tarball,运行 node scripts/smoke.mjs |
CI 只跑离线测试,不跑在线测试。
smoke 覆盖 AGENTS 7.2 的全部目标系统:
| target | runner |
|---|---|
| win32-x64 | windows-latest |
| win32-arm64 | windows-11-arm |
| darwin-arm64 | macos-latest |
| darwin-x64 | macos-15-intel |
| linux-x64-gnu | ubuntu-latest |
| linux-arm64-gnu | ubuntu-24.04-arm |
| linux-x64-musl | ubuntu-latest 上的 node:<版本>-alpine 容器 |
| linux-arm64-musl | ubuntu-24.04-arm 上的 node:<版本>-alpine 容器 |
Alpine 容器里跑不了 JS action(arm64 尤其如此),所以 musl 两项直接用 docker run。
4.2 冒烟测试查什么
scripts/smoke.mjs 对已经全局安装的 catbus 运行下面几项,数据目录用一个临时的 CATBUS_HOME:
| 检查项 | 通过条件 |
|---|---|
version |
退出码 0,报告的 Node 版本就是当前的 Node |
doctor |
全部检查项通过:Node 版本、数据目录可写且权限正确、签名 vm 能执行、wreq-js / @napi-rs/canvas / onnxruntime-web 能加载、两个模型包就位 |
| 模型包 | doctor 的 assets-jd、assets-ocr 两项通过 |
platforms |
返回 10 个平台 |
--help |
输出里有 catbus <platform> <resource> <action> |
| planned 端 | catbus xianyu item get x -e app 报 NOT_IMPLEMENTED,退出码 4 |
auth list |
返回数组 |
4.3 在本机跑冒烟测试
npm pack
npm pack -w packages/assets-jd
npm pack -w packages/assets-ocr
npm install -g ./cv-cat-catbus-assets-jd-*.tgz ./cv-cat-catbus-assets-ocr-*.tgz ./catbus-cli-*.tgz
node scripts/smoke.mjs
全局安装会覆盖 npm link 出来的 catbus,测完需要的话重新 npm link。在 macOS / Windows 上测 musl:
docker run --rm -v "$PWD:/w" -w /w node:24-alpine \
sh -c 'npm install -g ./cv-cat-catbus-assets-jd-*.tgz ./cv-cat-catbus-assets-ocr-*.tgz ./catbus-cli-*.tgz && node scripts/smoke.mjs'
4.4 CI 常见失败
| 现象 | 原因与处理 |
|---|---|
pack 在 tsc 这一步失败,本地 typecheck 却是好的 |
typecheck 用 tsconfig.json(包括 tests/),build 用 tsconfig.build.json(只有 src/),两者看到的类型不完全一样。例如 TextDecoder 要从 node:util 引入,不能依赖 DOM 类型。推送前本地也跑一次 npm run build |
| 只在 Windows 上失败 | 路径分隔符、rename 被占用(writeFileAtomic 已经重试)、换行符(仓库按 .gitattributes 统一 LF,static/ 按字节保存) |
某个目标系统的 smoke 失败,doctor 里 http / canvas / onnx 不通过 |
这个原生依赖在该系统上没有预编译包。按 AGENTS 7.2,任何一个目标系统缺预编译包的依赖都不能用,换依赖或去掉 |
smoke 的 assets-jd / assets-ocr 不通过 |
模型没有打进 tarball。检查 pack job 里 prepack 取回模型的日志(sha256 校验失败会直接报错) |
| 模型下载失败 | assets:jd 从 GitHub 下载、assets:ocr 从 PyPI 下载,偶发网络错误时重跑 job;缓存命中后不再下载 |