career-ops 测试体系完全指南:零框架 test-all 套件与 tests/ 自动发现机制剖析
导读:career-ops 是一个在 AI 编码 CLI(Claude Code、Codex、OpenCode 等)中本地运行的 AI 求职工具链,其测试体系同样"零依赖"——不引入任何测试框架,仅靠 Node.js 原生能力支撑起对数百个
.mjs脚本、Job 扫描 Provider、Go 仪表盘的数据契约与行为回归检查。本文以 tests/README.md 为主线,结合 test-all.mjs、tests/helpers.mjs 与 tests/providers 下数百个测试文件的真实实现,讲清这套套件的运行入口、发现机制、双风格取舍与编写规范,帮助你快速上手本地验证、新增用例并理解其"绝不让绿 CI 掩盖错误"的守卫设计。
一、设计原则:为什么一个测试套件可以没有测试框架
1.1 "裸克隆 + 纯 Node"的硬约束
test-all.mjs 是仓库根目录下的套件运行器(repo root 的 suite runner)。它的约束不是口味选择,而是一条硬性工程要求:套件必须能在刚 clone 下来的全新副本上,仅凭 Node.js 运行(见 tests/README.md 的 Purpose 一节)。
这意味着三件事:
- 不使用 Jest / Mocha / Vitest 等任何需要
npm install第三方依赖的框架; - 连 Node 自带的
node:test都不使用(README 中注明为 issue #1440 的决策),因为测试作者不想让套件强依赖某个最低 Node 版本中的测试运行器行为; - 断言与统计全部由仓库自带的 tests/helpers.mjs 提供。
运行器(test-all.mjs)的执行策略分为两层:
- 内联核心检查(inline core checks):语法(syntax)、脚本执行(scripts)、仪表盘构建(dashboard)、数据契约(data contract)、个人数据泄漏(personal data)、绝对路径(paths)等直接写在
test-all.mjs文件内的分节断言; - 自动发现的测试文件:递归扫描
tests/目录下所有*.test.mjs并逐一执行。
1.2 为什么新测试要"进文件、不进分节"
README 与 CONTRIBUTING.md 反复强调一个约定:新增测试放到 tests/ 下独立的 {name}.test.mjs 文件里,绝不要在 test-all.mjs 里新增编号分节。test-all.mjs 头部注释给出了一次真实的教训:2026 年 8 月有 6 位贡献者同时为内联分节取号,不约而同都选了 60a,导致每次 merge 都要强制其余 5 人 rebase——大约十五次 rebase 和六次串行 CI 只为六行测试代码。而新增一个文件与任何人都不冲突,PR 可以完全并行合入。
从源码看,分节编号在演进中也确实被淘汰了:test-all.mjs 顶部写明 "NEW TESTS GO IN A FILE OF THEIR OWN, NOT IN A SECTION HERE"(新测试放入自己的文件,而不是这里的某个分节)。
二、运行方式与命令行参数
2.1 三种典型用法
node test-all.mjs # 完整套件 —— push 前必跑
node test-all.mjs --quick # 完整套件,跳过 dashboard 构建(更快)
node test-all.mjs --only providers/themuse # 只运行路径中包含该子串的 tests/ 文件
三种用法的语义差异由 test-all.mjs 中的标志解析逻辑实现:
--only <substring>:从tests/相对路径中做子串过滤,例如--only providers/themuse只会命中tests/providers/themuse.test.mjs。--quick:跳过第 4 节"Dashboard build"(Go 编译器编译dashboard/的验证),见 test-all.mjs 的if (!QUICK)分支。- 无参数:完整套件。先跑全部内联核心分节,再执行
tests/下所有自动发现的测试。
2.2 反"假绿"设计
--only 有两个精心设计的失败语义(test-all.mjs):
- 无匹配即退出 1:如果过滤后
tests/下没有任何文件命中,立即打印❌ no test files matched --only "..."并以退出码 1 终止。这样路径拼写错误永远不可能把 CI 变绿。 - 缺值即报错:
--only提供了但后面没有值,会被判定为用法错误直接退出,而不会悄悄退化为"无过滤跑全量"——那会让请求子集的开发者误以为子集全绿(test-all.mjs)。
README 中的警告值得原文保留:--only 只是开发便利,不是 PR 门禁。它会跳过 test-all.mjs 中每一个内联核心分节,一次"绿色"的 --only 运行绝不等于套件通过——push 前永远要跑完整的 node test-all.mjs。
2.3 确定性发现机制
自动发现不依赖任何 glob 库或注册表,而是递归 readdirSync + 字典序排序(test-all.mjs):递归遍历 tests/,目录优先跳过嵌套检出(isNestedCheckout,防止把 git worktree 里的第二份仓库套件误当成当前树的测试),文件按 *.test.mjs 后缀收集,每层条目按名字字典序排序——保证每次运行、每个操作系统上的执行顺序完全一致(跨平台确定性,issue #1440)。根目录下的独立 *.test.mjs 文件(例如 set-status-tests.mjs、tracker-columns-tests.mjs)不属于该目录发现机制,而是被 test-all.mjs 第 2 节的脚本执行列表显式收录(见 tests/README.md 的 Layout 一节与 test-all.mjs 的 scripts 数组)。
三、目录布局与双套件边界
tests/README.md 的 Layout 一节把目录职责划得很清楚:
tests/
├── helpers.mjs # 共享断言辅助与计数器(pass/fail/warn…)
├── providers/ # 每个 scanner provider 一个测试文件
│ ├── themuse.test.mjs
│ ├── greenhouse.test.mjs
│ ├── ats-ssrf-hardening.test.mjs # 跨 Provider 的共享测试
│ ├── _html-entities.test.mjs # 下划线前缀 = 共享辅助模块的测试
│ └── …
└── *.test.mjs # 覆盖根目录脚本(stats、tracker、scan…)
关键区分如下:
providers/{name}.test.mjs:一个 Job 扫描 Provider(providers/下 100+ 个抓取器)对应一个测试文件;另有跨 Provider 的共享测试如ats-ssrf-hardening.test.mjs。具体写法模板见 providers/ADDING_A_PROVIDER.md 的 Tests 一节(其中明确:"One file:tests/providers/{name}.test.mjs"、"Auto-discovered (tests/**/*.test.mjs) — nothing to register intest-all.mjs")。- 下划线前缀文件(如
_html-entities.test.mjs、_html-to-text.test.mjs、_http.test.mjs、_profile-keywords.test.mjs):测试的是providers/下对应的共享辅助模块。 - 本层其他
*.test.mjs:覆盖根目录脚本(例如stats.test.mjs、merge-tracker.test.mjs、scan-*.test.mjs一族)。
3.1 核心与 Web 的刻意分治
README 用一个专门的段落声明:Web 测试不住在这里。web/(Next.js 前端)在自己的目录里跑 npm test,glob 发现 web/tests/**/*.test.mjs(详见 web/README.md 的 Tests 一节)。两个套件连风格都刻意不同:
| 维度 | 核心套件(本目录) | Web 套件(web/tests/) |
|---|---|---|
| 运行入口 | node test-all.mjs |
npm test |
| 断言风格 | 自研 pass/fail 辅助函数 |
Node 自带 node:test + node:assert/strict |
| 文件后缀 | .mjs |
.mjs(不使用 .ts,因为没有测试框架与 TS loader) |
| 覆盖对象 | 根目录脚本、Provider、数据契约 | web/src/ 下的模块,路径镜像 |
README 明确禁止把任一方风格带过界:核心套件坚持 "not even node:test"(#1440),因为必须在无框架裸克隆上运行;而 Web 套件则必须用 node:test。
唯一的例外是 web-test-layout.test.mjs:它守卫的恰恰是 Web 的目录布局规范,却刻意放在根目录套件里。原因(README 原文)是 web-ci.yml 的检查"只报告、不阻塞"(informative by design,永不拦 merge),而 test-all.mjs 作为 required check 在每一个 PR 上都会跑——把布局守卫放进必跑套件,才真正保证 web/src/ 之外不混入测试文件等规范被执行。这其实揭示了本仓库的一个通用哲学:真正要紧的约束要挂在"必跑的"那一环上。
四、helpers.mjs:零框架断言的引擎
tests/helpers.mjs 从 test-all.mjs 中"verbatim"迁移而来(issue #1440),是全套件唯一共享的断言底座。它导出的 API 可以分为四类。
4.1 断言、计数与收尾
export function pass(msg) { console.log(` ✅ ${msg}`); passed++; }
export function fail(msg) { console.log(` ❌ ${msg}`); failed++; }
export function warn(msg) { console.log(` ⚠️ ${msg}`); warnings++; }
export function results() { return { passed, failed, warnings }; }
export function finish() { /* 打印汇总并按计数器决定 process.exit 码 */ }
核心设计:
fail不中断执行,只递增计数器,让后续断言继续跑完,从而一次性暴露全部问题集;warn用于"本地环境合理缺失"的场景(如干净仓库里没有用户数据),保持可见但不红 CI;- 模块顶部导出常量
ROOT(仓库根目录)、QUICK(是否带--quick)、NODE(当前 Node 可执行文件路径); finish()输出📊 Results: N passed, M failed, K warnings后,按failed > 0→ 退出 1、有警告 → 退出 0 且黄灯提示、全绿 → 打印 "🟢 All tests passed — safe to push/merge"。
4.2 可执行文件的"白名单 run"
run(cmd, args, opts) 是子进程调用的唯一通道,安全设计极其严格:
- 只允许
node、bash、git、go、wsl这几个白名单字面量,外加 Windows 下按候选路径解析出的 Git Bash / cygpath(tests/helpers.mjs); - 永远走
execFileSync+ 参数数组,绝不经 shell 拼接字符串,因此参数永不被 shell 二次解析,也堵死了把任意可执行文件注入测试的路径; - 成功返回 trim 过的 stdout,失败返回
null并记录诊断,由lastRunFailure()与formatRunFailure()输出(后者对超长流"两头都保留",因为失败套件既可能把关键栈放在最前,也可能把汇总放在最后)。
4.3 跨平台与可靠性工具
rmSync:默认包装maxRetries: 10, retryDelay: 100的重试删除。Windows 上子进程刚退出时(杀毒软件会拉长窗口)文件句柄可能未释放,裸rmSync会以 EPERM 失败——Node 恰好会对 EPERM/EBUSY 这类错误做线性退避重试,所以全局默认它;getBash()/toBashPath():在 Windows 上按"Git for Windows 字面路径 → WSL → PATH → 未解析"的顺序惰性探测 bash(探测可能启动 WSL 虚拟机,故做进程级 memoize),路径转换优先 cygpath(/c/...)而非 wslpath(/mnt/c/...),避免与 Git Bash 挂载方案不匹配;walkFiles():字典序递归收集,遇到嵌套检出目录跳过;hermeticGitEnv()/makeUpdaterRepo():为 updater 系测试构造与宿主环境完全隔离的临时 git 仓库,用GIT_CONFIG_COUNT=0+ 空 excludes 文件等"pin"杜绝全局 gitignore、gpgsign、hooksPath 泄漏进测试。
4.4 时间边界与跨 UTC 日竞态
utcDay()、daysSpanned()、runAcrossUtcDay() 解决了一类很隐蔽的 flaky:测试若只在开始/结束时各读一次 UTC 日期,一旦子进程恰好跨过 UTC 午夜,两次读取就会不一致,导致同一份代码在个别 runner 上红、其他 runner 上绿。runAcrossUtcDay 在调用前后各取一天并返回区间内所有可能的日期,使断言"子进程看到的日期属于该区间"——断言不再依赖任何超时或时刻。
五、test-all.mjs 内联核心分节一览
把 18,000+ 行的 test-all.mjs 的编号分节(// ── N. ...)抽取出来,可以得到套件"门禁内容"的全貌:
| 分节 | 验证内容 | 代表性做法 |
|---|---|---|
| 1. Syntax checks | 仓库全部 .mjs 语法合法 |
复用 collectMjsFiles(ROOT)(与 npm run lint 共享同一遍历,防止范围漂移);8 个 worker 组成的 node --check 有界进程池,结果按收集顺序回填,日志与串行版逐字节一致 |
| 2. Script execution | 数十个根目录脚本可执行 | 把整个仓库复制进 ROOT 下临时目录(排除 node_modules、.git、data、reports),脚本在副本内运行,绝不改动真实用户数据;--self-test/--dry-run 模式为主;含 30s 默认预算与 SLOW_SCRIPT_WARN_FRACTION=0.75 的"接近超时"警告 |
| 3. Liveness classification | 职位页"存活性"分类逻辑 | 针对 liveness-core.mjs/liveness-api.mjs 注入各类真实页面文本:过期职位不得被页脚 "Apply" 复活、403 反爬页面归为 uncertain 而非 expired、WTTJ 排版弯引号与法语变音符横幅都能正确识别、Workday 多段 jobPath 的 SSRF 守卫等 |
| 4. Dashboard build | Go 仪表盘可编译 | go build 到临时目录;无 Go 编译器则 warn 跳过;--quick 时整体跳过 |
| 5. Data contract | 系统文件与各 CLI 技能入口完整 | 检查 CLAUDE.md、modes/_shared.md 等一长串文件存在;并比较 .agents/skills/career-ops/SKILL.md 与各 CLI(.claude/、.cursor/、.kimi/…)入口的 git blob——既允许真实 symlink(mode 120000),也允许物化出的同内容普通文件(mode 100644),但内容必须是规范 bloba |
| 6. Personal data leak check | 个人数据不泄漏 | 用 git grep 只扫已跟踪文件(untracked/被 gitignore 的文件不会误报),对维护者姓名、邮箱、路径等模式逐一扫描,落在授权文件列表外才告警 |
| 7. Absolute path check | 代码中无本机绝对路径 | 同样走 git grep /Users/ 思路,只查已跟踪的代码文件 |
| 7b+ | PDF 渲染等待条件、verify-cv-facts / verify-ats 回归、CLI 标志契约等 | 内联脚本化断言不断追加 |
| 8–15+ | Mode 文件完整性、本地解析器契约、Portal 配置校验、AGENTS.md / CLI wrapper 完整性、技能符号链接完整性、版本文件、Follow-up 节奏逻辑等 | 见第 14430 行之后的 20+ 个编号分节 |
值得注意的两点实现细节:
- 脚本执行从"一次性副本"运行:第 2 节把仓库整体复制到临时目录再逐个跑脚本,因此
normalize-statuses.mjs、merge-tracker.mjs等会原地写data/的脚本在测试中不会污染真实用户工作区; allowFail的语义是"预期非零退出",而不是"任何结果都行":cv-sync-check.mjs在没有cv.md的干净仓库中退出 1 是正常的;但若它死于未捕获异常(有Error:开头且带栈的 stderr),则判为"crashed before running"并 fail——这正是 #3440 修掉的陷阱:一个在模块作用域抛ReferenceError的脚本曾被安静地当成"预期失败"。
六、自动发现测试的三种运行形态
runDiscovered()(test-all.mjs)把发现的套件分成三类处理,每一类背后都是一个真实踩过的坑:
- 普通
pass/fail套件:在进程内await import()直接执行,共享本套件的计数器; node:test套件:通过在源码里探测from 'node:test'识别(例如 tests/reply-matcher.test.mjs 的import test from 'node:test')。它们必须在子进程里用node --test跑——因为node:test走的是 Node 自己的 runner,不触碰上面的计数器,且测试在 import 解析完成后异步执行,若直接 import 进来,finish()的process.exit()会在它们跑完前杀掉进程、丢弃结果。曾实测:把一个故意失败的 node:test 套件丢进tests/,结果打印 "All tests passed" 且退出码 0;- 违规套件:源码中出现
process.exit(或finish()的套件会被直接拒绝导入并fail——因为被发现的套件是"客人"而非"共同主办方",擅自 exit 会伪造整套件的最终判决并斩断排在后面的所有测试。
同样地,单个套件 import 时抛出未捕获异常,会被 try/catch 包裹成一条失败并继续运行后面的套件,而不是让整个 test-all 无提示终止。
七、新增一个测试:从文件到全绿
7.1 三步接入(无需注册)
tests/README.md 的 Adding a test 一节给出了完整的增量流程:
- 在
tests/下新增一个{name}.test.mjs,例如tests/my-script.test.mjs——自动被发现,无需注册,也绝不要给test-all.mjs加分节; - 用相对路径导入辅助函数:
import { pass, fail, ROOT } from './helpers.mjs'; // tests/*.test.mjs
import { pass, fail, ROOT } from '../helpers.mjs'; // tests/providers/*.test.mjs
- 本地开发循环用
node test-all.mjs --only my-script,push 前跑完整node test-all.mjs。完整的贡献流程见 CONTRIBUTING.md(其中第 170–199 行同样强调这三个命令与 "--only 不是 PR 门禁")。
导入路径的差异是刻意为之:辅助模块位于 tests/helpers.mjs,因此 tests/ 本层文件写 ./helpers.mjs,tests/providers/ 子层文件写 ../helpers.mjs。
7.2 Provider 测试的解剖
providers/ADDING_A_PROVIDER.md 第 3 节定义了 Provider 测试模板:tests/providers/themuse.test.mjs 是很好的范本,其结构为:
- ID 断言:
themuse.id === 'themuse'; - 纯解析函数单测:RSS/HTML Provider 应导出纯解析函数(如
normalizeMuseJob),直接喂入单条 job 对象断言字段映射(name → title、refs.landing_page → url、company.name → company、locations[0].name → location)与边界(空标题丢弃、相对 URL 丢弃、空 locations 返回空串、公司缺失回退 "The Muse"、非对象入参返回 null); fetch()行为:用 mock 的ctx注入样例结果,断言输出行数、去重与网络错误分支;- 共享安全守卫:SSRF 硬化的跨 Provider 测试集中在共享文件(
ats-ssrf-hardening.test.mjs、private-address-guard.test.mjs),单个 Provider 无需重复。
各阶段对应关系在 ADDING_A_PROVIDER.md 中有一张对照表(如 "Simple JSON API, no pagination" 对应 providers/greenhouse.mjs + tests/providers/greenhouse.test.mjs),可直接照抄体例。
7.3 编写时必须遵守的守卫约定
从 tests/README.md 与 test-all.mjs 的源码注释可以提炼出若干"硬约束",违反即红:
- 被发现的套件不得调用
process.exit(),也不得调用finish()(只有test-all.mjs有权打印全局汇总与决定退出码); - 套件允许使用
node:test风格(会被放到子进程跑),但不得在同一文件混用计数风格; - 需要真实 git 行为的用例,应使用
makeUpdaterRepo/hermeticGitEnv构造隔离仓库,而非依赖 CI 宿主环境; - 触发"预期失败"的
allowFail只能用于预期退出码,未捕获异常必须按崩溃处理。
八、常见排查手段
结合源码与 README,遇到"红了但看不懂"时可以按此定位:
- 区分"崩溃"与"断言失败":第 2 节每条失败都会带上子进程的退出码与 stdout/stderr 片段(
formatRunFailure()),先看是crashed还是普通 exit; - 怀疑 shell 而非被测代码:Windows 上若命令实际跑在 WSL/PATH 兜底 shell 里,脚本可能死于
node: command not found(exit 127),而断言看到的是空输出——helpers 会打印一行[shell] this command ran under ...提示你怀疑 shell; --only过滤为空的退出码 1 是特性不是 bug:这说明子串没匹配到任何tests/文件,检查路径拼写;- node:test 风格套件单独直跑:
node --test tests/reply-matcher.test.mjs可看其独立报告,再回跑全量确认子进程折叠逻辑正常; - 本地跑完整套件:
node test-all.mjs,注意 dashboard 分节需要 Go 编译器(没有会以 warn 跳过),脚本执行分节会在仓库内创建临时副本,属正常行为且结束即清理。
九、小结
career-ops 用最朴素的工具实现了极高的工程质量门槛:没有框架、没有注册表、没有 glob 库,只靠 tests/ 的字典序递归发现 + pass/fail 计数器 + 白名单子进程通道,就把语法、脚本执行、数据契约、个人数据泄漏、Provider 行为、跨语言(Go 仪表盘、Node 前端、shell 脚本)的回归全部挂到了每个 PR 必跑的 test-all.mjs 上。它真正值得借鉴的设计,不是某个断言技巧,而是处处"宁可失败得响亮,也不要绿得虚假"的守卫哲学——--only 无匹配必须退出 1、allowFail 不掩盖未捕获异常、node:test 套件的失败码被折叠进汇总、嵌套检出与网络竞态被显式隔离。理解了这些约定之后,向这套体系新增一个测试的成本几乎只剩"写断言本身"。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00