|
| 1 | +/** |
| 2 | + * 测试驱动文档生成脚本(演示 erest「测试即文档」能力)。 |
| 3 | + * |
| 4 | + * 与 generate.js 的区别: |
| 5 | + * - generate.js:只装配实例 + genDocs,不跑请求 → 所有 API 在文档里显示 ❌、无真实示例 |
| 6 | + * - 本脚本:用 test-agent 跑真实请求(.success().takeExample())→ 文档显示 ✅ + 真实示例 |
| 7 | + * |
| 8 | + * 原理(同一 ERest 实例上完成全链路): |
| 9 | + * 1. .success() / .error() / .raw() 读取响应输出 → 翻转对应路由的 tested 标记 → markdown 标题显示 ✅ |
| 10 | + * 2. .takeExample(name)(在 .success() 前调用)→ 把真实 input/headers/output 回填为该路由的示例 |
| 11 | + * 3. genDocs() 落盘时,✅ 标记与示例数据一并写入 markdown |
| 12 | + * |
| 13 | + * 运行:npm run docs:test |
| 14 | + */ |
| 15 | +import express from "express"; |
| 16 | +import { fileURLToPath } from "node:url"; |
| 17 | +import { dirname, resolve } from "node:path"; |
| 18 | +import { mkdirSync, readdirSync, readFileSync } from "node:fs"; |
| 19 | +import ERest from "erest"; |
| 20 | +import { ExpressAdapter } from "@erest/express"; |
| 21 | +import { API_INFO, GROUPS, registerApi } from "../src/api.js"; |
| 22 | +import { createStore } from "../src/store.js"; |
| 23 | +import { authBefore, adminBefore, logMiddleware, timingBefore } from "../src/hooks.js"; |
| 24 | + |
| 25 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 26 | +const outDir = resolve(__dirname, "out-from-test"); |
| 27 | +mkdirSync(outDir, { recursive: true }); |
| 28 | + |
| 29 | +// 装配 ERest 实例(开启 markdown 文档生成) |
| 30 | +const store = createStore(); |
| 31 | +const app = express(); |
| 32 | +app.use(express.json()); |
| 33 | +app.use(express.urlencoded({ extended: true })); |
| 34 | + |
| 35 | +const api = new ERest({ |
| 36 | + info: API_INFO, |
| 37 | + groups: GROUPS, |
| 38 | + forceGroup: true, |
| 39 | + // wiki: 每个分组生成独立 .md(public.md/post.md/admin.md);index: 生成目录页 |
| 40 | + docs: { markdown: true, wiki: true, index: true }, |
| 41 | +}); |
| 42 | + |
| 43 | +registerApi(api, store, { |
| 44 | + authBefore: authBefore(store), |
| 45 | + adminBefore: adminBefore(), |
| 46 | + logMiddleware: logMiddleware(), |
| 47 | + timingBefore: timingBefore(), |
| 48 | +}); |
| 49 | + |
| 50 | +api.bind({ adapter: new ExpressAdapter(), app, router: express.Router }); |
| 51 | + |
| 52 | +// 错误处理中间件(initTest 用 fetch 驱动测试,需错误以 JSON 响应体返回) |
| 53 | +app.use((err, _req, res, _next) => { |
| 54 | + res.status(err.statusCode || err.status || 400).json({ error: err.message }); |
| 55 | +}); |
| 56 | + |
| 57 | +// 初始化测试系统(接收 express app,内部 lazy listen 随机端口) |
| 58 | +api.initTest(app); |
| 59 | + |
| 60 | +// —— 用 test-agent 跑代表性请求,让文档通过测试变绿(✅)+ 回填真实示例 —— |
| 61 | +// 路径用完整 group 前缀(public/ /posts/ /admin/),鉴权用 X-Admin-Token header |
| 62 | +const examples = [ |
| 63 | + // public 组(无鉴权) |
| 64 | + () => api.test.get("/public/posts").takeExample("已发布文章列表").success(), |
| 65 | + () => api.test.get("/public/posts/hello-erest").takeExample("文章详情").success(), |
| 66 | + // post 组(需 user-token) |
| 67 | + () => |
| 68 | + api.test |
| 69 | + .get("/posts/posts") |
| 70 | + .headers({ "X-Admin-Token": "user-token" }) |
| 71 | + .input({ status: "published" }) |
| 72 | + .takeExample("我的文章列表") |
| 73 | + .success(), |
| 74 | + () => |
| 75 | + api.test |
| 76 | + .post("/posts/posts") |
| 77 | + .headers({ "X-Admin-Token": "user-token" }) |
| 78 | + .input({ slug: "test-driven-docs", title: "测试驱动文档", content: "测试即文档" }) |
| 79 | + .takeExample("创建文章") |
| 80 | + .success(), |
| 81 | + // admin 组(需 admin-token) |
| 82 | + () => api.test.get("/admin/users").headers({ "X-Admin-Token": "admin-token" }).takeExample("用户列表").success(), |
| 83 | + () => api.test.get("/admin/stats").headers({ "X-Admin-Token": "admin-token" }).takeExample("统计信息").success(), |
| 84 | +]; |
| 85 | + |
| 86 | +const results = await Promise.allSettled(examples.map((fn) => fn())); |
| 87 | +const failures = results.filter((r) => r.status === "rejected"); |
| 88 | +if (failures.length > 0) { |
| 89 | + for (const f of failures) console.error(" ✗ 请求失败:", f.reason?.message || f.reason); |
| 90 | + process.exit(1); |
| 91 | +} |
| 92 | + |
| 93 | +// 生成并保存文档(onExit=false 同步落盘) |
| 94 | +api.genDocs(outDir, false); |
| 95 | + |
| 96 | +// 统计 ✅ 覆盖率(遍历分组 md 文件) |
| 97 | +let testedCount = 0; |
| 98 | +let totalApis = 0; |
| 99 | +for (const f of readdirSync(outDir)) { |
| 100 | + if (!f.endsWith(".md")) continue; |
| 101 | + const md = readFileSync(resolve(outDir, f), "utf8"); |
| 102 | + testedCount += (md.match(/^## .+ ✅/gm) || []).length; |
| 103 | + totalApis += (md.match(/^## .+ [✅❌]/gm) || []).length; |
| 104 | +} |
| 105 | + |
| 106 | +console.log(`测试驱动文档已生成到 ${outDir}/`); |
| 107 | +console.log(` - ${testedCount}/${totalApis} 个路由通过测试变绿 ✅`); |
| 108 | +console.log(" - 示例数据来自 test-agent 真实响应(非 mock)"); |
| 109 | +console.log("\n对比:npm run docs 生成 mock 文档(全部 ❌、无示例)"); |
0 commit comments