项目:vitest-playground | 版本:v1.0 | 生成日期:2026-10-06 | 依据:源码分析
本项目对外只暴露一个自研 HTTP 接口:POST /api/run-vitest。它不是独立部署的服务,而是由 vite.config.ts 中的 vitestRunnerMiddleware() 插件挂载在 Vite dev 服务器与 preview 服务器上的同源中间件(configureServer 与 configurePreviewServer 两处 server.middlewares.use('/api/run-vitest', ...))。除此之外,页面所需的一切数据(课程结构、课时内容、初始代码、可见测试)都来自随包构建的 src/pages/vitest-learn/data.ts,通过模块导入而非接口获取。
| # | 方法 | 路径/通道 | 用途 | 入参 | 响应 | 实现位置 |
|---|---|---|---|---|---|---|
| 1 | POST | /api/run-vitest | 在服务端沙箱运行指定课时的用户代码,返回结构化结果 | JSON:{ chapterKey, lessonKey, code } | 200:RunResult;400/405/429/500:RunResult(success:false,见 §4;405 为纯文本) | 处理器 handleRunVitest(vitestServerRunner.ts);客户端 runVitest(src/pages/vitest-learn/runner.ts) |
| 2 | GET | /*(其余全部) | SPA 静态资源与路由回退 | - | HTML/JS/CSS/图片 | Vite dev/preview 内置能力 |
没有鉴权、没有 Cookie/CORS 特殊配置——同源调用即可(这也正是 AGENTS.md 警告"不要暴露到不可信网络"的原因,公网侧依赖 nginx 限流兜底,见 docs/deploy/runbook.md)。
请求头:Content-Type: application/json(runner.ts 显式设置)。
请求体字段(服务端在 handleRunVitest 中逐一校验):
| 字段 | 类型 | 必填 | 说明 | 校验失败后果 |
|---|---|---|---|---|
chapterKey | string | 是 | 章节 key,如 basics、tdd-practice | 400「请求无效: 需要 chapterKey / lessonKey / code 三个字符串字段」 |
lessonKey | string | 是 | 课时 key(章节内唯一),如 first-test | 同上 |
code | string | 是 | 用户当前编辑的代码。普通模式=测试文件内容;TDD 模式=被测实现;查看答案时=参考答案文本 | 同上;非字符串(如数字 42)同样 400(有测试锁定) |
约束:请求体整体超过 5MB 时,readBody 直接 req.destroy() 并拒绝(400「提交内容过大」),后续 chunk 被丢弃。请求体不是合法 JSON 也是 400。
设计要点——防篡改协议:客户端不能上传文件表、运行模式、覆盖率开关等任何执行参数。旧版 { files: {...} } 形态的请求会被 400 拒绝(tests/runner-security.test.ts 的「旧 files 形态请求被 400 拒绝」用例锁定)。服务端以 (chapterKey, lessonKey) 在 data.ts 中查出 Lesson,一切执行要素由服务端装配:
buildLessonFiles(lesson, code) 决定沙箱文件(普通/TDD/jsdom 规则见 01 篇 §3.2);resolveRunOptions(lesson) 决定 { coverage: !lesson.benchmark, benchmark: !!lesson.benchmark }。因此该接口本质上是"课程数据寻址 + 用户代码执行"的复合语义:chapterKey/lessonKey 是只读的寻址坐标,code 是唯一的自由变量。
成功响应(HTTP 200)与大多数失败响应共用同一结构 RunResult(类型定义在 src/pages/vitest-learn/types.ts,服务端 vitestServerRunner.ts 直接 import 同一类型):
| 字段 | 类型 | 必有 | 说明 |
|---|---|---|---|
success | boolean | 是 | 全部用例通过为 true;任何失败/错误路径均为 false |
output | string | 是 | 面向展示的纯文本报告:标题行 + 逐文件逐用例(✓/✗/○ 图标 + 耗时)+ 失败消息缩略(每条前 6 行)+「通过: x 失败: y 跳过: z」汇总 |
passed | number | 是 | 通过用例数 |
failed | number | 是 | 失败用例数(解析失败、超时等异常路径为 1) |
pending | number? | 否 | 跳过/待运行用例数(numPendingTests) |
error | string? | 否 | 预留错误字段(当前服务端主要把错误写进 output) |
tests | TestCaseSummary[]? | 否 | 逐用例明细:{ name, status, duration?, file?, line? } |
markers | ErrorMarker[]? | 否 | 失败定位:{ line, message },message 为 failureMessages[0] 首行且 ≤200 字符 |
coverage | CoverageSummary? | 否 | 非基准课时的运行结果附带:{ total: CoverageFile, files: CoverageFile[] },各项为 0-100 百分比 |
output 的组装规则(parseVitestJson):首两行固定 Test Results / ============;每个测试文件一段(文件名取 path.basename),其下逐用例一行——t.status 映射图标(passed→✓、failed→✗、skipped/pending→○、其余→·),耗时以 (Nms) 附注(四舍五入);失败用例在其后缩进附 failureMessages[0] 前 6 行;末尾追加统计行;若进程退出码非 0 但无失败用例,再追加一行"(进程退出码非 0,可能存在配置/环境问题)"。bench 模式下 output 以 Benchmark Results 开头,逐行 • 名称: mean ms (hz ops/s)。
本接口的约定是:尽量用 200 + success:false 表达"运行了但没通过",用 4xx/5xx 表达"根本没运行"。所有 JSON 错误响应的 body 仍是 RunResult(failResult() 构造:{ success:false, output, passed:0, failed:1 }),前端无需区分解析两种形态。
| HTTP | 触发条件 | output 文案(源码原文) | 后续动作 |
|---|---|---|---|
| 405 | 非 POST | 纯文本 Method Not Allowed(非 JSON) | - |
| 400 | body 非 JSON / 超 5MB / 缺字段或类型错误 / 未知课时 | 请求无效: …、提交内容过大、未知课时: {chapterKey}/{lessonKey} | 客户端直接展示 output |
| 429 | 并发 2 满且排队 8 满(RUNNER_BUSY) | 运行排队已满,请稍后再试 | 用户稍后重试;nginx 层另有 10r/m 限流(limit_req_status 429) |
| 500 | runInSandbox 抛出意外异常 | 服务端错误: {message} | 记录排查 |
| 200 | 运行超时(60s kill 进程组) | Vitest 运行超时(60s,可能有用例陷入死循环): + 输出尾部 2000 字符 | 按"运行了但失败"渲染 |
| 200 | vitest 启动失败(如未安装) | 启动 Vitest 失败: {message}(请确认项目已安装 vitest:pnpm install) | 同上 |
| 200 | 无法从输出解析结果 | 无法从输出解析测试结果,Vitest 输出: / 无法从输出解析基准结果,Vitest 输出:(均附尾部 2000 字符) | 同上 |
| 200 | result.json 存在但解析异常 | 测试结果解析失败,原始输出: + 前 2000 字符 | 同上 |
客户端侧约定(runner.ts 的 runVitest):HTTP 非 2xx 时不抛异常,而是合成 { success:false, output: '服务端返回 {status}', passed:0, failed:1 };fetch 网络异常(服务器未启动、断网)合成 无法连接运行服务: {message}(请确认开发服务器已启动)。因此调用方 chapter.tsx 的 .then/.catch 里,.catch 只兜底"极端未捕获错误"。
这一约定背后是一条清晰的设计哲学:接口面向"教学反馈"而非"机器判错"建模。学习者关心的不是 400 与 500 的语义差别,而是"我的代码怎么了、接下来该做什么"——因此所有可运行的失败(断言未过、超时、启动失败、解析失败)都以 HTTP 200 + success:false 回来,body 里带着可直接展示给用户的中文 output 文案;只有"请求本身不合法"或"服务不可用"才动用 4xx/5xx,前端把它们也折叠成同构的失败结果,UI 层(TestResult.status === 'error')完全不需要知道错误来自哪一层。副作用是排障时要多看一步(HTTP 200 不代表运行成功),为此 output 对每种异常路径都带上了定位线索:超时附输出尾部 2000 字符、解析失败附原始输出截断、退出码异常有专门提示行。
超时约束的传递:runInSandbox 的 options.timeoutMs 是导出参数但被 Math.min(timeoutMs, 60_000) 钳制,仅测试可缩短(死循环用例以 timeoutMs: 2000 验证 kill 与目录清理);HTTP 协议层不暴露超时参数。
请求(用户在 basics/first-test 中写了完整测试并点击运行):
POST /api/run-vitest
{ "chapterKey": "basics", "lessonKey": "first-test", "code": "test('add ok', () => { expect(1 + 1).toBe(2); });" }服务端动作:findLesson('basics','first-test') 命中 1.1 课时 → buildLessonFiles 产出 { 'lesson.spec.ts': code }(无 grader/hidden/jsdom/extraFiles)→ resolveRunOptions 得 { coverage: true, benchmark: false } → 沙箱写入 lesson.spec.ts + vitest.config.js(coverage enabled)+ setup.ts → node vitest.mjs run --root <dir> --reporter=json --outputFile.json=result.json --coverage → 读 result.json 与 coverage/coverage-summary.json。
响应(节选):
{
"success": true,
"output": "Test Results\n============\n\n📄 lesson.spec.ts\n ✓ add ok (3ms)\n\n通过: 1 失败: 0 跳过: 0",
"passed": 1,
"failed": 0,
"pending": 0,
"tests": [{ "name": "add ok", "status": "passed", "duration": 3, "file": "lesson.spec.ts" }],
"markers": [],
"coverage": { "total": { "lines": 100, "statements": 100, "branches": 100, "functions": 100 }, "files": [{ "file": "lesson.spec.ts", "lines": 100, "statements": 100, "branches": 100, "functions": 100 }] }
}前端动作:chapter.tsx setResult(success 态)、setLessonProgress({status:'passed'})、message.success('测试全部通过!');CodeEditor 渲染绿色面板、逐用例行、覆盖率条形图。
请求(tdd-practice/tdd-fizzbuzz,实现尚未完成):
POST /api/run-vitest
{ "chapterKey": "tdd-practice", "lessonKey": "tdd-fizzbuzz", "code": "export function fizzBuzz(n: number): string { return String(n); }" }服务端装配(与用户提交内容无关,只看课时数据):lesson.ts = 用户实现;lesson.spec.ts = 可见 grader(4 个 test);lesson.hidden.spec.ts = 隐藏 grader(含 1~100 全量遍历的反作弊校验)。运行结果中可见测试的第 2 个用例「3 的倍数返回 Fizz」失败。
响应(节选):
{
"success": false,
"passed": 2,
"failed": 2,
"tests": [
{ "name": "非 3/5 倍数返回数字本身", "status": "passed", "file": "lesson.spec.ts" },
{ "name": "3 的倍数返回 Fizz", "status": "failed", "file": "lesson.spec.ts", "line": 8 }
],
"markers": [{ "line": 8, "message": "AssertionError: expected '3' to be 'Fizz' // Object.is equality" }]
}前端动作:chapter.tsx 走 error 分支;由于是 TDD 课时,errorMarkers={!isTDD ? result.markers : undefined} 不下发 markers(失败行号属于 grader 文件,与用户编辑的 lesson.ts 行号不对应);结果面板逐用例列表红绿分明,message.error('有测试未通过')。用户补全实现后重跑,hidden spec 一并通过才 success:true 记通关。
以一个伪造的旧契约请求观察防线逐层生效的顺序:POST /api/run-vitest,body 为 {"files":{"lesson.spec.ts":"test('x',()=>{})"}}。处理路径为——方法检查通过(是 POST)→ readBody 成功解析出 JSON 对象 → 字段校验发现没有 chapterKey/lessonKey/code 三个字符串 → 立即 400,body 为 {"success":false,"output":"请求无效: 需要 chapterKey / lessonKey / code 三个字符串字段","passed":0,"failed":1}。全程未触碰文件系统、未占用并发槽位、未 spawn 任何进程——这正是"快速失败链"排列的意义:越廉价的检查越靠前,恶意或错误请求在进入昂贵阶段(创建目录、起进程)之前就被弹回。tests/runner-security.test.ts 用「旧 files 形态请求被 400 拒绝」「JSON null / 数组 body 返回 400 而非 500」等用例锁定了这条链的每一环,包括一个细节:typeof body !== 'object' 的判断若写成 !body 会把合法对象也拦下,而 null 在 JS 里 typeof 恰是 'object',因此校验是两个条件的合取——这类边界都有用例背书。
再把三个响应状态码的语义边界说透:400 表示"请求无法被理解或寻址不到课时"(重试无意义,需修请求);429 表示"请求合法但容量已满"(稍后重试有意义,客户端文案引导"稍后再试");500 表示"服务端装配/执行阶段出现了未被预期的异常"(需要维护者介入,output 里的 服务端错误: {message} 是第一线索)。三者与 200 + success:false 的"运行失败"共同构成完整的四象限,前端只用一个 TestResult.status === 'error' 承接后三者与第一象限的全部失败形态,协议复杂度被挡在了 UI 之外。
docs/superpowers/plans/2026-09-27-runner-hardening.md 记录了契约的一次收窄:加固前的契约是 { files, jsdom, coverage, benchmark }(客户端上传文件表与运行开关),加固后改为 { chapterKey, lessonKey, code } 三字段寻址式契约,装配权全部收归服务端。前端 runner.ts、服务端与测试三处同步演进,旧契约以 400 兼容拒绝而非静默转换。docs/superpowers/plans/2026-09-28-container-isolation.md 规划的 docker 后端(环境变量 VITEST_RUNNER_BACKEND)明确要求"API 契约零变化",即 §2/§3 的报文结构在容器化后保持不变(⚠️ 该后端当前未实现)。
下表把响应的每个字段从"谁生产、依据什么、谁消费、渲染成什么"串起来,可作为排障时的速查地图:
| 字段 | 生产者(服务端) | 数据来源 | 消费者(前端) | 最终呈现 |
|---|---|---|---|---|
success | parseVitestJson(failed === 0)或各 failResult | result.json 统计 / 异常路径 | chapter.tsx 的 handleRunTest 分支 | 结果面板红/绿色标题、全局 message 提示、是否记通关 |
output | parseVitestJson 拼装的文本报告 | assertionResults 遍历 | CodeEditor 结果面板 | <pre> 等宽文本块(含 ✓/✗/○ 图标与耗时) |
passed/failed/pending | numPassedTests 等统计键 | result.json | CodeEditor 头部 | 「通过 x 失败 y」徽标(失败数大于 0 才显示红色项) |
tests[] | 遍历 assertionResults | result.json | CodeEditor 逐用例列表 | 红绿着色的用例行 + 右对齐毫秒耗时 |
markers[] | failureMessages[0] 首行截断 + location.line | result.json | chapter.tsx(仅非 TDD 下传)→ CodeEditor 的 markers effect | Monaco 行内红色波浪线 + hover 错误消息 |
coverage | readCoverage 读 coverage-summary.json | vitest --coverage(json-summary reporter) | CodeEditor 覆盖率区 | 行/分支/函数三条进度条 + 分文件百分比(按 80/60 分档着色) |
error | 预留 | — | 当前无消费者 | — |
两条消费侧的"减法"值得注意:TDD 课时丢弃 markers(行号属于 grader 文件,见 02 篇 §5.3);查看答案状态下运行使用的是 solution 代码,成功同样记通关(backlog 项,⚠️ 未实现拦截)。
/api/run-vitest 在生产环境穿过 nginx(详见 docs/deploy/runbook.md),接口自身的行为与代理配置有以下几条默契:
limit_req(每 IP 10r/m、burst 3)管"单个来源单位时间能提多少次"。两层各自 429,客户端文案都走「服务端返回 429」→ 合成失败结果。proxy_read_timeout 600s 是按"2 并发消化 8 个深排队,最坏约 300 秒"推算的留缓冲值——排队等待也算在响应时间内,因此代理超时必须显著大于应用层超时与排队 worst case 之和。add_header(location / 注释明确警示)。接手时若要变更超时、并发或限流参数,务必三处联动核对:vitestServerRunner.ts 的 RUN_TIMEOUT/MAX_CONCURRENT_RUNS/MAX_QUEUE、runbook 的 nginx 配置、以及 tests/runner-security.test.ts 中对这些常量行为的锁定用例——文档与测试引用的是同一组语义,改一处不改其余会直接打破 600 秒预算推导。
本接口的可靠性不依赖口头约定,而是被三层测试钉死。理解这三层,等于掌握了"改接口不翻车"的操作手册:
第一层:纯函数单元测试。sanitizeEntryName 的全部拒绝规则(穿越、受保护名、大小写变体、非字符串)逐条有断言;buildLessonFiles 的六种装配矩阵(TDD 有/无 hidden、纯空白 grader、普通 + hidden、jsdom pragma 与扩展名、extraFiles 并入)以 toEqual 精确比对文件表;resolveRunOptions 锁定 coverage/benchmark 的互斥推导;buildChildEnv 锁定环境变量键的精确集合;createRunLimiter 锁定排队/拒绝/所有权转移语义。这些用例不需要启动任何进程,毫秒级完成,是最内层的契约防线。
第二层:真跑集成测试。runInSandbox 直接接收构造的文件表执行真实 vitest:合法用例的结构化结果、哨兵环境变量不泄漏、用户打印 JSON 不污染、死循环超时 kill 且目录零残留、超限文件数拒绝、coverage 字段存在性、海量 stdout 下的稳定性——每条防线的"行为证据"而非"代码存在性"被验证。handleRunVitest 层则用 node:stream 的 PassThrough 伪造请求/响应对象,验证 400(缺字段、null、数组、旧 files 契约、code 非字符串)、未知课时 400、以及两条全链路黄金路径(basics/first-test 以初始 code 必须成功、coverage-advanced/benchmark 的 bench 链路必须产出结果)。
第三层:课程级端到端门禁。scripts/check-lessons.ts(CI 的 lessons job)以与产品完全相同的装配规则逐课时实测,保证"接口契约 × 56 个课时的数据"这个笛卡尔积始终可运行——数据侧的任何破坏(模板字符串少闭合、solution 不可通过)都在 CI 被拦截,而不会等到用户点击运行时才暴露。
修改接口时的推荐顺序:先在 tests/runner-security.test.ts 写出新契约的期望(红)→ 改 vitestServerRunner.ts 与 runner.ts(绿)→ 跑 pnpm check:lessons 确认课程数据兼容 → 更新 AGENTS.md 的"运行契约"小节与本套文档的 03 篇。跳过任何一步,都会留下"代码改了、防线没跟上"的暗债。