diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..8c9a5f6 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,96 @@ +name: Mutation Testing + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + inputs: + full: + description: Force a full mutation run and rebuild the baseline + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: mutation-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + mutation: + runs-on: ubuntu-latest + timeout-minutes: 90 + env: + MUTATION_NODE_VERSION: '24' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.MUTATION_NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run source tests + run: pnpm run test:lib + + # Stryker tracks src/lib and test*.ts. Hash helpers, adapters and configs + # separately so changes outside the mutation scope invalidate the baseline. + - name: Compute baseline compatibility key + id: baseline-key + env: + INPUTS_HASH: >- + ${{ hashFiles('pnpm-lock.yaml', 'package.json', 'pnpm-workspace.yaml', + '.npmrc', 'stryker.config.*', 'vitest.config.*', 'tsconfig*.json', + '.github/workflows/mutation.yml', 'packages/**/package.json', + 'packages/**/tsconfig*.json', 'packages/**/src/**', 'src/test/**', + 'src/**/*.d.ts', '!src/test/test*.ts') }} + run: echo "prefix=mutation-core-v1-${RUNNER_OS}-node${MUTATION_NODE_VERSION}-${INPUTS_HASH}" >> "$GITHUB_OUTPUT" + + - name: Restore main mutation baseline + id: baseline + uses: actions/cache/restore@v4 + with: + path: reports/stryker-incremental.json + key: ${{ steps.baseline-key.outputs.prefix }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + ${{ steps.baseline-key.outputs.prefix }}- + + - name: Run mutation tests + env: + FORCE_FULL: ${{ inputs.full == true }} + run: | + if [ "$FORCE_FULL" = "true" ]; then + pnpm run mutation:full + else + # Missing or incompatible baseline naturally falls back to a full run. + pnpm run mutation + fi + + - name: Save main mutation baseline + if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: actions/cache/save@v4 + with: + path: reports/stryker-incremental.json + key: ${{ steps.baseline.outputs.cache-primary-key }} + + - name: Upload mutation reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: mutation-reports-${{ github.run_id }}-${{ github.run_attempt }} + path: reports/ + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index e1e32dd..70d93d7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ packages/*/src/*.d.ts coverage/ .nyc_output/ +# 变异测试(Stryker) +.stryker-tmp/ +reports/ + # 日志 *.log npm-debug.log* diff --git a/MIGRATION.md b/MIGRATION.md index 0206afe..26f4d41 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -340,6 +340,42 @@ instead」)。`@koa/router` 是官方维护的继任包,API 与 koa-router * - **`zod` 从 `dependencies` 改为 `peerDependencies`**:作为 schema 校验库,erest 不应硬钉死 zod 版本,应由宿主项目统一提供。此前 erest 把 `zod@^4.0.5` 列为 hard dependency,导致任何 link/依赖 erest 的项目出现两份 zod 副本(erest 自带的 + 宿主的),TS 因 zod 版本字面量标记不同而报类型不兼容。**迁移**:宿主项目须在自身 `dependencies` 显式声明 `zod`(版本 `^4.0.0`)。adapter 子包(`@erest/express` 等)不直接依赖 zod,无需改动。 +## v3.2.3 — 变异测试驱动的缺陷修复(文档生成器 / TestAgent / 工具函数) + +接入 StrykerJS 变异测试(`pnpm run mutation`)后,补测过程中发现并修复以下问题: + +### 修复(非 breaking) + +**文档生成器(生成物正确性):** + +- **`generate_axios`:路径参数模板转义错误** —— 生成的 SDK 中 `` `/user/\$\{id\}` `` 因 `\$` 转义使插值退化为字面量文本,**所有路径参数方法的 URL 参数实际不生效**。修复后生成 `` `/user/${id}` ``,参数正常插值。 +- **`generate_swagger`:多参数路径只转换第一个占位符** —— `/:a/:b` 类路径的 `replace` 缺 `/g` 标志,只有 `:a` 变成 `{a}`。修复后全部转换。 +- **`generate_swagger`:POST body 字段级 required 信息丢失** —— 此前字段必填信息被 `delete` 丢弃(相关收集代码为死代码)。修复后聚合进 body 参数的 `schema.required`(JSON Schema 语义)。 +- **`generate_markdown`(schema):zod 4 下数组元素恒显示 `unknown[]`、枚举取值恒为空** —— 直接读 `_def.type`/`_def.values`(zod 3 形态)导致。已改走 `zod-meta.ts` 双轨访问器(`_def.element` / `entries`),Zod 3 与 Zod 4 下均正确渲染 `string[]` 与枚举值列表。 +- **`generate_markdown`(schema):falsy 默认值渲染为空** —— `z.number().default(0)` / `z.boolean().default(false)` 的默认值列此前显示为空,现在如实展示 `0` / `false`。 +- **`generate_markdown`(apis):无 input 的示例渲染出字面量 `undefined` 文本** —— 已修复为不渲染该行。 + +**TestAgent(测试代理):** + +- **多个 `Set-Cookie` 响应头相互覆盖** —— `headers.forEach` 逐条回调使后值覆盖前值,session cookie 丢失。修复后用 `getSetCookie()` 取完整数组再合并(逗号拼接,与 cookie jar 解析约定一致)。 +- **`attach()` 传流时内容不传输** —— Readable 直接 `form.append` 会被 undici FormData 隐式 `String()` 成 `[object Object]`。修复后流在请求时读尽为 Blob 再传输(multipart 文件名取字段名)。 +- **GET/DELETE + `attach()` 直接抛 fetch 底层错误** —— 现在提前断言并给出中文提示(GET 不能携带 body,建议改用 POST/PUT/PATCH 或 `input()` 传 query)。 + +**工具函数:** + +- **`getCallerSourceLine` 在 ESM 下恒返回空** —— 栈帧正则只匹配 CJS 括号风格,ESM/现代 V8 无括号帧(`at /path/file.ts:1:1`,且为 `file://` URL)永不匹配,API 文档的 sourceFile 字段静默失效。修复后双风格兼容并统一去掉 `file://` 前缀。 +- **`jsonStringify` 的 replacer 为死代码** —— 防循环引用逻辑早已被注释,两分支行为恒同,已简化为直接 `JSON.stringify`。 +- **`ErrorManager.modify()` 换 code 后旧 code 永久占用** —— 修复后旧 code 释放(可被再次注册),且新 code 若与其他错误冲突会立即报错(此前静默成功、错误暴露点错位)。 + +### 约定收敛 + +- **`_def` 读取全部集中到 `plugin/zod-meta.ts`**(AGENTS.md 约定 6):`generate_axios` / `generate_swagger` / `generate_postman` 此前的直读 `_def.typeName` / `_def.shape` / `_def.innerType` 已迁移到统一双轨访问器,并新增 `resolveZodShape` / `getZodElement` / `getZodEnumValues` / `getZodOptions` / `getZodDefaultValue` / `getZodLazyGetter` 导出。 + +### 变异测试基础设施 + +- 新增 `stryker.config.json` 与 `pnpm run mutation` / `mutation:incremental` 脚本(vitest runner + perTest 覆盖分析)。 +- 变异分数从 44.93% 提升至 89.7%+(新增约 270 个内容级断言用例,全部融入现有测试文件)。 + ## v3.2.2 — 文档展示返回结果 schema(Swagger / Postman) ### 新增(非 breaking) diff --git a/README.md b/README.md index 2088436..ae5e948 100644 --- a/README.md +++ b/README.md @@ -693,6 +693,36 @@ examples 作为 pnpm workspace 子包,通过 `erest: workspace:*` 引用本地 自定义错误注册、自定义 type/schema 注册、`define()` 声明式、`mock()`、`response()` schema、 分层参数、文档生成、测试集成。详见 `examples/README.md`。 +## 开发 + +```bash +pnpm install +pnpm run test:lib # 直接跑源码测试(ISLIB=1,无需先 build) +pnpm run test:cov # 覆盖率报告 +pnpm run mutation # 变异测试(Stryker + vitest),默认增量;首次全量约 20-40 分钟 +pnpm run mutation:incremental # 保留的增量命令,与 mutation 相同 +pnpm run mutation:full # 忽略历史结果,强制全量重建基线 +``` + +变异测试基于 [StrykerJS](https://stryker-mutator.io/)(配置见 `stryker.config.json`,变异范围为 +`src/lib/**`),HTML 报告输出到 `reports/mutation/mutation.html`。变异分数衡量「测试集能否杀死 +被注入的缺陷」,用于发现覆盖漏网与断言不足;分数参考即可,不必追求 100%。 + +变异测试需要 Node.js 22+(Stryker 10),本地增量基线保存到 +`reports/stryker-incremental.json`。增量运行仍会先执行原始测试收集覆盖信息,再比较源码与测试变化。 +修改依赖、配置、测试辅助文件或运行环境后,本地可用 `pnpm run mutation:full` 重建基线。 + +GitHub Actions 的 `Mutation Testing` 工作流使用 Node.js 24 和 pnpm 9:面向 `main` 的 PR +只读取兼容的主分支基线;推送到 `main` 后增量运行,成功才保存新基线。缓存键包含依赖、配置、 +测试辅助文件及未参与变异的 adapter 源码,并为每次运行保存独立版本。基线缺失或兼容性输入变化时 +自动全量执行,因此首次合并前的 PR 可能较慢;合并后首次成功的主分支 CI 才会建立完整共享基线。 +PR 不写共享基线,主分支运行不会被后续提交主动取消。 + +需要强制重建时,在 Actions 中手动运行该工作流并勾选 `full`;仅在 `main` 成功运行时更新共享基线。 +成功或失败时的可用 HTML、JSON 报告均上传为 `mutation-reports-*` artifact,保留 14 天。 +静态变异或未跟踪的环境变化可能需要手动全量重跑。当前未设置最低变异分数门禁,作业成功仍可能 +存在存活变异;此工作流也不会自动配置分支保护或 required status check。 + ## API 文档 线上文档站(typedoc 产物)由 CI 自动构建部署: diff --git a/package.json b/package.json index 0582519..6c74cd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "erest", - "version": "3.2.2", + "version": "3.2.3", "description": "Framework-agnostic REST API framework with Zod validation, auto docs & test scaffolding. Express/Koa/@leizm/web adapters as separate packages.", "type": "module", "main": "dist/lib/index.js", @@ -21,6 +21,9 @@ "test": "npm run build && npm run build:packages && vitest run && pnpm --filter erest-example test", "test:lib": "export ISLIB=1 && vitest run", "test:cov": "export ISLIB=1 && vitest run --coverage", + "mutation": "export ISLIB=1 && stryker run", + "mutation:full": "export ISLIB=1 && stryker run --force", + "mutation:incremental": "export ISLIB=1 && stryker run --incremental", "test:examples": "pnpm --filter erest-example test", "bench": "export ISLIB=1 && vitest bench --config vitest.bench.config.ts", "tag": "git tag v`node -p 'require(\"./package\").version'`", @@ -71,6 +74,8 @@ }, "devDependencies": { "@leizm/web": "^2.7.3", + "@stryker-mutator/core": "^10.0.0", + "@stryker-mutator/vitest-runner": "^10.0.0", "@types/debug": "^4.1.12", "@types/express": "^5.0.0", "@types/koa": "^2.15.0", @@ -93,11 +98,6 @@ "vitest": "^3.2.4", "zod": "^4.4.3" }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] - }, "lint-staged": { "*.{ts,js}": [ "oxfmt --write", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 618e9c7..83796c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,14 +10,20 @@ importers: dependencies: debug: specifier: ^4.4.1 - version: 4.4.1 + version: 4.4.1(supports-color@7.2.0) path-to-regexp: specifier: ^6.3.0 version: 6.3.0 devDependencies: '@leizm/web': specifier: ^2.7.3 - version: 2.7.3(@types/express@5.0.6) + version: 2.7.3(@types/express@5.0.6)(supports-color@7.2.0) + '@stryker-mutator/core': + specifier: ^10.0.0 + version: 10.0.0(@types/node@24.0.15) + '@stryker-mutator/vitest-runner': + specifier: ^10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@24.0.15))(vitest@3.2.4) '@types/debug': specifier: ^4.1.12 version: 4.1.12 @@ -38,25 +44,25 @@ importers: version: 24.0.15 '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(supports-color@7.2.0)(vitest@3.2.4) '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) express: specifier: ^5.1.0 - version: 5.2.1 + version: 5.2.1(supports-color@7.2.0) husky: specifier: ^9.1.7 version: 9.1.7 koa: specifier: ^3.0.0 - version: 3.0.0 + version: 3.0.0(supports-color@7.2.0) koa-bodyparser: specifier: ^4.4.1 version: 4.4.1 koa-router: specifier: ^13.1.1 - version: 13.1.1 + version: 13.1.1(supports-color@7.2.0) lint-staged: specifier: ^17.0.8 version: 17.0.8 @@ -77,7 +83,7 @@ importers: version: 5.8.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@3.2.4)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@3.2.4)(supports-color@7.2.0)(yaml@2.9.0) zod: specifier: ^4.4.3 version: 4.4.3 @@ -95,29 +101,29 @@ importers: version: link:../packages/erest-leizmweb '@leizm/web': specifier: ^2.7.0 - version: 2.7.3(@types/express@5.0.6) + version: 2.7.3(@types/express@5.0.6)(supports-color@7.2.0) erest: specifier: workspace:* version: link:.. express: specifier: ^5.1.0 - version: 5.2.1 + version: 5.2.1(supports-color@7.2.0) koa: specifier: ^3.0.0 - version: 3.0.0 + version: 3.0.0(supports-color@7.2.0) koa-bodyparser: specifier: ^4.4.1 version: 4.4.1 koa-router: specifier: ^13.1.0 - version: 13.1.1 + version: 13.1.1(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.0.5 devDependencies: vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@3.2.4)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@3.2.4)(supports-color@7.2.0)(yaml@2.9.0) packages/erest-express: devDependencies: @@ -129,7 +135,7 @@ importers: version: link:../.. express: specifier: ^5.1.0 - version: 5.2.1 + version: 5.2.1(supports-color@7.2.0) typescript: specifier: ^5.8.0 version: 5.8.3 @@ -144,7 +150,7 @@ importers: dependencies: '@koa/router': specifier: ^13.0.0 - version: 13.1.1 + version: 13.1.1(supports-color@7.2.0) devDependencies: '@types/koa': specifier: ^2.15.0 @@ -157,10 +163,10 @@ importers: version: link:../.. koa: specifier: ^3.0.0 - version: 3.0.0 + version: 3.0.0(supports-color@7.2.0) koa-router: specifier: ^13.1.0 - version: 13.1.1 + version: 13.1.1(supports-color@7.2.0) typescript: specifier: ^5.8.0 version: 5.8.3 @@ -169,7 +175,7 @@ importers: devDependencies: '@leizm/web': specifier: ^2.7.3 - version: 2.7.3(@types/express@5.0.6) + version: 2.7.3(@types/express@5.0.6)(supports-color@7.2.0) erest: specifier: workspace:* version: link:../.. @@ -183,23 +189,208 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@babel/code-frame@8.0.6': + resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/compat-data@8.0.5': + resolution: {integrity: sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/core@8.0.7': + resolution: {integrity: sha512-ZIsZWBG6Y1oa+8fkQVp0xeCdjCHBkXFCCIWrmg9SvejVvfcVQgKKRmGrBDWfM7rilhvnRoEuaqa5dkK7KB3pHA==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/generator@8.0.6': + resolution: {integrity: sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-annotate-as-pure@8.0.0': + resolution: {integrity: sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-compilation-targets@8.0.7': + resolution: {integrity: sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-create-class-features-plugin@8.0.7': + resolution: {integrity: sha512-7ug3KrOhNVPPxFFLTel0/pMzimKzU/vTlNJKVL/dKguCpmNJZjjibyFbWg+Qs8QHmiIZPNuZ2UNnDGdHWGEpOg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/helper-globals@8.0.6': + resolution: {integrity: sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-member-expression-to-functions@8.0.5': + resolution: {integrity: sha512-GLe05QD98BkNFTkjaqeqF9QSRoHLKrrB4tpoplyQuuPrQJ72rtmkM4wvqJLX/sjZPqkbK5MUYSsuUTqdJoOVjA==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-module-imports@8.0.0': + resolution: {integrity: sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-module-transforms@8.0.6': + resolution: {integrity: sha512-YUi+3X0cb0v97xKhuqkLdrwQtP6XXv1eVbhLRCFyMwG481UskbMQk4mjH7K/Jg3oJ1KLskvFwwq4kF/QnABkWQ==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/helper-optimise-call-expression@8.0.0': + resolution: {integrity: sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-plugin-utils@8.0.1': + resolution: {integrity: sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/helper-replace-supers@8.0.1': + resolution: {integrity: sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@8.0.0': + resolution: {integrity: sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.6': + resolution: {integrity: sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@7.27.1': resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.6': + resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-validator-option@8.0.0': + resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helpers@8.0.7': + resolution: {integrity: sha512-5dMj5kFwS2AY7Dt4PZp4px3c5ThMbbEa7tZfuHTncCGSFYq8uoA1ufDE1IS/lYpQedE+3+GQ3cPZUiplIu195w==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/parser@7.28.0': resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@8.0.7': + resolution: {integrity: sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + + '@babel/plugin-proposal-decorators@8.0.2': + resolution: {integrity: sha512-+C6O6KKXU7BBq1GNaIkFJxrALUVGRcr+WeWm4OcuRl3h+l/CmNfcTLMrT2Lm3uvGBimBH/8pEBRrXJFLoO67Gg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-syntax-decorators@8.0.1': + resolution: {integrity: sha512-NI+0S/6MvR6GlcQFwjDZ+WIc2qvG6TXN534lYs9llNldwW4b7Dh6KTtk030FA0xWdYGs4t1lWo+OEWN8wGB+Nw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-syntax-jsx@8.0.1': + resolution: {integrity: sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-syntax-typescript@8.0.3': + resolution: {integrity: sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-destructuring@8.0.5': + resolution: {integrity: sha512-evjxc5fGvpXG2WMGSalpX8IJZVumLIDgk5r5eRhvXRDqFX/GPWq1BeRfJalPCcwJfrR5A/wvegUhx9W5Nr7/mg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-explicit-resource-management@8.0.5': + resolution: {integrity: sha512-Vr20jk/ZxRGu30o0jYvcKl2Pf0tiyr8H9q2ZGxYD6yjOpv9DdP6//I85q0cSOdADZhsu/ZZoApjGVCfl4xnVJw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-modules-commonjs@8.0.1': + resolution: {integrity: sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-display-name@8.0.1': + resolution: {integrity: sha512-soLishXlkyu6jcICPyO3HEP7A3GCzKEnn7XfvYrImuWEOwFAz93qShmWSYPf5ww0ZkO4By0zsN2bVIDF54fSdA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-jsx-development@8.0.1': + resolution: {integrity: sha512-Hb+HUZpV9KFHjm+F+P3aLDMi8QXU9l3ROCQv20z18Me2sGyW5nNNR5YTevNlgHvCpFek3BnAwhDGq/BRndXViw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-jsx@8.0.1': + resolution: {integrity: sha512-NgkoF7Uq+30TmOPDdNUimT0Nta02uVjqJRFNlVWKrbOCu/CkzfHa4aMnIs0lMpkMmZmWA1e42Va+F04i/pY1zw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-pure-annotations@8.0.1': + resolution: {integrity: sha512-7/8UwU8hoPBurXa9tUiTTC8aACTRy5tCqLUtqikHp2eGiWoEB57AduOdbQ71OOMTEvawKrGhv3WfzkDpI+/oSg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-typescript@8.0.6': + resolution: {integrity: sha512-WF3yRlQtClzScvQbWct00ix63+w3DPiyU0WmCBU4YqK539n3VvJFElElpevoD8wfu6953CbiNoUZBi2B7/5jXw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/preset-react@8.0.1': + resolution: {integrity: sha512-jrFuPp/pTddFZbtmWhdLNAYc6UMcpboeUPnw0BBrm4nOmcAko/1TRcFi1PzWCeOFRU+VaSiKmat87W1HvR7mIg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/preset-typescript@8.0.7': + resolution: {integrity: sha512-sUFLFphAG6YNjCnJpSKpyoNxkfyeM2KNuUjJJN0R6dZusMcK2V3Fg11MVHPFvb37AZeDNocc+xM748vdh9z82Q==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/template@8.0.0': + resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/traverse@8.0.7': + resolution: {integrity: sha512-bVV0brinsTWgc6ptSIoQo3GbITtl0VZAqEUJbHHcyfDPEu+q/fEvlYEa+6H85HoK8IdksKBFtKJbJFZ6Kff+zg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/types@7.28.1': resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==} engines: {node: '>=6.9.0'} + '@babel/types@8.0.6': + resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} + engines: {node: ^22.18.0 || >=24.11.0} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -366,6 +557,140 @@ packages: '@hapi/bourne@3.0.0': resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==} + '@inquirer/ansi@2.0.9': + resolution: {integrity: sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.6': + resolution: {integrity: sha512-XkOardciF3tow2NUlY6/Qjk1QqYa+6WpmVLTpxXl97Uv4erczDEZ+xg1j6LqBf0ekcRSUiICQA4nQXHHkVKQ9w==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.3.3': + resolution: {integrity: sha512-ImOBgpl/sJTFp8naeSkNu4S0Sf8F8pxv3/CUx+LoBAdQvCVqFz6rSB2A/oZYsJge3+yphbEDxEkfaRNYq1OU0A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@12.0.4': + resolution: {integrity: sha512-M5fB67y7jPObI45vhD82RskWwCSm0scOvenV/I6K8tIi2gfr9DXqQqJ+iw+Wi6SgapikdOiJibYzz1gW47fsDg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.3.4': + resolution: {integrity: sha512-imzxQDfw26NpcJAubMj/dE5RjU9Mp80IcT+FzsD6YoxXmL8L1QYnAFdCNv7ccGWh//73+0YP+1kfhwnExdl2VQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.6': + resolution: {integrity: sha512-JfiLKi7qGuHmcQO/DqR10UFEp054Y1FXZaszP5ZEfHhgHeuFoT5nNFQIXFhed/5/p07SbsV2tIJFwG/dg5+NmQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@3.0.6': + resolution: {integrity: sha512-rWmw6WG8JZ7LMn/srXEylYGhL6EXXJeROE7/4x4GREh6NgzqJBDbIgpNGsYoLPfobeb4VZtEYzPGnT5EFNYhiQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.9': + resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.7': + resolution: {integrity: sha512-Icrs9tuMBCxGI+lFYNO71VFOnqGO8IIrLIGqnTGh07X8jZ2la9QLAYxXcikHZQjjVhIZUtU2xyH89DVN/pPhVg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.2.4': + resolution: {integrity: sha512-C6g80U4U2+w2Vxa1CB0MN3a2WnQIfzLSUG0lduO3Au9GsirJsKATZkOwZPK+vZ1BlAlz+/Z5ParfQCS9E0svmg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.2.3': + resolution: {integrity: sha512-1Uo2qfI2K3wXbfunMpvwAzoZPIUf5iEETwDGqRVbswoorXGqYQJ/bfIJFeLj+pxjq+e2UFPNOmx+fsIl4Urigw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.7.3': + resolution: {integrity: sha512-DHK20kO+H5k/XcJQpHQUpVQbMYHHhZchlGWhZVMUV4WIuRuBW5744hipOtriE1vMqPLgnIyXFMHWKkIbTepa4Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.6': + resolution: {integrity: sha512-2exExhQ5xTBqfN/rRlZkvM1Gm3b0eiLcWe2NqxWxe7qYmnU4M3j/n9TwzfPUb+Q7dAAz9TmBtDWWdF3jbp4ZLA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.3.4': + resolution: {integrity: sha512-xsk0UeFP04jsbEeFMzghjUaH2aHXyt+WykSajQzq/8qyBNHsgGYHea/sFwv5T08kW7Sp+KDX8aF124zPUIs1Ww==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.6': + resolution: {integrity: sha512-jFu7CFghlr0vpi/326a4wz6BGkJH9X3H/iIu+ahyV2Ja52ktqQE3fC9BYP47JKeSPQYGUv5xquE2Vp9PbepZ3g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.2.0': + resolution: {integrity: sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -377,6 +702,9 @@ packages: '@jridgewell/gen-mapping@0.3.12': resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + '@jridgewell/gen-mapping@0.4.0-beta.0': + resolution: {integrity: sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -384,9 +712,15 @@ packages: '@jridgewell/sourcemap-codec@1.5.4': resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.29': resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsonjoy.com/base64@1.1.2': resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} engines: {node: '>=10.0'} @@ -426,6 +760,7 @@ packages: '@koa/router@13.1.1': resolution: {integrity: sha512-JQEuMANYRVHs7lm7KY9PCIjkgJk73h4m4J+g2mkw2Vo1ugPZ17UJVqEH8F+HeAdjKz5do1OaLe7ArDz+z308gw==} engines: {node: '>= 18'} + deprecated: Please upgrade to v15 or higher. All reported bugs in this version are fixed in newer releases, dependencies have been updated, and security has been improved. '@leizm/web@2.7.3': resolution: {integrity: sha512-PrjrZWOIvtnSTVt8zuDxWaqjpnzdQCSs2XZehbzT7wycjc8XG9o/g+34CdpwVBF1M8/0U+MClt35PWJLINXJfA==} @@ -792,6 +1127,9 @@ packages: cpu: [x64] os: [win32] + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@shikijs/engine-oniguruma@3.23.0': resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} @@ -807,6 +1145,33 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@stryker-mutator/api@10.0.0': + resolution: {integrity: sha512-ZtAJ0ZT3MVRCWJTBE2h90XB/6E+4lifHYtcTyNG6nU2nLekPgTo4gD5esjX6Okxo1b/JB4jJzyxYB54fwKAoJw==} + engines: {node: '>=22.0.0'} + + '@stryker-mutator/core@10.0.0': + resolution: {integrity: sha512-ZvMsRyaXQQ5e6Thcid9pkuODv6Fn9E3nrBQJUap+hcJuGJ4unm26afo3m6YKSjn8kinyxJ/3TXf0cTWRDaTxVw==} + engines: {node: '>=22.0.0'} + hasBin: true + + '@stryker-mutator/instrumenter@10.0.0': + resolution: {integrity: sha512-B7Wmn1KlEWyFeOz6D6oGvQGRfi5Xw3VemG6dEKvFQp4qLvxD9Mf4kcZghfxffgnYwXd3bFgqXsJ+ZGlhdfIOrQ==} + engines: {node: '>=22.0.0'} + + '@stryker-mutator/util@10.0.0': + resolution: {integrity: sha512-LzOpHiJaCp2ABQgnPMlrQQcsK43bd5Vo/2FGL78aN62yDoeRQ+4j3tzeuXxK5OAHdC3fUz6TDoy4IsoAuLAd3w==} + + '@stryker-mutator/vitest-runner@10.0.0': + resolution: {integrity: sha512-SHK2/vfvRUpiz7jXPnQMBnr6zLdm69DK03Mo5mPhaZWcRSygrKUqYsPqWsXsK+5ySHzlMTfCyFK5NQ/X9sJFFw==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@stryker-mutator/core': 10.0.0 + vitest: '>=2.0.0' + '@types/accepts@1.3.7': resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} @@ -854,6 +1219,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/gensync@1.0.5': + resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -863,6 +1231,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/keygrip@1.0.6': resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} @@ -956,6 +1327,13 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + angular-html-parser@10.11.0: + resolution: {integrity: sha512-3vERzJ65UFDr3C7uozLJwsNcQS3FS784dSh583oDgDTTZMgXe3/pdyXgKndxiP5R2lvYRfW6gSl145Hnyf2OFA==} + engines: {node: '>= 14'} + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -1001,6 +1379,11 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.11.29: + resolution: {integrity: sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==} + engines: {node: '>=6.0.0'} + hasBin: true + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1016,6 +1399,11 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + browserslist@4.29.3: + resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + busboy@0.3.1: resolution: {integrity: sha512-y7tTxhGKXcyBxRKAni+awqx8uqaJKrSFSNFSeRG5CsWNdmy2BIK+6VGWEW7TZnIO/533mtMEA4rOevQV815YJw==} engines: {node: '>=4.5.0'} @@ -1040,10 +1428,20 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + caniuse-lite@1.0.30001816: + resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==} + chai@5.2.1: resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} engines: {node: '>=18'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -1056,6 +1454,10 @@ packages: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + co-body@6.2.0: resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==} engines: {node: '>=8.0.0'} @@ -1067,6 +1469,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -1083,6 +1489,9 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-parser@1.4.7: resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} engines: {node: '>= 0.8.0'} @@ -1157,6 +1566,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + des.js@1.1.0: + resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} + destroy@1.0.4: resolution: {integrity: sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==} @@ -1168,6 +1580,9 @@ packages: resolution: {integrity: sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA==} engines: {node: '>=4.5.0'} + diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1178,6 +1593,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.458: + resolution: {integrity: sha512-/hIH2gRSmxrf4/X4Nw/t/Wc8PVS22l8/3HnWjWTWsTOM5dsnMUPZF/+u+yr3LqweNuTBJE1rq6qAQzKkZVwMfA==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -1187,6 +1605,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} + engines: {node: '>=14'} + encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -1223,6 +1645,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -1236,6 +1662,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.2.2: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} @@ -1244,6 +1674,21 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fdir@6.4.6: resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} peerDependencies: @@ -1255,6 +1700,10 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -1262,6 +1711,10 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flru@1.0.2: + resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} + engines: {node: '>=6'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1286,6 +1739,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-east-asian-width@1.6.0: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} @@ -1298,8 +1755,13 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true gopd@1.2.0: @@ -1337,6 +1799,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -1354,6 +1820,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + inflation@2.1.0: resolution: {integrity: sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==} engines: {node: '>= 0.8.0'} @@ -1373,9 +1842,21 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1398,9 +1879,31 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + js-md4@0.3.2: + resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-rpc-2.0@1.8.1: + resolution: {integrity: sha512-BmN42JKn6FI36pcMMzHCXQhpKNEfbeqgSIcUYHKnifq0/4Ey6PDinovwUOgajT7NsgUtpWdDXaCWlpI4LHXN3g==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} @@ -1433,6 +1936,9 @@ packages: resolution: {integrity: sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==} engines: {node: '>=22.13.0'} + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -1517,6 +2023,9 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1539,6 +2048,23 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mutation-server-protocol@0.4.1: + resolution: {integrity: sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==} + engines: {node: '>=18'} + + mutation-testing-elements@3.8.4: + resolution: {integrity: sha512-5CF1SNa7at5ZH33vEr+21wNebTSrtNIVvnzaUlxortHajOrIPaSLczIWvg6sI/fsExekQ7jookwf2cHftkckqQ==} + + mutation-testing-metrics@3.8.4: + resolution: {integrity: sha512-DZcmndJBH6nrNs3tpiB3OcMVq9KkG2cHCpJSnDxSxPwi9qrafRmoec40xjhkbzOoX1n7/4UkDqg5tIj4A6nvCw==} + + mutation-testing-report-schema@3.8.4: + resolution: {integrity: sha512-s4G71R6Lt/PpZ0cqeglIcgyBdzLM8E+SeCHZAPg1wkSsPtRBa4XfPzAozYKdiJk/TLbNEEb7En9t0/bveuPuxA==} + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1552,10 +2078,22 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-releases@2.0.59: + resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} + engines: {node: '>=18'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -1600,6 +2138,10 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1608,6 +2150,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -1640,6 +2186,14 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} + engines: {node: '>=18'} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1656,6 +2210,10 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -1672,6 +2230,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -1688,6 +2250,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -1699,6 +2264,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.17.2: resolution: {integrity: sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww==} engines: {node: '>= 0.8.0'} @@ -1777,6 +2347,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1831,6 +2405,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} @@ -1892,6 +2470,10 @@ packages: peerDependencies: tslib: '2' + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1899,6 +2481,10 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -1911,6 +2497,14 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} + typed-inject@5.0.0: + resolution: {integrity: sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==} + engines: {node: '>=18'} + + typed-rest-client@2.3.1: + resolution: {integrity: sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==} + engines: {node: '>= 16.0.0'} + typedoc@0.28.19: resolution: {integrity: sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==} engines: {node: '>= 18', pnpm: '>= 10'} @@ -1926,17 +2520,34 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@7.8.0: resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + verkit@0.5.0: + resolution: {integrity: sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==} + engines: {node: '>=18.12.0'} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2010,6 +2621,9 @@ packages: jsdom: optional: true + weapon-regex@2.0.6: + resolution: {integrity: sha512-iJC2vqYp+bh3r4X6zjuP0h65XqXjgUX3nJgmhj0PQMrdhAbUwCfN76CSTEEQqV3G7468kjOJ69PmytTG8l/8Gg==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2048,6 +2662,10 @@ packages: resolution: {integrity: sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==} engines: {node: '>= 4.0.0'} + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + zod@4.0.5: resolution: {integrity: sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==} @@ -2061,19 +2679,243 @@ snapshots: '@jridgewell/gen-mapping': 0.3.12 '@jridgewell/trace-mapping': 0.3.29 + '@babel/code-frame@8.0.6': + dependencies: + '@babel/helper-validator-identifier': 8.0.6 + js-tokens: 10.0.0 + + '@babel/compat-data@8.0.5': {} + + '@babel/core@8.0.7': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-compilation-targets': 8.0.7 + '@babel/helpers': 8.0.7 + '@babel/parser': 8.0.7 + '@babel/template': 8.0.0 + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.6 + '@types/gensync': 1.0.5 + convert-source-map: 2.0.0 + empathic: 2.1.0 + gensync: 1.0.0-beta.2 + import-meta-resolve: 4.2.0 + json5: 2.2.3 + obug: 3.0.0 + verkit: 0.5.0 + + '@babel/generator@8.0.6': + dependencies: + '@babel/parser': 8.0.7 + '@babel/types': 8.0.6 + '@jridgewell/gen-mapping': 0.4.0-beta.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@8.0.0': + dependencies: + '@babel/types': 8.0.6 + + '@babel/helper-compilation-targets@8.0.7': + dependencies: + '@babel/compat-data': 8.0.5 + '@babel/helper-validator-option': 8.0.0 + browserslist: 4.29.3 + flru: 1.0.2 + verkit: 0.5.0 + + '@babel/helper-create-class-features-plugin@8.0.7(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-member-expression-to-functions': 8.0.5 + '@babel/helper-optimise-call-expression': 8.0.0 + '@babel/helper-replace-supers': 8.0.1(@babel/core@8.0.7) + '@babel/helper-skip-transparent-expression-wrappers': 8.0.0 + '@babel/traverse': 8.0.7 + verkit: 0.5.0 + + '@babel/helper-globals@8.0.6': {} + + '@babel/helper-member-expression-to-functions@8.0.5': + dependencies: + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.6 + + '@babel/helper-module-imports@8.0.0': + dependencies: + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.6 + + '@babel/helper-module-transforms@8.0.6(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-module-imports': 8.0.0 + '@babel/helper-validator-identifier': 8.0.6 + '@babel/traverse': 8.0.7 + + '@babel/helper-optimise-call-expression@8.0.0': + dependencies: + '@babel/types': 8.0.6 + + '@babel/helper-plugin-utils@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + + '@babel/helper-replace-supers@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-member-expression-to-functions': 8.0.5 + '@babel/helper-optimise-call-expression': 8.0.0 + '@babel/traverse': 8.0.7 + + '@babel/helper-skip-transparent-expression-wrappers@8.0.0': + dependencies: + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.6 + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@8.0.6': {} + '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@8.0.6': {} + + '@babel/helper-validator-option@8.0.0': {} + + '@babel/helpers@8.0.7': + dependencies: + '@babel/template': 8.0.0 + '@babel/types': 8.0.6 + '@babel/parser@7.28.0': dependencies: '@babel/types': 7.28.1 + '@babel/parser@8.0.7': + dependencies: + '@babel/types': 8.0.6 + + '@babel/plugin-proposal-decorators@8.0.2(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-create-class-features-plugin': 8.0.7(@babel/core@8.0.7) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-syntax-decorators': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-syntax-decorators@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-syntax-jsx@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-syntax-typescript@8.0.3(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-transform-destructuring@8.0.5(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-transform-explicit-resource-management@8.0.5(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-transform-destructuring': 8.0.5(@babel/core@8.0.7) + + '@babel/plugin-transform-modules-commonjs@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-module-transforms': 8.0.6(@babel/core@8.0.7) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-transform-react-display-name@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-transform-react-jsx-development@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/plugin-transform-react-jsx': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-transform-react-jsx@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-module-imports': 8.0.0 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-syntax-jsx': 8.0.1(@babel/core@8.0.7) + '@babel/types': 8.0.6 + + '@babel/plugin-transform-react-pure-annotations@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-transform-typescript@8.0.6(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-create-class-features-plugin': 8.0.7(@babel/core@8.0.7) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/helper-skip-transparent-expression-wrappers': 8.0.0 + '@babel/plugin-syntax-typescript': 8.0.3(@babel/core@8.0.7) + + '@babel/preset-react@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/helper-validator-option': 8.0.0 + '@babel/plugin-transform-react-display-name': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-transform-react-jsx': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-transform-react-jsx-development': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-transform-react-pure-annotations': 8.0.1(@babel/core@8.0.7) + + '@babel/preset-typescript@8.0.7(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/helper-validator-option': 8.0.0 + '@babel/plugin-transform-modules-commonjs': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-transform-typescript': 8.0.6(@babel/core@8.0.7) + + '@babel/template@8.0.0': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/parser': 8.0.7 + '@babel/types': 8.0.6 + + '@babel/traverse@8.0.7': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-globals': 8.0.6 + '@babel/parser': 8.0.7 + '@babel/template': 8.0.0 + '@babel/types': 8.0.6 + obug: 3.0.0 + '@babel/types@7.28.1': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@babel/types@8.0.6': + dependencies: + '@babel/helper-string-parser': 8.0.6 + '@babel/helper-validator-identifier': 8.0.6 + '@bcoe/v8-coverage@1.0.2': {} '@esbuild/aix-ppc64@0.25.8': @@ -2164,6 +3006,125 @@ snapshots: '@hapi/bourne@3.0.0': {} + '@inquirer/ansi@2.0.9': {} + + '@inquirer/checkbox@5.2.6(@types/node@24.0.15)': + dependencies: + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.4(@types/node@24.0.15) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0(@types/node@24.0.15) + optionalDependencies: + '@types/node': 24.0.15 + + '@inquirer/confirm@6.3.3(@types/node@24.0.15)': + dependencies: + '@inquirer/core': 12.0.4(@types/node@24.0.15) + '@inquirer/type': 4.2.0(@types/node@24.0.15) + optionalDependencies: + '@types/node': 24.0.15 + + '@inquirer/core@12.0.4(@types/node@24.0.15)': + dependencies: + '@inquirer/ansi': 2.0.9 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0(@types/node@24.0.15) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 24.0.15 + + '@inquirer/editor@5.3.4(@types/node@24.0.15)': + dependencies: + '@inquirer/core': 12.0.4(@types/node@24.0.15) + '@inquirer/external-editor': 3.0.6(@types/node@24.0.15) + '@inquirer/type': 4.2.0(@types/node@24.0.15) + optionalDependencies: + '@types/node': 24.0.15 + + '@inquirer/expand@5.1.6(@types/node@24.0.15)': + dependencies: + '@inquirer/core': 12.0.4(@types/node@24.0.15) + '@inquirer/type': 4.2.0(@types/node@24.0.15) + optionalDependencies: + '@types/node': 24.0.15 + + '@inquirer/external-editor@3.0.6(@types/node@24.0.15)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 24.0.15 + + '@inquirer/figures@2.0.9': {} + + '@inquirer/input@5.1.7(@types/node@24.0.15)': + dependencies: + '@inquirer/core': 12.0.4(@types/node@24.0.15) + '@inquirer/type': 4.2.0(@types/node@24.0.15) + optionalDependencies: + '@types/node': 24.0.15 + + '@inquirer/number@4.2.4(@types/node@24.0.15)': + dependencies: + '@inquirer/core': 12.0.4(@types/node@24.0.15) + '@inquirer/type': 4.2.0(@types/node@24.0.15) + optionalDependencies: + '@types/node': 24.0.15 + + '@inquirer/password@5.2.3(@types/node@24.0.15)': + dependencies: + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.4(@types/node@24.0.15) + '@inquirer/type': 4.2.0(@types/node@24.0.15) + optionalDependencies: + '@types/node': 24.0.15 + + '@inquirer/prompts@8.7.3(@types/node@24.0.15)': + dependencies: + '@inquirer/checkbox': 5.2.6(@types/node@24.0.15) + '@inquirer/confirm': 6.3.3(@types/node@24.0.15) + '@inquirer/editor': 5.3.4(@types/node@24.0.15) + '@inquirer/expand': 5.1.6(@types/node@24.0.15) + '@inquirer/input': 5.1.7(@types/node@24.0.15) + '@inquirer/number': 4.2.4(@types/node@24.0.15) + '@inquirer/password': 5.2.3(@types/node@24.0.15) + '@inquirer/rawlist': 5.3.6(@types/node@24.0.15) + '@inquirer/search': 4.3.4(@types/node@24.0.15) + '@inquirer/select': 5.2.6(@types/node@24.0.15) + optionalDependencies: + '@types/node': 24.0.15 + + '@inquirer/rawlist@5.3.6(@types/node@24.0.15)': + dependencies: + '@inquirer/core': 12.0.4(@types/node@24.0.15) + '@inquirer/type': 4.2.0(@types/node@24.0.15) + optionalDependencies: + '@types/node': 24.0.15 + + '@inquirer/search@4.3.4(@types/node@24.0.15)': + dependencies: + '@inquirer/core': 12.0.4(@types/node@24.0.15) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0(@types/node@24.0.15) + optionalDependencies: + '@types/node': 24.0.15 + + '@inquirer/select@5.2.6(@types/node@24.0.15)': + dependencies: + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.4(@types/node@24.0.15) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0(@types/node@24.0.15) + optionalDependencies: + '@types/node': 24.0.15 + + '@inquirer/type@4.2.0(@types/node@24.0.15)': + optionalDependencies: + '@types/node': 24.0.15 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2180,15 +3141,27 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.4 '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping@0.4.0-beta.0': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.4': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.29': dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.4 + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': dependencies: tslib: 2.8.1 @@ -2221,16 +3194,16 @@ snapshots: '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) tslib: 2.8.1 - '@koa/router@13.1.1': + '@koa/router@13.1.1(supports-color@7.2.0)': dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) http-errors: 2.0.0 koa-compose: 4.1.0 path-to-regexp: 6.3.0 transitivePeerDependencies: - supports-color - '@leizm/web@2.7.3(@types/express@5.0.6)': + '@leizm/web@2.7.3(@types/express@5.0.6)(supports-color@7.2.0)': dependencies: '@types/body-parser': 1.19.6 '@types/busboy': 0.2.4 @@ -2240,7 +3213,7 @@ snapshots: '@types/mime': 2.0.3 '@types/qs': 6.14.0 '@types/send': 0.17.5 - body-parser: 1.20.3 + body-parser: 1.20.3(supports-color@7.2.0) busboy: 0.3.1 cookie: 0.4.2 cookie-parser: 1.4.7 @@ -2248,8 +3221,8 @@ snapshots: mime: 2.6.0 path-to-regexp: 6.3.0 qs: 6.14.0 - send: 0.17.2 - serve-static: 1.16.2 + send: 0.17.2(supports-color@7.2.0) + serve-static: 1.16.2(supports-color@7.2.0) transitivePeerDependencies: - '@types/express' - supports-color @@ -2433,6 +3406,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.45.1': optional: true + '@sec-ant/readable-stream@0.4.1': {} + '@shikijs/engine-oniguruma@3.23.0': dependencies: '@shikijs/types': 3.23.0 @@ -2453,6 +3428,74 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/merge-streams@4.0.0': {} + + '@stryker-mutator/api@10.0.0': + dependencies: + mutation-testing-metrics: 3.8.4 + mutation-testing-report-schema: 3.8.4 + tslib: 2.8.1 + typed-inject: 5.0.0 + + '@stryker-mutator/core@10.0.0(@types/node@24.0.15)': + dependencies: + '@inquirer/prompts': 8.7.3(@types/node@24.0.15) + '@stryker-mutator/api': 10.0.0 + '@stryker-mutator/instrumenter': 10.0.0 + '@stryker-mutator/util': 10.0.0 + ajv: 8.20.0 + chalk: 5.6.2 + commander: 14.0.3 + diff-match-patch: 1.0.5 + emoji-regex: 10.6.0 + execa: 9.6.1 + json-rpc-2.0: 1.8.1 + lodash.groupby: 4.6.0 + minimatch: 10.2.5 + mutation-server-protocol: 0.4.1 + mutation-testing-elements: 3.8.4 + mutation-testing-metrics: 3.8.4 + mutation-testing-report-schema: 3.8.4 + npm-run-path: 6.0.0 + progress: 2.0.3 + rxjs: 7.8.2 + semver: 7.7.2 + source-map: 0.7.6 + tree-kill: 1.2.2 + tslib: 2.8.1 + typed-inject: 5.0.0 + typed-rest-client: 2.3.1 + transitivePeerDependencies: + - '@types/node' + + '@stryker-mutator/instrumenter@10.0.0': + dependencies: + '@babel/core': 8.0.7 + '@babel/generator': 8.0.6 + '@babel/parser': 8.0.7 + '@babel/plugin-proposal-decorators': 8.0.2(@babel/core@8.0.7) + '@babel/plugin-transform-explicit-resource-management': 8.0.5(@babel/core@8.0.7) + '@babel/preset-react': 8.0.1(@babel/core@8.0.7) + '@babel/preset-typescript': 8.0.7(@babel/core@8.0.7) + '@babel/traverse': 8.0.7 + '@stryker-mutator/api': 10.0.0 + '@stryker-mutator/util': 10.0.0 + angular-html-parser: 10.11.0 + semver: 7.8.5 + tslib: 2.8.1 + weapon-regex: 2.0.6 + + '@stryker-mutator/util@10.0.0': {} + + '@stryker-mutator/vitest-runner@10.0.0(@stryker-mutator/core@10.0.0(@types/node@24.0.15))(vitest@3.2.4)': + dependencies: + '@stryker-mutator/api': 10.0.0 + '@stryker-mutator/core': 10.0.0(@types/node@24.0.15) + '@stryker-mutator/util': 10.0.0 + semver: 7.8.5 + tslib: 2.8.1 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@3.2.4)(supports-color@7.2.0)(yaml@2.9.0) + '@types/accepts@1.3.7': dependencies: '@types/node': 24.0.15 @@ -2514,6 +3557,8 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 + '@types/gensync@1.0.5': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -2522,6 +3567,8 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/jsesc@2.5.1': {} + '@types/keygrip@1.0.6': {} '@types/koa-bodyparser@4.3.12': @@ -2573,22 +3620,22 @@ snapshots: '@types/unist@3.0.3': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': + '@vitest/coverage-v8@3.2.4(supports-color@7.2.0)(vitest@3.2.4)': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 ast-v8-to-istanbul: 0.3.3 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 + istanbul-lib-source-maps: 5.0.6(supports-color@7.2.0) istanbul-reports: 3.1.7 magic-string: 0.30.17 magicast: 0.3.5 std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@3.2.4)(yaml@2.9.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@3.2.4)(supports-color@7.2.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -2637,7 +3684,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@3.2.4)(yaml@2.9.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@3.2.4)(supports-color@7.2.0)(yaml@2.9.0) '@vitest/utils@3.2.4': dependencies: @@ -2655,6 +3702,15 @@ snapshots: mime-types: 3.0.1 negotiator: 1.0.0 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + angular-html-parser@10.11.0: {} + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -2687,11 +3743,13 @@ snapshots: balanced-match@4.0.4: {} - body-parser@1.20.3: + baseline-browser-mapping@2.11.29: {} + + body-parser@1.20.3(supports-color@7.2.0): dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 2.6.9 + debug: 2.6.9(supports-color@7.2.0) depd: 2.0.0 destroy: 1.2.0 http-errors: 2.0.0 @@ -2704,11 +3762,11 @@ snapshots: transitivePeerDependencies: - supports-color - body-parser@2.3.0: + body-parser@2.3.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 content-type: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -2726,6 +3784,14 @@ snapshots: dependencies: balanced-match: 4.0.4 + browserslist@4.29.3: + dependencies: + baseline-browser-mapping: 2.11.29 + caniuse-lite: 1.0.30001816 + electron-to-chromium: 1.5.458 + node-releases: 2.0.59 + update-browserslist-db: 1.3.4(browserslist@4.29.3) + busboy@0.3.1: dependencies: dicer: 0.3.0 @@ -2749,6 +3815,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + caniuse-lite@1.0.30001816: {} + chai@5.2.1: dependencies: assertion-error: 2.0.1 @@ -2757,6 +3825,10 @@ snapshots: loupe: 3.1.4 pathval: 2.0.1 + chalk@5.6.2: {} + + chardet@2.2.0: {} + check-error@2.1.1: {} cli-cursor@5.0.0: @@ -2768,6 +3840,8 @@ snapshots: slice-ansi: 8.0.0 string-width: 8.2.1 + cli-width@4.1.0: {} + co-body@6.2.0: dependencies: '@hapi/bourne': 3.0.0 @@ -2782,6 +3856,8 @@ snapshots: color-name@1.1.4: {} + commander@14.0.3: {} + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -2792,6 +3868,8 @@ snapshots: content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-parser@1.4.7: dependencies: cookie: 0.7.2 @@ -2818,17 +3896,23 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - debug@2.6.9: + debug@2.6.9(supports-color@7.2.0): dependencies: ms: 2.0.0 + optionalDependencies: + supports-color: 7.2.0 - debug@4.4.1: + debug@4.4.1(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 deep-eql@5.0.2: {} @@ -2840,6 +3924,11 @@ snapshots: depd@2.0.0: {} + des.js@1.1.0: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + destroy@1.0.4: {} destroy@1.2.0: {} @@ -2848,6 +3937,8 @@ snapshots: dependencies: streamsearch: 0.1.2 + diff-match-patch@1.0.5: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2858,12 +3949,16 @@ snapshots: ee-first@1.1.1: {} + electron-to-chromium@1.5.458: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} + empathic@2.1.0: {} + encodeurl@1.0.2: {} encodeurl@2.0.0: {} @@ -2911,6 +4006,8 @@ snapshots: '@esbuild/win32-ia32': 0.25.8 '@esbuild/win32-x64': 0.25.8 + escalade@3.2.0: {} + escape-html@1.0.3: {} estree-walker@3.0.3: @@ -2921,22 +4018,37 @@ snapshots: eventemitter3@5.0.4: {} + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.1 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 + expect-type@1.2.2: {} - express@5.2.1: + express@5.2.1(supports-color@7.2.0): dependencies: accepts: 2.0.0 - body-parser: 2.3.0 + body-parser: 2.3.0(supports-color@7.2.0) content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@7.2.0) fresh: 2.0.0 http-errors: 2.0.0 merge-descriptors: 2.0.0 @@ -2947,24 +4059,42 @@ snapshots: proxy-addr: 2.0.7 qs: 6.14.0 range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 + router: 2.2.0(supports-color@7.2.0) + send: 1.2.1(supports-color@7.2.0) + serve-static: 2.2.1(supports-color@7.2.0) statuses: 2.0.2 type-is: 2.0.1 vary: 1.1.2 transitivePeerDependencies: - supports-color + fast-deep-equal@3.1.3: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.8: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fdir@6.4.6(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 fflate@0.8.2: {} - finalhandler@2.1.1: + figures@6.1.0: dependencies: - debug: 4.4.1 + is-unicode-supported: 2.1.0 + + finalhandler@2.1.1(supports-color@7.2.0): + dependencies: + debug: 4.4.1(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -2975,6 +4105,8 @@ snapshots: flatted@3.3.3: {} + flru@1.0.2: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -2991,6 +4123,8 @@ snapshots: function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} + get-east-asian-width@1.6.0: {} get-intrinsic@1.3.0: @@ -3011,6 +4145,11 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + glob@10.4.5: dependencies: foreground-child: 3.3.1 @@ -3061,6 +4200,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + human-signals@8.0.1: {} + husky@9.1.7: {} hyperdyperid@1.2.0: {} @@ -3073,6 +4214,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + import-meta-resolve@4.2.0: {} + inflation@2.1.0: {} inherits@2.0.4: {} @@ -3085,8 +4228,14 @@ snapshots: dependencies: get-east-asian-width: 1.6.0 + is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -3097,10 +4246,10 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@5.0.6: + istanbul-lib-source-maps@5.0.6(supports-color@7.2.0): dependencies: '@jridgewell/trace-mapping': 0.3.29 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -3116,8 +4265,20 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + js-md4@0.3.2: {} + + js-tokens@10.0.0: {} + js-tokens@9.0.1: {} + jsesc@3.1.0: {} + + json-rpc-2.0@1.8.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + keygrip@1.1.0: dependencies: tsscmp: 1.0.6 @@ -3130,23 +4291,23 @@ snapshots: koa-compose@4.1.0: {} - koa-router@13.1.1: + koa-router@13.1.1(supports-color@7.2.0): dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) http-errors: 2.0.0 koa-compose: 4.1.0 path-to-regexp: 6.3.0 transitivePeerDependencies: - supports-color - koa@3.0.0: + koa@3.0.0(supports-color@7.2.0): dependencies: accepts: 1.3.8 cache-content-type: 1.0.1 content-disposition: 0.5.4 content-type: 1.0.5 cookies: 0.9.1 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) delegates: 1.0.0 destroy: 1.2.0 encodeurl: 2.0.0 @@ -3184,6 +4345,8 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 10.0.0 + lodash.groupby@4.6.0: {} + log-update@6.1.0: dependencies: ansi-escapes: 7.3.0 @@ -3260,6 +4423,8 @@ snapshots: mimic-function@5.0.1: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -3276,14 +4441,37 @@ snapshots: ms@2.1.3: {} + mutation-server-protocol@0.4.1: + dependencies: + zod: 4.4.3 + + mutation-testing-elements@3.8.4: {} + + mutation-testing-metrics@3.8.4: + dependencies: + mutation-testing-report-schema: 3.8.4 + + mutation-testing-report-schema@3.8.4: {} + + mute-stream@3.0.0: {} + nanoid@3.3.11: {} negotiator@0.6.3: {} negotiator@1.0.0: {} + node-releases@2.0.59: {} + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + object-inspect@1.13.4: {} + obug@3.0.0: {} + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -3348,10 +4536,14 @@ snapshots: package-json-from-dist@1.0.1: {} + parse-ms@4.0.0: {} + parseurl@1.3.3: {} path-key@3.1.1: {} + path-key@4.0.0: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -3377,6 +4569,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-ms@9.3.1: + dependencies: + parse-ms: 4.0.0 + + progress@2.0.3: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -3392,6 +4590,10 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.15.1: + dependencies: + side-channel: 1.1.1 + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -3413,6 +4615,8 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + require-from-string@2.0.2: {} + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -3446,9 +4650,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.45.1 fsevents: 2.3.3 - router@2.2.0: + router@2.2.0(supports-color@7.2.0): dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -3456,15 +4660,21 @@ snapshots: transitivePeerDependencies: - supports-color + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} semver@7.7.2: {} - send@0.17.2: + semver@7.8.5: {} + + send@0.17.2(supports-color@7.2.0): dependencies: - debug: 2.6.9 + debug: 2.6.9(supports-color@7.2.0) depd: 1.1.2 destroy: 1.0.4 encodeurl: 1.0.2 @@ -3480,9 +4690,9 @@ snapshots: transitivePeerDependencies: - supports-color - send@0.19.0: + send@0.19.0(supports-color@7.2.0): dependencies: - debug: 2.6.9 + debug: 2.6.9(supports-color@7.2.0) depd: 2.0.0 destroy: 1.2.0 encodeurl: 1.0.2 @@ -3498,9 +4708,9 @@ snapshots: transitivePeerDependencies: - supports-color - send@1.2.1: + send@1.2.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -3514,21 +4724,21 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@1.16.2: + serve-static@1.16.2(supports-color@7.2.0): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 0.19.0 + send: 0.19.0(supports-color@7.2.0) transitivePeerDependencies: - supports-color - serve-static@2.2.1: + serve-static@2.2.1(supports-color@7.2.0): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -3603,6 +4813,8 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.7.6: {} + stackback@0.0.2: {} statuses@1.5.0: {} @@ -3652,6 +4864,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@4.0.0: {} + strip-literal@3.0.0: dependencies: js-tokens: 9.0.1 @@ -3697,10 +4911,14 @@ snapshots: dependencies: tslib: 2.8.1 + tree-kill@1.2.2: {} + tslib@2.8.1: {} tsscmp@1.0.6: {} + tunnel@0.0.6: {} + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -3718,6 +4936,16 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.1 + typed-inject@5.0.0: {} + + typed-rest-client@2.3.1: + dependencies: + des.js: 1.1.0 + js-md4: 0.3.2 + qs: 6.15.1 + tunnel: 0.0.6 + underscore: 1.13.8 + typedoc@0.28.19(typescript@5.8.3): dependencies: '@gerrit0/mini-shiki': 3.23.0 @@ -3731,16 +4959,28 @@ snapshots: uc.micro@2.1.0: {} + underscore@1.13.8: {} + undici-types@7.8.0: {} + unicorn-magic@0.3.0: {} + unpipe@1.0.0: {} + update-browserslist-db@1.3.4(browserslist@4.29.3): + dependencies: + browserslist: 4.29.3 + escalade: 3.2.0 + picocolors: 1.1.1 + vary@1.1.2: {} - vite-node@3.2.4(@types/node@24.0.15)(yaml@2.9.0): + verkit@0.5.0: {} + + vite-node@3.2.4(@types/node@24.0.15)(supports-color@7.2.0)(yaml@2.9.0): dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.0.5(@types/node@24.0.15)(yaml@2.9.0) @@ -3771,7 +5011,7 @@ snapshots: fsevents: 2.3.3 yaml: 2.9.0 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@3.2.4)(yaml@2.9.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@3.2.4)(supports-color@7.2.0)(yaml@2.9.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -3782,7 +5022,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.2.1 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) expect-type: 1.2.2 magic-string: 0.30.17 pathe: 2.0.3 @@ -3794,7 +5034,7 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 7.0.5(@types/node@24.0.15)(yaml@2.9.0) - vite-node: 3.2.4(@types/node@24.0.15)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.0.15)(supports-color@7.2.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -3814,6 +5054,8 @@ snapshots: - tsx - yaml + weapon-regex@2.0.6: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3853,6 +5095,8 @@ snapshots: ylru@1.4.0: {} + yoctocolors@2.2.0: {} + zod@4.0.5: {} zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fbbc863..93e188e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,5 @@ packages: - 'packages/erest-leizmweb' # codegen CLI 子包(从 Zod schema 生成 handler 骨架等) - 'packages/erest-gen' +allowBuilds: + esbuild: true diff --git a/src/lib/agent.ts b/src/lib/agent.ts index 42c470f..514dd77 100644 --- a/src/lib/agent.ts +++ b/src/lib/agent.ts @@ -31,6 +31,8 @@ interface AgentState { query: Record; body: unknown; form?: FormData; + /** attach() 收到的流字段,request() 时读尽为 Blob 再进 form */ + pendingStreams?: Array<[string, stream.Readable]>; /** 最终请求 URL 的 path(供 saveExample 记录) */ reqPath: string; } @@ -180,7 +182,9 @@ export class TestAgent { const form = this.state.form ?? new FormData(); for (const i in data) { if (data[i] instanceof stream.Readable) { - form.append(i, data[i] as never); + // 流不能直接 append(undici FormData 会把非 Blob 对象隐式 String()), + // 挂起并在 request() 中读尽为 Blob;同时不进入 agentInput(文档示例不序列化流) + (this.state.pendingStreams ??= []).push([i, data[i] as stream.Readable]); delete data[i]; } else { form.append(i, String(data[i])); @@ -227,6 +231,26 @@ export class TestAgent { let body: string | FormData | undefined; if (this.state.form) { + // GET/DELETE 不能携带 body(fetch 会直接抛错),attach() 只适用于 POST/PUT/PATCH + assert( + this.state.method !== "get" && this.state.method !== "delete", + `attach() 不支持 ${this.state.method.toUpperCase()} 请求(不能携带 body),请改用 POST/PUT/PATCH 或 input() 传 query` + ); + // 把挂起的流字段读尽为 Blob 再进 form(流本身不能作为 FormData 部件传输) + if (this.state.pendingStreams?.length) { + for (const [field, rs] of this.state.pendingStreams) { + const chunks: Buffer[] = []; + try { + for await (const chunk of rs) chunks.push(Buffer.from(chunk as Buffer)); + } catch (e) { + throw new Error(`attach() 读取流字段 "${field}" 失败:${e instanceof Error ? e.message : String(e)}`, { + cause: e, + }); + } + this.state.form.append(field, new Blob(chunks), field); + } + this.state.pendingStreams = []; + } body = this.state.form; // FormData 的 boundary 由 fetch 自动设置,不要手动 Content-Type } else if (this.state.body !== undefined && this.state.method !== "get" && this.state.method !== "delete") { @@ -250,6 +274,11 @@ export class TestAgent { res.headers.forEach((v, k) => { respHeaders[k] = v; }); + // 多个 Set-Cookie 响应头在 forEach 中会相互覆盖,用 getSetCookie() 取完整数组后合并 + const setCookies = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : []; + if (setCookies.length > 0) { + respHeaders["set-cookie"] = setCookies.join(", "); + } return { status: res.status, text, body: parsed, headers: respHeaders }; } diff --git a/src/lib/api.ts b/src/lib/api.ts index 1dd77a1..d392f63 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -375,7 +375,7 @@ class API { - const validated = ctx.$validated ?? { params: {}, query: {}, body: {}, headers: {} }; + const validated = (ctx.$validated ?? {}) as Partial>; // 组装类型安全的 req:缺失的层级补空对象,保证 handler 内字段访问安全。 // 此处运行时值已由 checker 校验,仅做拼装;类型断言对齐 handler 期望的推导类型。 diff --git a/src/lib/manager/error.ts b/src/lib/manager/error.ts index 393aa55..956f26f 100644 --- a/src/lib/manager/error.ts +++ b/src/lib/manager/error.ts @@ -38,7 +38,7 @@ export class ErrorManager extends Manager { assert(!this.codes.has(code), `code already exits: ${code}`); - debug("register: %s %j", name, error); + debug("register: %s %o", name, error); this.codes.add(code); this.map.set(name, { name, code, description, status, isDefault, isShow, isLog }); @@ -51,7 +51,12 @@ export class ErrorManager extends Manager { const old = this.map.get(name); assert(old?.isDefault, "only modify default error"); - if (data.code) this.codes.add(data.code); + if (data.code) { + // 换新 code:校验不与其他错误冲突(排除自身旧 code),并释放旧 code 使其可被再次注册 + assert(!this.codes.has(data.code) || data.code === old.code, `code already exits: ${data.code}`); + this.codes.delete(old.code); + this.codes.add(data.code); + } data.isDefault = false; this.map.set(name, Object.assign(old, data)); diff --git a/src/lib/plugin/generate_axios/index.ts b/src/lib/plugin/generate_axios/index.ts index 285c9e3..3238830 100644 --- a/src/lib/plugin/generate_axios/index.ts +++ b/src/lib/plugin/generate_axios/index.ts @@ -4,6 +4,7 @@ import { plugin as debug } from "../../debug.js"; import type { IDocData, IDocWritter } from "../../extend/docs.js"; import type { IDocOptions } from "../../index.js"; import * as utils from "../../utils.js"; +import { resolveZodShape } from "../zod-meta.js"; export default function generateAxios(data: IDocData, dir: string, options: IDocOptions, writter: IDocWritter) { debug("generateAxios: %s - %o", dir, options); @@ -25,8 +26,7 @@ export default function generateAxios(data: IDocData, dir: string, options: IDoc // Stage 1:从预编译的 Zod schema 提取字段名(替代旧 ISchemaType Record 的 keys) function schemaFieldNames(schema: unknown): string[] { if (!schema) return []; - const def = (schema as { _def?: { shape?: Record } })._def; - return def?.shape ? Object.keys(def.shape) : []; + return Object.keys(resolveZodShape(schema) ?? {}); } function getFuncParams(req: APIOption) { @@ -43,7 +43,8 @@ export default function generateAxios(data: IDocData, dir: string, options: IDoc function getReqSendPath(path: string) { if (hasUrlParam(path)) { - return `\`${path.replace(/:([a-z]+)/gi, "\\$\\{$1\\}")}\``; + // 生成模板字符串插值(注意:替换串不能带反斜杠转义,否则 \$ 使插值退化为字面量文本) + return `\`${path.replace(/:([a-z]+)/gi, "${$1}")}\``; } return `'${path}'`; } diff --git a/src/lib/plugin/generate_markdown/apis.ts b/src/lib/plugin/generate_markdown/apis.ts index 666f0a6..be668cc 100644 --- a/src/lib/plugin/generate_markdown/apis.ts +++ b/src/lib/plugin/generate_markdown/apis.ts @@ -93,7 +93,7 @@ export default function apiDocs(data: IDocData) { .map((item) => { const title = `// ${stringOrEmpty(item.name)} - ${item.path} `; const header = item.headers ? `\nheaders = ${jsonStringify(item.headers, 2)}\n` : ""; - const input = item.input && `input = ${jsonStringify(formatExampleInput(item.input), 2)};`; + const input = item.input ? `input = ${jsonStringify(formatExampleInput(item.input), 2)};` : ""; const outString = jsonStringify(item.output || {}, 2); const output = `output = ${outString};`; return `${title}\n${header}${input}\n${output}`.trim(); diff --git a/src/lib/plugin/generate_markdown/schema.ts b/src/lib/plugin/generate_markdown/schema.ts index 0176a07..21d62f2 100644 --- a/src/lib/plugin/generate_markdown/schema.ts +++ b/src/lib/plugin/generate_markdown/schema.ts @@ -1,6 +1,16 @@ import type { ZodType } from "zod"; import type { IDocData, IDocTypes } from "../../extend/docs.js"; import { isZodSchema } from "../../params.js"; +import { + getZodDefaultValue, + getZodElement, + getZodEnumValues, + getZodInner, + getZodLazyGetter, + getZodOptions, + getZodTypeName, + resolveZodShape, +} from "../zod-meta.js"; import { fieldString, itemTF, stringOrEmpty, tableHeader } from "./utils.js"; export default function schemaDocs(data: IDocData) { @@ -30,34 +40,17 @@ export default function schemaDocs(data: IDocData) { const tableHead = tableHeader(["字段", "类型", "备注", "格式化", "默认值", "必填", "参数"]); res.push(tableHead); - const typeName = - (zodSchema as ZodType & { _def: { typeName?: string; type?: string; shape?: Record } })._def - .typeName || - (zodSchema as ZodType & { _def: { typeName?: string; type?: string; shape?: Record } })._def - .type; + const typeName = getZodTypeName(zodSchema); // 处理lazy类型 - const typeValue = (zodSchema._def as { type?: string }).type; - if (typeName === "ZodLazy" || typeName === "lazy" || typeValue === "lazy") { - const getter = (zodSchema._def as unknown as { getter?: () => ZodType }).getter; + if (typeName === "ZodLazy" || typeName === "lazy") { + const getter = getZodLazyGetter(zodSchema); if (getter) { try { const innerSchema = getter(); - const innerTypeName = - (innerSchema._def as { typeName?: string; type?: string }).typeName || - (innerSchema._def as { typeName?: string; type?: string }).type; - if ( - (innerTypeName === "ZodObject" || innerTypeName === "object") && - (innerSchema as ZodType & { _def: { shape?: Record | (() => Record) } }) - ._def.shape - ) { - let shape = ( - innerSchema as ZodType & { _def: { shape: Record | (() => Record) } } - )._def.shape; - // 如果shape是函数,调用它获取实际的shape - if (typeof shape === "function") { - shape = shape(); - } - for (const [fieldName, fieldSchema] of Object.entries(shape as Record)) { + const innerTypeName = getZodTypeName(innerSchema); + if (innerTypeName === "ZodObject" || innerTypeName === "object") { + const shape = resolveZodShape(innerSchema); + for (const [fieldName, fieldSchema] of Object.entries(shape ?? {})) { const fieldInfo = extractZodFieldInfo(fieldName, fieldSchema); res.push( fieldString([ @@ -102,11 +95,24 @@ export default function schemaDocs(data: IDocData) { ); } } - } else if ( - (typeName === "ZodObject" || typeName === "object") && - (zodSchema as ZodType & { _def: { shape?: Record } })._def.shape - ) { - const shape = (zodSchema as ZodType & { _def: { shape: Record } })._def.shape; + } else if (typeName === "ZodObject" || typeName === "object") { + const shape = resolveZodShape(zodSchema); + if (!shape) { + // 缺失 shape 的 object:按非对象类型渲染单行 + const fieldInfo = extractZodFieldInfo(schemaName, zodSchema); + res.push( + fieldString([ + stringOrEmpty(schemaName), + stringOrEmpty(fieldInfo.type), + stringOrEmpty(fieldInfo.description), + itemTF(true), + stringOrEmpty(fieldInfo.defaultValue), + itemTF(fieldInfo.required), + stringOrEmpty(fieldInfo.params), + ]) + ); + return res.join("\n"); + } for (const [fieldName, fieldSchema] of Object.entries(shape)) { const fieldInfo = extractZodFieldInfo(fieldName, fieldSchema); res.push( @@ -160,11 +166,7 @@ export default function schemaDocs(data: IDocData) { info.description = zodSchemaWithDescription.description; } - const typeName = - (zodSchema._def as { typeName?: string; type?: string }).typeName || - (zodSchema._def as { typeName?: string; type?: string }).type; - - const typeValue = (zodSchema.def as { type?: string }).type; + const typeName = getZodTypeName(zodSchema); switch (typeName) { case "ZodString": case "string": @@ -188,8 +190,9 @@ export default function schemaDocs(data: IDocData) { break; case "ZodArray": case "array": { - const typeField = (zodSchema._def as unknown as { type?: ZodType }).type; - const innerType = typeField ? extractZodFieldInfo("", typeField) : { type: "unknown" }; + // 数组元素 schema 在 _def.element(Zod 3 与 Zod 4 同名;_def.type 在 Zod 4 是判别字符串) + const element = getZodElement(zodSchema); + const innerType = element ? extractZodFieldInfo("", element) : { type: "unknown" }; info.type = `${innerType.type}[]`; if (!info.description) info.description = "数组类型"; break; @@ -203,17 +206,16 @@ export default function schemaDocs(data: IDocData) { case "enum": info.type = "enum"; if (!info.description) info.description = "枚举类型"; - if ((zodSchema._def as { values?: unknown }).values) { - const values = (zodSchema._def as unknown as { values: unknown }).values; - info.params = Array.isArray(values) ? values.join(", ") : String(values); + { + // 枚举取值:Zod 3 用 _def.values,Zod 4 用 _def.entries(统一走 zod-meta 双轨) + const values = getZodEnumValues(zodSchema); + if (values) info.params = values.join(", "); } break; case "ZodOptional": case "optional": { - const innerInfo = extractZodFieldInfo( - fieldName, - (zodSchema._def as unknown as { innerType: ZodType }).innerType - ); + const inner = getZodInner(zodSchema); + const innerInfo = inner ? extractZodFieldInfo(fieldName, inner) : info; info.type = innerInfo.type; info.description = info.description || innerInfo.description; info.required = false; @@ -222,14 +224,12 @@ export default function schemaDocs(data: IDocData) { } case "ZodDefault": case "default": { - const defaultInnerInfo = extractZodFieldInfo( - fieldName, - (zodSchema._def as unknown as { innerType: ZodType }).innerType - ); + const inner = getZodInner(zodSchema); + const defaultInnerInfo = inner ? extractZodFieldInfo(fieldName, inner) : info; info.type = defaultInnerInfo.type; info.description = info.description || defaultInnerInfo.description; info.required = false; - const defValue = (zodSchema._def as { defaultValue?: unknown }).defaultValue; + const defValue = getZodDefaultValue(zodSchema); if (typeof defValue === "function") { try { info.defaultValue = String(defValue()); @@ -237,14 +237,15 @@ export default function schemaDocs(data: IDocData) { info.defaultValue = "[default value]"; } } else { - info.defaultValue = String(defValue || ""); + // ?? 而非 ||:0/false/"" 等 falsy 默认值也要如实展示 + info.defaultValue = String(defValue ?? ""); } info.params = defaultInnerInfo.params; break; } case "ZodUnion": case "union": { - const options = (zodSchema._def as unknown as { options: ZodType[] }).options; + const options = getZodOptions(zodSchema); if (options && options.length > 0) { const types = options.map((opt) => extractZodFieldInfo("", opt).type); info.type = types.join(" | "); @@ -256,25 +257,22 @@ export default function schemaDocs(data: IDocData) { } case "ZodLazy": case "lazy": { - // 检查是否为lazy类型 - if (typeValue === "lazy" || typeName === "ZodLazy" || typeName === "lazy") { - // 对于lazy类型,尝试获取内部的getter函数返回的schema - const getter = (zodSchema.def as unknown as { getter?: () => ZodType }).getter; - if (getter) { - try { - const innerSchema = getter(); - const innerInfo = extractZodFieldInfo(fieldName, innerSchema); - info.type = innerInfo.type; - info.description = info.description || innerInfo.description; - info.params = innerInfo.params; - } catch (_e) { - info.type = "lazy"; - if (!info.description) info.description = "延迟类型"; - } - } else { + // 对于lazy类型,尝试获取内部的getter函数返回的schema + const getter = getZodLazyGetter(zodSchema); + if (getter) { + try { + const innerSchema = getter(); + const innerInfo = extractZodFieldInfo(fieldName, innerSchema); + info.type = innerInfo.type; + info.description = info.description || innerInfo.description; + info.params = innerInfo.params; + } catch (_e) { info.type = "lazy"; if (!info.description) info.description = "延迟类型"; } + } else { + info.type = "lazy"; + if (!info.description) info.description = "延迟类型"; } break; } diff --git a/src/lib/plugin/generate_markdown/types.ts b/src/lib/plugin/generate_markdown/types.ts index c761248..3505f19 100644 --- a/src/lib/plugin/generate_markdown/types.ts +++ b/src/lib/plugin/generate_markdown/types.ts @@ -23,9 +23,6 @@ export default function typeDocs(data: IDocData) { } } - defaultTypes.sort(); - customTypes.sort(); - const typeList: string[] = []; const tableHead = tableHeader(["类型", "描述", "检查", "格式化", "解析"]); diff --git a/src/lib/plugin/generate_postman/index.ts b/src/lib/plugin/generate_postman/index.ts index c83e789..9f97775 100644 --- a/src/lib/plugin/generate_postman/index.ts +++ b/src/lib/plugin/generate_postman/index.ts @@ -2,7 +2,7 @@ import * as path from "node:path"; import { plugin as debug } from "../../debug.js"; import type { IDocData, IDocWritter } from "../../extend/docs.js"; import type { IDocOptions } from "../../index.js"; -import { extractDocFields, type DocField } from "../zod-meta.js"; +import { extractDocFields, type DocField, resolveZodShape } from "../zod-meta.js"; import * as utils from "../../utils.js"; /** 按 Zod 推断类型生成 Postman 示例占位值 */ @@ -121,7 +121,7 @@ export default function generatePostman(data: IDocData, dir: string, options: ID urlencoded: [], }; // Stage 1:从预编译的 Zod bodySchema 提取字段名 - const bodyShape = (item.bodySchema as { _def?: { shape?: Record } })?._def?.shape; + const bodyShape = resolveZodShape(item.bodySchema); if (bodyShape) { for (const sKey of Object.keys(bodyShape)) { req.request.body.urlencoded?.push({ diff --git a/src/lib/plugin/generate_swagger/index.ts b/src/lib/plugin/generate_swagger/index.ts index a477461..145ee9d 100644 --- a/src/lib/plugin/generate_swagger/index.ts +++ b/src/lib/plugin/generate_swagger/index.ts @@ -10,7 +10,7 @@ import { plugin as debug } from "../../debug.js"; import type { IDocData, IDocWritter } from "../../extend/docs.js"; import type { IDocOptions } from "../../index.js"; import { isZodSchema } from "../../params.js"; -import { extractDocFields } from "../zod-meta.js"; +import { extractDocFields, getZodInner, getZodTypeName, resolveZodShape } from "../zod-meta.js"; import * as utils from "../../utils.js"; type SCHEMA = "http" | "https" | "ws" | "wss"; @@ -101,15 +101,7 @@ function generateSwaggerSchemaDefinitions(data: IDocData, result: ISwaggerResult if (schemaRegistry && schemaRegistry.size > 0) { for (const [schemaName, zodSchema] of schemaRegistry.entries()) { if (isZodSchema(zodSchema)) { - const swaggerSchema = convertZodSchemaToSwagger(zodSchema); - // 确保对象类型能正确转换 - if (swaggerSchema.type === "object" && Object.keys(swaggerSchema.properties).length === 0) { - // 如果是空对象,尝试重新解析 - const reprocessedSchema = convertZodSchemaToSwagger(zodSchema); - result.definitions[schemaName] = reprocessedSchema; - } else { - result.definitions[schemaName] = swaggerSchema; - } + result.definitions[schemaName] = convertZodSchemaToSwagger(zodSchema); } } } @@ -142,12 +134,10 @@ function convertZodSchemaToSwagger(zodSchema: ZodType): ISwaggerModels { return swaggerSchema; } - const typeName = - (zodSchema._def as { typeName?: string; type?: string }).typeName || - (zodSchema._def as { typeName?: string; type?: string }).type; + const typeName = getZodTypeName(zodSchema); if (typeName === "ZodObject" || typeName === "object") { - const shape = (zodSchema as ZodType & { _def: { shape: Record } })._def.shape; + const shape = resolveZodShape(zodSchema) ?? {}; const requiredFields: string[] = []; for (const [fieldName, fieldSchema] of Object.entries(shape)) { @@ -179,7 +169,7 @@ function convertZodSchemaToSwagger(zodSchema: ZodType): ISwaggerModels { /** * 将 Zod 字段转换为 Swagger 属性 */ -function convertZodFieldToSwagger(zodSchema: ZodType): { +function convertZodFieldToSwagger(zodSchema: ZodType | undefined): { property: { type: string; format?: string; description: string }; required: boolean; } { @@ -192,9 +182,7 @@ function convertZodFieldToSwagger(zodSchema: ZodType): { return result; } - const typeName = - (zodSchema._def as { typeName?: string; type?: string }).typeName || - (zodSchema._def as { typeName?: string; type?: string }).type; + const typeName = getZodTypeName(zodSchema); switch (typeName) { case "ZodString": @@ -236,16 +224,14 @@ function convertZodFieldToSwagger(zodSchema: ZodType): { break; case "ZodOptional": case "optional": { - const innerInfo = convertZodFieldToSwagger((zodSchema._def as unknown as { innerType: ZodType }).innerType); + const innerInfo = convertZodFieldToSwagger(getZodInner(zodSchema)); result.property = innerInfo.property; result.required = false; break; } case "ZodDefault": case "default": { - const defaultInnerInfo = convertZodFieldToSwagger( - (zodSchema._def as unknown as { innerType: ZodType }).innerType - ); + const defaultInnerInfo = convertZodFieldToSwagger(getZodInner(zodSchema)); result.property = defaultInnerInfo.property; result.required = false; break; @@ -322,7 +308,7 @@ export function buildSwagger(data: IDocData) { const paths = result.paths as Record; for (const [key, api] of Object.entries(data.apis)) { - const newPath = utils.getRealPath(key).replace(/:(\w+)/, "{$1}"); + const newPath = utils.getRealPath(key).replace(/:(\w+)/g, "{$1}"); if (!paths[newPath]) { paths[newPath] = {}; } @@ -340,6 +326,7 @@ export function buildSwagger(data: IDocData) { (sc[api.method as string] as Record).parameters = []; const bodySchema: Record = {}; + const bodyRequired: string[] = []; let example = api.examples?.[0]; if (api.examples && api.examples.length > 1) { for (const item of api.examples) { @@ -385,9 +372,7 @@ export function buildSwagger(data: IDocData) { delete obj.name; delete obj.required; bodySchema[f.name] = obj; - if (f.required && Array.isArray(bodySchema.required)) { - bodySchema.required.push(f.name); - } + if (f.required) bodyRequired.push(f.name); } else { ((sc[api.method as string] as Record).parameters as unknown[]).push(obj); } @@ -396,7 +381,6 @@ export function buildSwagger(data: IDocData) { // POST 请求体汇总为单个 in:body 参数 if (api.method === "post" && api.bodySchema) { - const required = Object.keys(bodySchema).filter((k) => k !== "required"); ((sc[api.method as string] as Record).parameters as unknown[]).push({ in: "body", name: "body", @@ -405,9 +389,10 @@ export function buildSwagger(data: IDocData) { schema: { type: "object", properties: bodySchema, + // 字段级必填信息收集进 schema.required(JSON Schema 语义),不再丢弃 + ...(bodyRequired.length > 0 ? { required: bodyRequired } : {}), }, }); - void required; } } return result; diff --git a/src/lib/plugin/zod-meta.ts b/src/lib/plugin/zod-meta.ts index 6cfb4cd..4004f7d 100644 --- a/src/lib/plugin/zod-meta.ts +++ b/src/lib/plugin/zod-meta.ts @@ -41,6 +41,10 @@ interface ZodDefLike { options?: ZodTypeAny[] | readonly ZodTypeAny[]; /** array 的元素 schema(两版同名) */ element?: ZodTypeAny; + /** ZodDefault 的默认值(两版同名) */ + defaultValue?: unknown; + /** ZodLazy 的 getter(两版同名) */ + getter?: () => ZodTypeAny; } /** 读取 Zod schema 的规范化类型名(兼容 Zod 3 的 typeName 与 Zod 4 的 type) */ @@ -58,6 +62,15 @@ export function getZodShape(schema: unknown): Record | undef return typeof shape === "function" ? undefined : shape; } +/** 解析 ZodObject 的 shape(函数形态会调用之),非 ZodObject 返回 undefined */ +export function resolveZodShape(schema: unknown): Record | undefined { + if (!schema || typeof schema !== "object") return undefined; + const def = (schema as { _def?: ZodDefLike })._def; + const shape = def?.shape; + if (!shape) return undefined; + return typeof shape === "function" ? shape() : shape; +} + /** 获取 ZodOptional/ZodDefault/ZodNullable 的内层 schema */ export function getZodInner(schema: ZodTypeAny): ZodTypeAny | undefined { if (!schema || typeof schema !== "object") return undefined; @@ -77,6 +90,37 @@ function getEnumEntries(def: ZodDefLike | undefined): string[] | undefined { return undefined; } +/** 读取 enum 取值(公开访问器:兼容 Zod 3 的 values 与 Zod 4 的 entries) */ +export function getZodEnumValues(schema: unknown): string[] | undefined { + if (!schema || typeof schema !== "object") return undefined; + return getEnumEntries((schema as { _def?: ZodDefLike })._def); +} + +/** 获取 ZodArray 的元素 schema */ +export function getZodElement(schema: unknown): ZodTypeAny | undefined { + if (!schema || typeof schema !== "object") return undefined; + return (schema as { _def?: ZodDefLike })._def?.element; +} + +/** 获取 ZodUnion 的分支列表 */ +export function getZodOptions(schema: unknown): ZodTypeAny[] | undefined { + if (!schema || typeof schema !== "object") return undefined; + const options = (schema as { _def?: ZodDefLike })._def?.options; + return Array.isArray(options) ? options : undefined; +} + +/** 获取 ZodDefault 的默认值(函数形态返回函数本身,由调用方决定如何展示) */ +export function getZodDefaultValue(schema: unknown): unknown { + if (!schema || typeof schema !== "object") return undefined; + return (schema as { _def?: ZodDefLike })._def?.defaultValue; +} + +/** 获取 ZodLazy 的 getter */ +export function getZodLazyGetter(schema: unknown): (() => ZodTypeAny) | undefined { + if (!schema || typeof schema !== "object") return undefined; + return (schema as { _def?: ZodDefLike })._def?.getter; +} + /** 文档字段元信息 */ export interface DocField { /** 字段名 */ diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 7037198..28ecb3d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -3,6 +3,7 @@ * @author Yourtion Guo */ import { resolve as pathResolve } from "node:path"; +import { fileURLToPath } from "node:url"; export interface ISupportMethds { get: T; @@ -25,8 +26,13 @@ export function getCallerSourceLine(dir: string): SourceResult { for (let line of stack) { line = line.trim(); if (line.replace(/\\/g, "/").indexOf(resolvedDir) !== -1) { - const s = line.match(/\((.*)\)\s*$/); - if (s) return { relative: s[1].slice(resolvedDir.length + 1), absolute: s[1] }; + // CJS 风格栈帧:`at fn (/path/file.ts:1:1)`(取括号内); + // ESM 栈帧无括号:`at file:///path/file.ts:1:1`(取行尾 URL,去 file:// 前缀统一为纯路径) + const s = line.match(/\((.*):\d+:\d+\)\s*$/) || line.match(/^(?:at\s+)?(\S+):\d+:\d+$/); + if (s) { + const absolute = s[1].startsWith("file://") ? fileURLToPath(s[1]) : s[1]; + return { relative: absolute.slice(resolvedDir.length + 1), absolute }; + } } } return { relative: undefined, absolute: undefined }; @@ -40,17 +46,7 @@ export function getSchemaKey(method: string, path: string, prefix?: string): str /** 返回安全的JSON字符串 */ export function jsonStringify(data: object, space: string | number) { - // const seen: any[] = []; - return JSON.stringify( - data, - (_, val) => { - if (!val || typeof val !== "object") return val; - // if (seen.indexOf(val) !== -1) return "[Circular]"; - // seen.push(val); - return val; - }, - space - ); + return JSON.stringify(data, undefined, space); } /** 获取路径 */ diff --git a/src/test/test-api.ts b/src/test/test-api.ts index 41ccf9d..7a200e2 100644 --- a/src/test/test-api.ts +++ b/src/test/test-api.ts @@ -1,6 +1,7 @@ import { vol } from "memfs"; import { vi } from "vitest"; import { z } from "zod"; +import { API, type SUPPORT_METHODS } from "../lib/api.js"; import { apiDelete } from "./helper"; import lib from "./lib"; @@ -250,3 +251,142 @@ describe("API 高级功能和错误处理测试", () => { }); }); }); + +describe("API 构造与入参校验", () => { + const SRC = { absolute: "/virtual/test-api.ts", relative: "test-api.ts" }; + + test("构造函数应校验 method/path/prefix 入参", () => { + expect(() => new API(123 as unknown as string, "/p", SRC)).toThrow("`method`必须是字符串类型"); + expect(() => new API("bad" as SUPPORT_METHODS, "/p", SRC)).toThrow("`method`必须是以下请求方法中的一个"); + expect(() => new API("get", 123 as unknown as string, SRC)).toThrow("`path`必须是字符串类型"); + expect(() => new API("get", "p", SRC)).toThrow('`path`必须以"/"开头'); + expect(() => new API("get", "/p", SRC, undefined, "pp")).toThrow('`prefix`必须以"/"开头'); + }); + + test("构造函数应归一化 method 大小写并生成 key/realPath", () => { + const a = new API("GET", "/p1", SRC); + expect(a.options.method).toBe("get"); + expect(a.key).toBe("GET_/p1"); + expect(a.options.realPath).toBe("/p1"); + expect(a.options.group).toBe(""); + expect(a.inited).toBe(false); + expect(a.pathTestRegExp.test("/p1")).toBe(true); + expect(a.pathTestRegExp.test("/other")).toBe(false); + }); + + test("pathTest 应同时匹配 method(大小写归一)与路径", () => { + const s = lib().api.get("/pt-check"); + expect(s.pathTest("get", "/pt-check")).toBe(true); + expect(s.pathTest("GET", "/pt-check")).toBe(true); + expect(s.pathTest("post", "/pt-check")).toBe(false); + expect(s.pathTest("get", "/pt-other")).toBe(false); + }); + + test("example 应校验 input/output 为对象", () => { + const s = new API("get", "/ex-validate", SRC); + expect(() => s.example({ input: "str" as unknown as Record, output: {} })).toThrow( + "`input`必须是一个对象" + ); + expect(() => s.example({ input: {} })).toThrow("`output`必须是一个对象"); + // 合法 example 正常入列 + s.example({ input: { a: 1 }, output: { b: 2 } }); + expect(s.options.examples).toEqual([{ input: { a: 1 }, output: { b: 2 } }]); + }); +}); + +describe("API define 全量选项归一化", () => { + test("define 应把全部选项归一化到 options", () => { + const erest = lib(); + const bodyS = z.object({ b: z.string() }); + const queryS = z.object({ q: z.number() }); + const paramsS = z.object({ p: z.string() }); + const headersS = z.object({ h: z.string() }); + const responseS = z.object({ r: z.boolean() }); + const midFn = () => {}; + const beforeFn = () => {}; + const handlerFn = () => {}; + const s = erest.api.define({ + method: "POST", + path: "/define-full", + title: "T", + description: "D", + group: "Index", + response: responseS, + body: bodyS, + query: queryS, + params: paramsS, + headers: headersS, + requiredOneOf: ["a", "b"], + middlewares: [midFn], + before: [beforeFn], + handler: handlerFn, + mock: { m: 1 }, + }); + expect(s.options.title).toBe("T"); + expect(s.options.description).toBe("D"); + expect(s.options.group).toBe("Index"); + expect(s.options.response).toBe(responseS); + expect(s.options.bodySchema).toBe(bodyS); + expect(s.options.querySchema).toBe(queryS); + expect(s.options.paramsSchema).toBe(paramsS); + expect(s.options.headersSchema).toBe(headersS); + expect(s.options.requiredOneOf).toEqual([["a", "b"]]); + expect(Array.from(s.options.middlewares)).toEqual([midFn]); + expect(Array.from(s.options.beforeHooks)).toEqual([beforeFn]); + expect(s.options.handler).toBe(handlerFn); + expect(s.options.mock).toEqual({ m: 1 }); + }); + + test("define 仅传必填项时应保持各字段为空", () => { + const erest = lib(); + const handlerFn = () => {}; + const s = erest.api.define({ + method: "GET", + path: "/define-min", + title: "T2", + handler: handlerFn, + middlewares: [], + before: [], + }); + expect(s.options.description).toBeUndefined(); + expect(s.options.response).toBeUndefined(); + expect(s.options.bodySchema).toBeUndefined(); + expect(s.options.paramsSchema).toBeUndefined(); + expect(s.options.headersSchema).toBeUndefined(); + expect(s.options.requiredOneOf).toEqual([]); + expect(s.options.mock).toBeUndefined(); + expect(Array.from(s.options.middlewares)).toEqual([]); + expect(Array.from(s.options.beforeHooks)).toEqual([]); + }); +}); + +describe("API registerTyped 包装 handler 行为", () => { + const SRC = { absolute: "/virtual/test-api.ts", relative: "test-api.ts" }; + + test("handler 返回 undefined 时不应触发 response schema 校验", async () => { + const rt = new API("get", "/rt-no-return", SRC); + rt.group("Index"); + rt.registerTyped({ response: z.object({ ok: z.boolean() }) }, () => { + // 返回 undefined:response schema 不应 parse(undefined) + }); + const ctx: Record = {}; + await (rt.options.handler as unknown as (c: unknown) => Promise)(ctx); + expect(ctx.__returned).toBe(true); + expect(ctx.__returnValue).toBeUndefined(); + }); + + test("$validated 部分层级缺失时应补空对象", async () => { + const rt2 = new API("get", "/rt-layers", SRC); + rt2.group("Index"); + let captured: Record = {}; + rt2.registerTyped({}, (req) => { + captured = req as unknown as Record; + }); + const ctx2 = { $validated: { params: { id: 7 }, query: { q: "s" } } }; + await (rt2.options.handler as unknown as (c: unknown) => Promise)(ctx2); + expect(captured.params).toEqual({ id: 7 }); + expect(captured.query).toEqual({ q: "s" }); + expect(captured.body).toEqual({}); + expect(captured.headers).toEqual({}); + }); +}); diff --git a/src/test/test-docs-plugins.ts b/src/test/test-docs-plugins.ts index 610ab5f..996a699 100644 --- a/src/test/test-docs-plugins.ts +++ b/src/test/test-docs-plugins.ts @@ -6,8 +6,11 @@ * 回归:generate_postman 此前误用 options.swagger 作为文件名,导致 postman.json * 内容被写入 swagger.json(与 swagger 插件冲突、互相覆盖)。 */ +import * as path from "node:path"; +import Debug from "debug"; import { describe, expect, test } from "vitest"; import { z } from "zod"; +import { plugin } from "../lib/debug"; import generateAxios from "../lib/plugin/generate_axios"; import generatePostman from "../lib/plugin/generate_postman"; import generateSwagger, { buildSwagger } from "../lib/plugin/generate_swagger"; @@ -144,3 +147,992 @@ describe("postman response 示例(issue #6)", () => { expect(item.response).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// 以下为内容级精确断言测试(原 test-mutation-swagger-postman.ts 迁入): +// 通过 crafted docData 直接驱动 buildSwagger / generatePostman,断言生成的完整结构。 +const BASE_INFO = { title: "测试标题", description: "测试描述", host: "http://x", basePath: "" }; + +/** 构造最小 docData(插件实际只读少量字段,直接构造可精确控制输入) */ +function makeDocData(over: Record = {}): any { + return { + info: { ...BASE_INFO }, + group: {}, + apis: {}, + types: {}, + ...over, + }; +} + +/** 构造单个 API 条目 */ +function makeApi(api: Record): any { + return { method: "get", path: "/t", realPath: "/t", group: "G", title: "t", ...api }; +} + +/** 捕获 writer 输出 */ +function capture(): { writer: any; out: Array<{ path: string; data: string }> } { + const out: Array<{ path: string; data: string }> = []; + const writer = (p: string, data: string) => out.push({ path: p, data }); + return { writer, out }; +} + +/** 直接生成 postman collection JSON */ +function genPostman(data: any, options: any = { postman: "postman.json" }): any { + const { writer, out } = capture(); + generatePostman(data, "/out", options, writer); + expect(out).toHaveLength(1); + return JSON.parse(out[0].data); +} + +describe("swagger definitions:types 注册表(convertTypeToSwaggerType)", () => { + const data = makeDocData({ + info: { title: "t", description: "d", host: "http://x", basePath: "" }, + types: { + StrArr: { tsType: "string[]" }, + Num: { tsType: "number" }, + Bool: { tsType: "boolean" }, + DateArr: { tsType: "Date[]" }, + Obj: { tsType: "object" }, + Unk: { tsType: "unknown" }, + UnkArr: { tsType: "unknown[]" }, + NoTsType: {}, + WithDesc: { tsType: "string", description: "自定义说明" }, + } as any, + }); + const result = buildSwagger(data); + + test("各 tsType 到 swagger type 的映射顺序", () => { + expect(result.definitions.StrArr.type).toBe("string"); + expect(result.definitions.Num.type).toBe("number"); + expect(result.definitions.Bool.type).toBe("boolean"); + // Date 优先于数组判断:Date[] 仍应映射为 string + expect(result.definitions.DateArr.type).toBe("string"); + expect(result.definitions.Obj.type).toBe("object"); + // 未识别类型默认 string + expect(result.definitions.Unk.type).toBe("string"); + expect(result.definitions.UnkArr.type).toBe("array"); + }); + + test("definitions 完整键集合", () => { + expect(Object.keys(result.definitions)).toEqual([ + "StrArr", + "Num", + "Bool", + "DateArr", + "Obj", + "Unk", + "UnkArr", + "NoTsType", + "WithDesc", + ]); + }); + + test("带 description 的类型应输出 description 字段", () => { + expect(result.definitions.WithDesc).toStrictEqual({ + type: "string", + properties: {}, + description: "自定义说明", + }); + }); + + test("无 description 的类型不应有 description 键", () => { + expect(result.definitions.NoTsType).toStrictEqual({ type: "string", properties: {} }); + expect(result.definitions.Unk).toStrictEqual({ type: "string", properties: {} }); + }); + + test("types 为 undefined 时不生成 definitions 且不抛错", () => { + const r = buildSwagger(makeDocData({ types: undefined as any })); + expect(r.definitions).toEqual({}); + }); +}); + +describe("swagger definitions:schema 注册表与反射分支", () => { + const byObj = z.object({ + s: z.string(), + n: z.number(), + b: z.boolean(), + d: z.date(), + a: z.array(z.string()), + o: z.object({}), + e: z.enum(["x", "y"]), + opt: z.string().optional(), + def: z.string().default("v"), + uni: z.union([z.string(), z.number()]), + rec: z.record(z.string()), + nl: z.string().nullable(), + }); + + test("erest.schemaRegistry(Map)直接读取:Zod 对象转 definitions", () => { + const r = buildSwagger( + makeDocData({ + erest: { + schemaRegistry: new Map([ + ["ByObj", byObj], + ["ByEmpty", z.object({})], + ["ByStr", z.string()], + ["ByUndef", z.object({ x: undefined as any })], + ["Junk", 42 as any], + ]), + } as any, + }) + ); + expect(Object.keys(r.definitions)).toEqual(["ByObj", "ByEmpty", "ByStr", "ByUndef"]); + + expect(r.definitions.ByObj).toEqual({ + type: "object", + properties: { + s: { type: "string", description: "字符串类型" }, + n: { type: "number", description: "数字类型" }, + b: { type: "boolean", description: "布尔类型" }, + d: { type: "string", format: "date-time", description: "日期类型" }, + a: { type: "array", description: "数组类型" }, + o: { type: "object", description: "对象类型" }, + e: { type: "string", description: "枚举类型" }, + opt: { type: "string", description: "字符串类型" }, + def: { type: "string", description: "字符串类型" }, + uni: { type: "string", description: "联合类型" }, + rec: { type: "string", description: "record 类型" }, + nl: { type: "string", description: "nullable 类型" }, + }, + required: ["s", "n", "b", "d", "a", "o", "e", "uni", "rec", "nl"], + }); + + // 空对象 schema:属性为空对象、required 为空数组 + expect(r.definitions.ByEmpty).toEqual({ type: "object", properties: {}, required: [] }); + + // 非对象顶层 schema:退化为 { type, properties:{}, description } + expect(r.definitions.ByStr).toEqual({ type: "string", properties: {}, description: "字符串类型" }); + + // shape 中值为 undefined 的字段:早退返回初始 property + expect(r.definitions.ByUndef).toEqual({ + type: "object", + properties: { x: { type: "string", description: "" } }, + required: ["x"], + }); + }); + + test("无任何 schema 注册时 definitions 为空且不抛错", () => { + const r = buildSwagger(makeDocData()); + expect(r.definitions).toEqual({}); + }); + + test("erest 缺失时通过 data.schema 反射找到 Map 注册表", () => { + const r = buildSwagger( + makeDocData({ + schema: { + note: "不是 Map", + reg: new Map([["ByReflection", z.object({ id: z.string() })]]), + } as any, + }) + ); + expect(r.definitions.ByReflection).toEqual({ + type: "object", + properties: { id: { type: "string", description: "字符串类型" } }, + required: ["id"], + }); + }); + + test("data.schema 为函数(非 object)时不走反射", () => { + const r = buildSwagger( + makeDocData({ + schema: Object.assign(() => {}, { + reg: new Map([["FnSchema", z.object({})]]), + }) as any, + }) + ); + expect(r.definitions).toEqual({}); + }); +}); + +describe("swagger responses.200(response200)", () => { + function buildWithApi(api: Record) { + return buildSwagger( + makeDocData({ + group: { G: "G" }, + apis: { "get_/resp": makeApi({ realPath: "/resp", ...api }) }, + }) + ); + } + const realPath = "/resp"; + + test("含必填/可选/enum/array/date 字段的完整 response schema", () => { + const r = buildWithApi({ + responseSchema: z.object({ + id: z.number(), + name: z.string().describe("名字"), + age: z.number().optional(), + tag: z.enum(["a", "b"]), + list: z.array(z.string()), + when: z.date(), + }), + }); + expect((r.paths as any)[realPath].get.responses).toEqual({ + 200: { + description: "请求成功", + schema: { + type: "object", + properties: { + id: { type: "number" }, + name: { type: "string", description: "名字" }, + age: { type: "number" }, + tag: { type: "string", enum: ["a", "b"] }, + list: { type: "array", items: { type: "string" } }, + when: { type: "date", format: "date-time" }, + }, + required: ["id", "name", "tag", "list", "when"], + }, + }, + }); + }); + + test("全可选字段时 schema 不含 required 键", () => { + const r = buildWithApi({ responseSchema: z.object({ a: z.string().optional() }) }); + expect((r.paths as any)[realPath].get.responses[200]).toStrictEqual({ + description: "请求成功", + schema: { type: "object", properties: { a: { type: "string" } } }, + }); + }); + + test("无 responseSchema 时仅 description", () => { + const r = buildWithApi({}); + expect((r.paths as any)[realPath].get.responses).toEqual({ 200: { description: "请求成功" } }); + }); +}); + +describe("swagger buildSwagger 顶层(info/schemes/tags/definitions/paths)", () => { + const data = makeDocData({ + info: { + title: "服务", + description: "说明", + version: "2.1.0", + host: "https://api.ex.com", + basePath: "/v2", + }, + group: { Beta: "贝塔", Alpha: "阿尔法" }, + apis: {}, + }); + + test("顶层字段精确断言", () => { + const r = buildSwagger(data); + expect(r.swagger).toBe("2.0"); + expect(r.info).toEqual({ title: "服务", description: "说明", version: "2.1.0" }); + expect(r.basePath).toBe("/v2"); + expect(r.schemes).toEqual(["https"]); + expect(r.definitions).toEqual({}); + expect(r.paths).toEqual({}); + }); + + test("tags 按名称排序(多分组、部分有序初始序)", () => { + const r = buildSwagger( + makeDocData({ + info: { title: "t", description: "d", host: "http://x", basePath: "" }, + group: { Bravo: "B", Alpha: "A", Delta: "D", Charlie: "C", Echo: "E" }, + }) + ); + expect(r.tags).toEqual([ + { name: "Alpha", description: "A" }, + { name: "Bravo", description: "B" }, + { name: "Charlie", description: "C" }, + { name: "Delta", description: "D" }, + { name: "Echo", description: "E" }, + ]); + }); + + test("host 为空时 basePath 承载完整 URL", () => { + const r = buildSwagger( + makeDocData({ + info: { title: "t", description: "d", host: "", basePath: "https://fallback.example.com/v3" }, + }) + ); + expect(r.basePath).toBe("/v3"); + expect(r.schemes).toEqual(["https"]); + }); + + test("version 缺省时回退 1.0.0", () => { + const r = buildSwagger( + makeDocData({ + info: { title: "t", description: "d", host: "http://x", basePath: "" }, + }) + ); + expect(r.info.version).toBe("1.0.0"); + }); + + test("host + basePath 组合解析 URL", () => { + const r = buildSwagger( + makeDocData({ + info: { title: "t", description: "d", host: "http://h:8080", basePath: "/api/v9" }, + }) + ); + expect(r.basePath).toBe("/api/v9"); + expect(r.schemes).toEqual(["http"]); + }); +}); + +describe("swagger paths 与参数提取", () => { + const apis: Record = { + "get_/users/:userId": makeApi({ + method: "get", + path: "/users/:userId", + realPath: "/users/:userId", + title: "查用户", + description: "按 ID 查询用户", + paramsSchema: z.object({ userId: z.string().describe("用户ID") }), + querySchema: z.object({ + q: z.string().optional().describe("关键词"), + full: z.string(), + when: z.date().optional(), + st: z.enum(["a", "b", "c"]), + tags: z.array(z.string()), + }), + examples: [{ input: { q: "搜索" } }], + responseSchema: z.object({ ok: z.boolean() }), + }), + }; + const r = buildSwagger(makeDocData({ group: { G: "G" }, apis })); + + test("路径参数占位符 :param 转 {param}", () => { + expect(Object.keys(r.paths as any)).toEqual(["/users/{userId}"]); + }); + + test("多参数路径的占位符全部转换(/:a/:b → /{a}/{b})", () => { + const multi = buildSwagger( + makeDocData({ + group: { G: "G" }, + apis: { + "delete_/org/:oid/user/:uid": makeApi({ + method: "delete", + path: "/org/:oid/user/:uid", + realPath: "/org/:oid/user/:uid", + title: "删成员", + }), + }, + }) + ); + expect(Object.keys(multi.paths as any)).toEqual(["/org/{oid}/user/{uid}"]); + }); + + test("GET 操作对象完整断言", () => { + const op = (r.paths as any)["/users/{userId}"].get; + expect(op.tags).toEqual(["G"]); + expect(op.summary).toBe("查用户"); + expect(op.description).toBe("按 ID 查询用户"); + expect(op.consumes).toEqual(["application/json"]); + expect(op.produces).toEqual(["application/json"]); + expect(op.responses).toEqual({ + 200: { + description: "请求成功", + schema: { + type: "object", + properties: { ok: { type: "boolean" } }, + required: ["ok"], + }, + }, + }); + }); + + test("params 字段:in=path、required=true、带注释", () => { + const op = (r.paths as any)["/users/{userId}"].get; + expect(op.parameters).toHaveLength(6); + expect(op.parameters[0]).toStrictEqual({ + name: "userId", + in: "path", + description: "用户ID", + required: true, + type: "string", + example: undefined, + }); + }); + + test("query 字段:required/可选/enum/array/date-format", () => { + const op = (r.paths as any)["/users/{userId}"].get; + expect(op.parameters[1]).toStrictEqual({ + name: "q", + in: "query", + description: "关键词", + required: false, + type: "string", + example: undefined, + }); + expect(op.parameters[2]).toStrictEqual({ + name: "full", + in: "query", + description: "", + required: true, + type: "string", + example: undefined, + }); + expect(op.parameters[3]).toStrictEqual({ + name: "when", + in: "query", + description: "", + required: false, + type: "date", + format: "date-time", + example: undefined, + }); + expect(op.parameters[4]).toStrictEqual({ + name: "st", + in: "query", + description: "", + required: true, + type: "string", + enum: ["a", "b", "c"], + example: undefined, + }); + expect(op.parameters[5]).toStrictEqual({ + name: "tags", + in: "query", + description: "", + required: true, + type: "array", + items: { type: "string" }, + example: undefined, + }); + }); + + test("无 description 的 API 回退空字符串", () => { + const rr = buildSwagger( + makeDocData({ + group: { G: "G" }, + apis: { "get_/plain": makeApi({ realPath: "/plain" }) }, + }) + ); + const op = (rr.paths as any)["/plain"].get; + expect(op.description).toBe(""); + expect(op.tags).toEqual(["G"]); + expect(op.consumes).toEqual(["application/json"]); + expect(op.produces).toEqual(["application/json"]); + }); + + test("同一路径多个方法共存(get + post)", () => { + const rr = buildSwagger( + makeDocData({ + group: { G: "G" }, + apis: { + "get_/things": makeApi({ method: "get", realPath: "/things", title: "列表" }), + "post_/things": makeApi({ method: "post", realPath: "/things", title: "新建" }), + }, + }) + ); + const p = (rr.paths as any)["/things"]; + expect(Object.keys(p)).toEqual(["get", "post"]); + expect(p.get.summary).toBe("列表"); + expect(p.post.summary).toBe("新建"); + }); + + test("GET 带 bodySchema 时不生成 in:body 聚合参数", () => { + const rr = buildSwagger( + makeDocData({ + group: { G: "G" }, + apis: { + "get_/search": makeApi({ + method: "get", + realPath: "/search", + bodySchema: z.object({ kw: z.string() }), + }), + }, + }) + ); + expect((rr.paths as any)["/search"].get.parameters).toEqual([]); + }); + + test("POST 无 bodySchema 时无 in:body 聚合参数", () => { + const rr = buildSwagger( + makeDocData({ + group: { G: "G" }, + apis: { "post_/ping": makeApi({ method: "post", realPath: "/ping" }) }, + }) + ); + expect((rr.paths as any)["/ping"].post.parameters).toEqual([]); + }); +}); + +describe("swagger examples 选择与 POST 请求体聚合", () => { + function postOp(apis: Record) { + const rr = buildSwagger(makeDocData({ group: { G: "G" }, apis })); + return (rr.paths as any)["/form"].post; + } + + test("单个 example:body 字段带 example 值", () => { + const op = postOp({ + "post_/form": makeApi({ + method: "post", + realPath: "/form", + bodySchema: z.object({ name: z.string().describe("名字"), age: z.number().optional() }), + examples: [{ input: { name: "小明", age: 18 }, output: { success: true } }], + }), + }); + expect(op.parameters).toEqual([ + { + in: "body", + name: "body", + description: "请求体", + required: true, + schema: { + type: "object", + properties: { + name: { description: "名字", type: "string", example: "小明" }, + age: { description: "", type: "number", example: 18 }, + }, + required: ["name"], + }, + }, + ]); + }); + + test("多个 example:优先选 output.success 的那个", () => { + const op = postOp({ + "post_/form": makeApi({ + method: "post", + realPath: "/form", + bodySchema: z.object({ name: z.string() }), + examples: [{ input: { name: "失败例" } }, { input: { name: "成功例" }, output: { success: true } }], + }), + }); + expect(op.parameters[0].schema.properties.name.example).toBe("成功例"); + }); + + test("example 无 input 时不抛错、example 值缺省", () => { + const op = postOp({ + "post_/form": makeApi({ + method: "post", + realPath: "/form", + bodySchema: z.object({ n: z.string() }), + examples: [{ output: {} }], + }), + }); + expect(op.parameters[0].schema.properties.n).toEqual({ description: "", type: "string" }); + }); +}); + +describe("swagger 落盘输出(generateSwagger 包装)", () => { + test("使用 options.swagger 作为文件名并以两空格缩进序列化", () => { + const { writer, out } = capture(); + generateSwagger(makeDocData(), "/out", { swagger: "custom-sw.json" } as any, writer); + expect(out).toHaveLength(1); + expect(out[0].path).toBe("/out/custom-sw.json"); + expect(out[0].data.startsWith('{\n "swagger": "2.0"')).toBe(true); + // 内容可解析回完整结构 + const parsed = JSON.parse(out[0].data); + expect(parsed.swagger).toBe("2.0"); + expect(parsed.info.title).toBe("测试标题"); + }); + + test("options.swagger 为 true 时使用默认文件名 swagger.json", () => { + const { writer, out } = capture(); + generateSwagger(makeDocData(), "/out", { swagger: true } as any, writer); + expect(out).toHaveLength(1); + expect(out[0].path).toBe("/out/swagger.json"); + }); +}); + +describe("postman 顶层 collection 结构", () => { + test("空 apis 时顶层字段精确断言", () => { + const pm = genPostman( + makeDocData({ + info: { title: "标题", description: "描述", host: "http://h:1", basePath: "/api" }, + }) + ); + expect(pm).toEqual({ + variables: [{ enabled: true, key: "HOST", value: "http://h:1/api", type: "text" }], + info: { + name: "标题", + _postman_id: "", + description: "描述", + schema: "https://schema.getpostman.com/json/collection/v2.0.0/collection.json", + }, + item: [], + }); + }); + + test("host 缺省时 HOST 变量仅含 basePath", () => { + const pm = genPostman(makeDocData({ info: { title: "t", description: "d", host: "", basePath: "/api" } })); + expect(pm.variables[0]).toEqual({ enabled: true, key: "HOST", value: "/api", type: "text" }); + }); +}); + +describe("postman 分组文件夹", () => { + test("声明的分组用描述名,未声明的分组自动创建并合并", () => { + const pm = genPostman( + makeDocData({ + group: { G: "G组" }, + apis: { + "get_/a": makeApi({ realPath: "/a", group: "G", title: "A" }), + "get_/h1": makeApi({ realPath: "/h1", group: "H", title: "H1" }), + "get_/h2": makeApi({ realPath: "/h2", group: "H", title: "H2" }), + }, + }) + ); + expect(pm.item).toHaveLength(2); + expect(pm.item[0].name).toBe("G组"); + expect(pm.item[0].item).toHaveLength(1); + expect(pm.item[0].item[0].name).toBe("A"); + // 未声明的 H 分组:单个文件夹聚合两个条目 + expect(pm.item[1].name).toBe("H"); + expect(pm.item[1].item).toHaveLength(2); + expect(pm.item[1].item.map((i: any) => i.name)).toEqual(["H1", "H2"]); + }); +}); + +describe("postman 请求项与 urlencoded body", () => { + const pm = genPostman( + makeDocData({ + group: { G: "G" }, + apis: { + "get_/users/:id": makeApi({ realPath: "/users/:id", title: "查用户" }), + "post_/form": makeApi({ + method: "post", + realPath: "/form", + title: "表单", + bodySchema: z.object({ name: z.string(), age: z.number() }), + }), + "put_/form2": makeApi({ + method: "put", + realPath: "/form2", + title: "更新", + bodySchema: z.object({ x: z.string() }), + }), + "post_/noform": makeApi({ method: "post", realPath: "/noform", title: "无体" }), + }, + }) + ); + const folders: Record = {}; + for (const f of pm.item) folders[f.name] = f; + + test("GET 请求项:url/method/header", () => { + const getItem = (folder: string, name: string) => folders[folder].item.find((i: any) => i.name === name); + const get = getItem("G", "查用户"); + expect(get).toEqual({ + name: "查用户", + request: { + url: "{{HOST}}/users/:id", + method: "GET", + header: [{ key: "Content-Type", value: "application/json" }], + }, + }); + }); + + test("POST:从 bodySchema 提取 urlencoded 字段", () => { + const post = folders["G"].item.find((i: any) => i.name === "表单"); + expect(post.request.body).toEqual({ + mode: "urlencoded", + urlencoded: [ + { key: "name", description: "" }, + { key: "age", description: "" }, + ], + }); + }); + + test("PUT 同样生成 urlencoded body", () => { + const put = folders["G"].item.find((i: any) => i.name === "更新"); + expect(put.request.method).toBe("PUT"); + expect(put.request.body).toEqual({ + mode: "urlencoded", + urlencoded: [{ key: "x", description: "" }], + }); + }); + + test("POST 无 bodySchema 时 body 为空 urlencoded 且不抛错", () => { + const noform = folders["G"].item.find((i: any) => i.name === "无体"); + expect(noform.request.body).toEqual({ mode: "urlencoded", urlencoded: [] }); + }); + + test("GET 不带 body 字段", () => { + const get = folders["G"].item.find((i: any) => i.name === "查用户"); + expect(get.request.body).toBeUndefined(); + }); +}); + +describe("postman response 示例(sampleValue 类型映射)", () => { + test("各类型字段生成对应占位值", () => { + const pm = genPostman( + makeDocData({ + group: { G: "G" }, + apis: { + "get_/resp": makeApi({ + realPath: "/resp", + responseSchema: z.object({ + id: z.number(), + on: z.boolean(), + tags: z.array(z.string()), + meta: z.object({}), + when: z.date(), + st: z.enum(["a", "b"]), + none: z.enum([]) as any, + name: z.string(), + }), + }), + }, + }) + ); + const item = pm.item[0].item[0]; + expect(item.response).toEqual([ + { + name: "成功", + status: "OK", + code: 200, + body: JSON.stringify( + { + id: 0, + on: false, + tags: [], + meta: {}, + when: "2024-01-01T00:00:00Z", + st: "a", + none: "", + name: "", + }, + null, + 2 + ), + }, + ]); + }); + + test("无 responseSchema 时无 response 字段", () => { + const pm = genPostman(makeDocData({ group: { G: "G" }, apis: { "get_/x": makeApi({ realPath: "/x" }) } })); + expect(pm.item[0].item[0].response).toBeUndefined(); + }); +}); + +describe("postman 落盘文件名与格式", () => { + test("使用 options.postman 作为文件名并以两空格缩进序列化", () => { + const { writer, out } = capture(); + generatePostman(makeDocData(), "/out", { postman: "custom-pm.json" } as any, writer); + expect(out).toHaveLength(1); + expect(out[0].path).toBe("/out/custom-pm.json"); + expect(out[0].data.startsWith('{\n "variables"')).toBe(true); + }); + + test("options.postman 为 true 时使用默认文件名 postman.json", () => { + const { writer, out } = capture(); + generatePostman(makeDocData(), "/out", { postman: true } as any, writer); + expect(out).toHaveLength(1); + expect(out[0].path).toBe("/out/postman.json"); + }); +}); + +/** 捕获 generateAxios 写入的文件名与内容 */ +function captureAxios( + apis: Record, + info: { host: string; basePath: string } = { host: "http://h", basePath: "/b" }, + options: Record = { axios: "sdk.js" } +) { + let file = ""; + let content = ""; + const writer = (p: string, data: string) => { + file = p; + content = data; + }; + generateAxios( + { info: { title: "T", ...info }, genTime: "GT", group: { G: "G" }, types: {}, apis } as any, + "/out", + options as any, + writer + ); + return { file, content }; +} + +/** 临时开启 debug 命名空间并捕获输出(结束后恢复) */ +function captureDebugLogs(namespace: string, logger: Debug.Debugger, run: () => void): string[] { + const lines: string[] = []; + const originalLog = (logger as unknown as { log: unknown }).log; + Debug.enable(namespace); + (logger as unknown as { log: (fmt: unknown, ...args: unknown[]) => void }).log = (...args: unknown[]) => { + lines.push(args.map((a) => String(a)).join(" ")); + }; + try { + run(); + } finally { + (logger as unknown as { log: unknown }).log = originalLog; + Debug.disable(); + } + return lines; +} + +describe("generateAxios 生成 SDK 内容", () => { + /** 覆盖:多方法(get/post/put/patch/delete)、query/body/params、多级路径、URL 参数、无 schema API */ + const apis = { + "get_/user/list": { + method: "get", + path: "/user/list", + realPath: "/user/list", + title: "用户列表", + group: "G", + querySchema: z.object({ page: z.number(), size: z.number() }), + }, + "post_/user/:id": { + method: "post", + path: "/user/:id", + realPath: "/user/:id", + title: "创建用户", + group: "G", + paramsSchema: z.object({ id: z.string() }), + bodySchema: z.object({ name: z.string(), age: z.number() }), + }, + "put_/order/:oid": { + method: "put", + path: "/order/:oid", + realPath: "/order/:oid", + title: "更新订单", + group: "G", + paramsSchema: z.object({ oid: z.string() }), + bodySchema: z.object({ sku: z.string(), num: z.number() }), + }, + "patch_/user/:id/name": { + method: "patch", + path: "/user/:id/name", + realPath: "/user/:id/name", + title: "改名", + group: "G", + bodySchema: z.object({ name: z.string() }), + }, + "delete_/health": { + method: "delete", + path: "/health", + realPath: "/health", + title: "健康检查", + group: "G", + }, + }; + + test("生成的 SDK 全文与期望精确一致", () => { + const { file, content } = captureAxios(apis); + expect(file).toBe(path.resolve("/out", "sdk.js")); + const expected = [ + "", + " // T GT", + " import axios from 'axios';", + ' const BASE = "http://h/b";', + "", + " const api = {", + " instance: axios.create({", + " baseURL: BASE,", + " }),", + " ", + " // 用户列表", + " getUserList(", + " ", + " { page, size }) {", + " return this.instance.get(", + " '/user/list'", + " , {", + " params: { page, size }", + " })", + " },", + "", + " // 创建用户", + " postUserId(", + " id,", + " { name, age }) {", + " return this.instance.post(", + " `/user/${id}`", + " , { name, age })", + " },", + "", + " // 更新订单", + " putOrderOid(", + " oid,", + " { sku, num }) {", + " return this.instance.put(", + " `/order/${oid}`", + " , { sku, num })", + " },", + "", + " // 改名", + " patchUserIdName(", + " ", + " { name }) {", + " return this.instance.patch(", + " `/user/${id}/name`", + " , { name })", + " },", + "", + " // 健康检查", + " deleteHealth(", + " ", + " ) {", + " return this.instance.delete(", + " '/health'", + " )", + " }", + " };", + "", + " export default api;", + " ", + ].join("\n"); + expect(content).toBe(expected); + }); + + test("关键片段:方法名 / URL / this.instance 调用 / import / BASE / export", () => { + const { content } = captureAxios(apis); + // 方法名:斜杠转驼峰(getUserList)、路径参数去冒号(postUserId) + expect(content).toContain("getUserList("); + expect(content).toContain("postUserId("); + expect(content).toContain("putOrderOid("); + expect(content).toContain("patchUserIdName("); + expect(content).toContain("deleteHealth("); + // this.instance. 调用 + expect(content).toContain("return this.instance.get("); + expect(content).toContain("return this.instance.post("); + expect(content).toContain("return this.instance.put("); + expect(content).toContain("return this.instance.patch("); + expect(content).toContain("return this.instance.delete("); + // 无 URL 参数的路径用单引号,含 :param 的用模板字符串 + expect(content).toContain(" '/user/list'"); + expect(content).toContain(" '/health'"); + expect(content).toContain(" `/user/${id}`"); + // 头尾结构 + expect(content).toContain(" import axios from 'axios';"); + expect(content).toContain(' const BASE = "http://h/b";'); + expect(content).toContain(" export default api;"); + }); + + test("host 为空时 BASE 仅保留 basePath", () => { + const { content } = captureAxios( + { "get_/ping": { method: "get", path: "/ping", realPath: "/ping", title: "Ping", group: "G" } }, + { host: "", basePath: "/api" } + ); + expect(content).toContain(' const BASE = "/api";'); + expect(content).toContain("getPing("); + }); + + test("options.axios 非 string 时默认文件名 jssdk.js", () => { + const { file } = captureAxios(apis, undefined, { axios: true }); + expect(file).toBe(path.resolve("/out", "jssdk.js")); + }); + + test("schema 为真值但无 _def 时安全退化为无参数(不抛错)", () => { + const fake = { + "get_/probe": { method: "get", path: "/probe", realPath: "/probe", title: "P", group: "G", querySchema: {} }, + }; + let content = ""; + expect(() => { + content = captureAxios(fake).content; + }).not.toThrow(); + expect(content).toContain("getProbe(\n \n ) {"); + expect(content).toContain(" '/probe'"); + }); + + test("路径中冒号后跟数字(如时间 9:30)不算 URL 参数,按字面量输出", () => { + const { content } = captureAxios({ + "get_/schedule/9:30": { + method: "get", + path: "/schedule/9:30", + realPath: "/schedule/9:30", + title: "时间", + group: "G", + }, + }); + expect(content).toContain("getSchedule/9:30("); + expect(content).toContain(" '/schedule/9:30'"); + expect(content).not.toContain("`/schedule/9:30`"); + }); + + test("debug 输出包含 generateAxios 前缀", () => { + const logs = captureDebugLogs("erest:plugin", plugin, () => { + captureAxios({ "get_/ping": { method: "get", path: "/ping", realPath: "/ping", title: "Ping", group: "G" } }); + }); + expect(logs.join("\n")).toContain("generateAxios:"); + }); +}); diff --git a/src/test/test-docs-zod.ts b/src/test/test-docs-zod.ts index 813260a..8496e2e 100644 --- a/src/test/test-docs-zod.ts +++ b/src/test/test-docs-zod.ts @@ -1,7 +1,14 @@ +import Debug from "debug"; +import { vol } from "memfs"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { z } from "zod"; -import IAPIDoc, { type IDocGeneratePlugin } from "../lib/extend/docs"; +import IAPIDoc, { type IDocGeneratePlugin, type IDocWritter } from "../lib/extend/docs"; +import apiDocs from "../lib/plugin/generate_markdown/apis"; +import errorDocs from "../lib/plugin/generate_markdown/errors"; +import generateMarkdown from "../lib/plugin/generate_markdown/index"; import schemaDocs from "../lib/plugin/generate_markdown/schema"; +import typeDocs from "../lib/plugin/generate_markdown/types"; +import { fieldString, tableHeader, trimSpaces } from "../lib/plugin/generate_markdown/utils"; import { buildSwagger } from "../lib/plugin/generate_swagger"; import lib from "./lib"; @@ -556,3 +563,1260 @@ describe("Zod Documentation Generation Tests", () => { }); }); }); + +/** 构造满足 isZodSchema 契约(_def + parse)的最小 fake schema */ +function fakeZod(def: Record) { + return { _def: def, def, parse: () => ({ ok: true }) } as any; +} + +/** 重置 lib 实例上的类型/Schema 注册表 */ +function resetRegistries() { + (app as any).typeRegistry = new Map(); + (app as any).schemaRegistry = new Map(); +} + +/** 构造带 forEach 的最小 errorManager fake */ +function fakeErrorManager(errors: Array>) { + return { forEach: (cb: (v: unknown) => void) => errors.forEach(cb) }; +} + +/** 构造 apiDocs 所需的最小 docData */ +function buildApiData( + apis: Record, + group: Record = { User: "用户组", Other: "其他" } +) { + return { + info: { title: "t", description: "d", host: "http://x", basePath: "/api" }, + group, + apis, + types: {}, + } as any; +} + +describe("Markdown 生成器 - utils 工具函数", () => { + test("trimSpaces 应将 2 个以上连续换行折叠为恰好 2 个、纯空白行折叠为空行", () => { + expect(trimSpaces("a\n\n\n\n\nb\n \n c")).toEqual("a\n\nb\n\n c"); + expect(trimSpaces("a\r\n\r\nb")).toEqual("a\n\nb"); + }); + + test("fieldString 应以“ | ”连接字段并去除首尾空白", () => { + expect(fieldString(["x", "y", "z"])).toEqual("x | y | z"); + expect(fieldString(["a", "", "c"])).toEqual("a | | c"); + }); + + test("tableHeader 应生成表头行 + 去尾竖线的分隔行", () => { + expect(tableHeader(["a", "b", "c"])).toEqual("a | b | c \n---|---|---"); + expect(tableHeader(["错误", "错误码"])).toEqual("错误 | 错误码 \n---|---"); + }); +}); + +describe("Markdown 生成器 - 错误文档(errors.ts)", () => { + test("应按错误码降序输出完整错误表格", () => { + const out = errorDocs({ + errorManager: fakeErrorManager([ + { name: "LOW", code: 5, description: "低", isShow: true, isLog: false }, + { name: "HIGH", code: 900, description: "高", isShow: false, isLog: true }, + ]), + } as any); + // 降序:HIGH(900) 在前,LOW(5) 在后;名称加反引号;是/否 由 itemTF 渲染 + expect(out).toEqual( + "# 错误类型\n" + + "错误 | 错误码 | 描述 | 显示 | 日志 \n" + + "---|---|---|---|---\n" + + "`HIGH` | 900 | 高 | 否 | 是\n" + + "`LOW` | 5 | 低 | 是 | 否" + ); + }); + + test("三个错误乱序注册时仍应严格按错误码降序排列", () => { + const out = errorDocs({ + errorManager: fakeErrorManager([ + { name: "ONE", code: 1, description: "一", isShow: true, isLog: false }, + { name: "HUNDRED", code: 100, description: "百", isShow: false, isLog: true }, + { name: "FIFTY", code: 50, description: "五十", isShow: true, isLog: true }, + ]), + } as any); + expect(out).toEqual( + "# 错误类型\n" + + "错误 | 错误码 | 描述 | 显示 | 日志 \n" + + "---|---|---|---|---\n" + + "`HUNDRED` | 100 | 百 | 否 | 是\n" + + "`FIFTY` | 50 | 五十 | 是 | 是\n" + + "`ONE` | 1 | 一 | 是 | 否" + ); + }); + + test("没有注册错误时应只输出标题与表头", () => { + const out = errorDocs({ errorManager: fakeErrorManager([]) } as any); + expect(out).toEqual("# 错误类型\n错误 | 错误码 | 描述 | 显示 | 日志 \n---|---|---|---|---"); + }); +}); + +describe("Markdown 生成器 - 类型文档(types.ts)", () => { + test("内置类型与自定义类型应分表输出,字段各列精确渲染", () => { + const out = typeDocs({ + types: { + Bt: { + name: "Bt", + description: "内置类型", + checker: "c1", + formatter: "", + parser: "p1", + isBuiltin: true, + isParamsRequired: false, + }, + Ct: { + name: "Ct", + description: "自定义类型", + checker: "", + formatter: "f1", + parser: "", + isBuiltin: false, + isParamsRequired: true, + }, + }, + } as any); + expect(out).toEqual( + "## 默认数据类型\n\n" + + "类型 | 描述 | 检查 | 格式化 | 解析 \n" + + "---|---|---|---|---\n" + + "`Bt` | 内置类型 | 是 | 否 | 是\n" + + "\n" + + "## 自定义数据类型\n\n" + + "类型 | 描述 | 检查 | 格式化 | 解析 \n" + + "---|---|---|---|---\n" + + "`Ct` | 自定义类型 | 否 | 是 | 否\n" + ); + }); + + test("未标记 isBuiltin 的类型全部落入自定义表", () => { + const out = typeDocs({ + types: { + X: { + name: "X", + description: "x", + checker: "", + formatter: "", + parser: "", + isBuiltin: false, + isParamsRequired: false, + }, + }, + } as any); + expect(out).not.toContain("`X` | x | 否 | 否 | 否\n\n## 默认数据类型"); + expect(out).toContain( + "## 自定义数据类型\n\n类型 | 描述 | 检查 | 格式化 | 解析 \n---|---|---|---|---\n`X` | x | 否 | 否 | 否\n" + ); + }); +}); + +describe("Markdown 生成器 - Schema 文档(schema.ts)", () => { + test.each([{ type: "object" }, { typeName: "ZodObject" }])( + "object schema 缺少 shape 时保留定义并回退为单行对象:%j", + (def) => { + const out = schemaDocs({ + types: {}, + schema: { registry: new Map([["IncompleteObject", fakeZod(def)]]) }, + } as any); + expect(out).toBe( + "# 数据类型\n\n## Schema定义\n## IncompleteObject\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "IncompleteObject | object | 对象类型 | 是 | | 是 |" + ); + } + ); + + test("真实 zod 4 schema:注册类型表 + 对象/标量/枚举/默认值/联合/懒加载全量渲染", () => { + resetRegistries(); + app.type.register("UserName", z.string().min(1)); + const userSchema = z.object({ + name: z.string(), + age: z.number().optional(), + vip: z.boolean(), + born: z.date(), + tags: z.array(z.string()), + meta: z.object({ k: z.string() }), + color: z.enum(["red", "green"]), + status: z.string().default("active"), + score: z.number().default(() => 0), + flag: z.boolean().default(false), + mix: z.union([z.string(), z.number()]), + note: z.string().describe("自定义备注"), + big: z.bigint(), + ref: z.lazy(() => z.string()), + boom: z.lazy(() => { + throw new Error("boom"); + }), + }); + app.schema.register("User", userSchema); + app.schema.register("JustString", z.string()); + app.schema.register( + "LazyObj", + z.lazy(() => + z.object({ + a: z.string(), + b: z.number().optional(), + }) + ) + ); + app.schema.register( + "LazyStr", + z.lazy(() => z.string()) + ); + app.schema.register( + "LazyThrow", + z.lazy(() => { + throw new Error("boom"); + }) + ); + + const doc = new IAPIDoc(app as any); + const out = schemaDocs(doc.buildDocData()); + + expect(out).toEqual( + "# 数据类型\n\n" + + "## 注册类型\n" + + "类型名 | TypeScript类型 | 描述 | 格式化 | 默认值 | 可选 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + // 未在 typeManager 注册的 tsType 渲染为小写锚点链接 + "UserName | [string](#string) | 字符串类型 | 是 | | 是 |\n" + + "User | [object](#object) | 对象类型 | 是 | | 是 |\n" + + "JustString | [string](#string) | 字符串类型 | 是 | | 是 |\n" + + "LazyObj | [unknown](#unknown) | Zod lazy 类型 | 是 | | 是 |\n" + + "LazyStr | [unknown](#unknown) | Zod lazy 类型 | 是 | | 是 |\n" + + "LazyThrow | [unknown](#unknown) | Zod lazy 类型 | 是 | | 是 |\n" + + "\n" + + "## Schema定义\n" + + "## User\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "name | string | 字符串类型 | 是 | | 是 |\n" + + // optional:类型穿透、必填为否、描述继承内层 + "age | number | 数字类型 | 是 | | 否 |\n" + + "vip | boolean | 布尔类型 | 是 | | 是 |\n" + + "born | Date | 日期类型 | 是 | | 是 |\n" + + // 数组元素在 _def.element(Zod 3/4 同名),渲染为 string[] + "tags | string[] | 数组类型 | 是 | | 是 |\n" + + "meta | object | 对象类型 | 是 | | 是 |\n" + + // 枚举取值走 zod-meta 双轨(Zod 4 entries / Zod 3 values) + "color | enum | 枚举类型 | 是 | | 是 | red, green\n" + + // default:默认值列输出 "active",必填为否 + "status | string | 字符串类型 | 是 | active | 否 |\n" + + // falsy 默认值(0/false)如实展示(?? 而非 ||) + "score | number | 数字类型 | 是 | 0 | 否 |\n" + + "flag | boolean | 布尔类型 | 是 | false | 否 |\n" + + "mix | string | number | 联合类型 | 是 | | 是 |\n" + + // describe 覆盖默认描述 + "note | string | 自定义备注 | 是 | | 是 |\n" + + // default 分支:去掉 Zod 前缀转小写 + "big | bigint | bigint 类型 | 是 | | 是 |\n" + + // 字段级 lazy:取内层类型 + "ref | string | 字符串类型 | 是 | | 是 |\n" + + // 字段级 lazy getter 抛错:退化为 lazy 类型 + "boom | lazy | 延迟类型 | 是 | | 是 |\n" + + "## JustString\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "JustString | string | 字符串类型 | 是 | | 是 |\n" + + // 顶层 lazy 包对象:展开内层对象字段 + "## LazyObj\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "a | string | 字符串类型 | 是 | | 是 |\n" + + "b | number | 数字类型 | 是 | | 否 |\n" + + // 顶层 lazy 包标量:单行渲染 + "## LazyStr\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "LazyStr | string | 字符串类型 | 是 | | 是 |\n" + + // 顶层 lazy getter 抛错:catch 分支 + "## LazyThrow\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "LazyThrow | lazy | 延迟类型 | 是 | | 是 |" + ); + }); + + test("Zod 3 风格 fake schema:typeName 双轨、enum values、函数默认值、shape 函数等分支", () => { + const hand: any = { + info: { title: "t", description: "d", host: "http://x", basePath: "/api" }, + genTime: "g", + group: {}, + types: { + // tsType 已在 typeManager 注册 → 渲染纯文本 + TPlain: { + name: "TPlain", + description: "已注册类型", + tsType: "string", + isDefaultFormat: true, + isParamsRequired: false, + }, + // tsType 未注册 → 渲染小写锚点链接(含去除 "[]") + TLink: { + name: "TLink", + description: "未注册类型", + tsType: "Custom[]", + isDefaultFormat: false, + isParamsRequired: true, + }, + // 无 tsType → 回退 unknown 链接 + TNoTs: { name: "TNoTs", description: "", isParamsRequired: false }, + }, + apis: {}, + typeManager: { has: (t: string) => t === "string" }, + // 无 erest 引用时,从 schema 管理器属性中扫描 Map 作为注册表 + schema: { + has: () => false, + registry: new Map([ + ["FakeZod3Str", fakeZod({ typeName: "ZodString" })], + [ + "FakeZod3Obj", + fakeZod({ + typeName: "ZodObject", + shape: { + f1: fakeZod({ typeName: "ZodNumber" }), + // 字段缺 _def → 走默认信息 + bad: { parse: () => ({}) }, + }, + }), + ], + ["FakeEnumValues", fakeZod({ type: "enum", values: ["v1", "v2", "v3"] })], + [ + "FakeDefaultFn", + fakeZod({ type: "default", innerType: fakeZod({ type: "string" }), defaultValue: () => "FNV" }), + ], + [ + "FakeDefaultThrow", + fakeZod({ + type: "default", + innerType: fakeZod({ type: "string" }), + defaultValue: () => { + throw new Error("d"); + }, + }), + ], + ["FakeZod3Lazy", fakeZod({ typeName: "ZodLazy", getter: () => fakeZod({ type: "string" }) })], + [ + "FakeZod3LazyObj", + fakeZod({ + typeName: "ZodLazy", + getter: () => fakeZod({ typeName: "ZodObject", shape: { z3: fakeZod({ typeName: "ZodBoolean" }) } }), + }), + ], + [ + "FakeLazyShapeFn", + fakeZod({ + type: "lazy", + getter: () => fakeZod({ type: "object", shape: () => ({ fn: fakeZod({ type: "date" }) }) }), + }), + ], + ["FakeLazyNoGetter", fakeZod({ type: "lazy" })], + [ + "FakeLazyThrow", + fakeZod({ + type: "lazy", + getter: () => { + throw new Error("x"); + }, + }), + ], + ["FakeArrayNoEl", fakeZod({ typeName: "ZodArray" })], + ["FakeUnionEmpty", fakeZod({ type: "union", options: [] })], + ["FakeZodCustom", fakeZod({ typeName: "ZodIntersection" })], + [ + "FakeLazyField", + fakeZod({ + type: "object", + shape: { + lz: fakeZod({ type: "lazy", getter: () => fakeZod({ type: "string" }) }), + lzthrow: fakeZod({ + typeName: "ZodLazy", + getter: () => { + throw new Error("y"); + }, + }), + lznog: fakeZod({ type: "lazy" }), + }, + }), + ], + // 不满足 isZodSchema(无 _def)→ 仅输出标题,无表格 + ["NoDefSchema", { parse: () => ({}) }], + ]), + }, + }; + + expect(schemaDocs(hand)).toEqual( + "# 数据类型\n\n" + + "## 注册类型\n" + + "类型名 | TypeScript类型 | 描述 | 格式化 | 默认值 | 可选 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "TPlain | string | 已注册类型 | 是 | | 是 |\n" + + "TLink | [Custom[]](#custom) | 未注册类型 | 否 | | 否 |\n" + + "TNoTs | [unknown](#unknown) | | 否 | | 是 |\n" + + "\n" + + "## Schema定义\n" + + "## FakeZod3Str\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "FakeZod3Str | string | 字符串类型 | 是 | | 是 |\n" + + "## FakeZod3Obj\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "f1 | number | 数字类型 | 是 | | 是 |\n" + + "bad | unknown | | 是 | | 是 |\n" + + // Zod 3 enum 用 _def.values → 参数列输出取值列表 + "## FakeEnumValues\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "FakeEnumValues | enum | 枚举类型 | 是 | | 是 | v1, v2, v3\n" + + // 函数默认值:调用后字符串化 + "## FakeDefaultFn\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "FakeDefaultFn | string | 字符串类型 | 是 | FNV | 否 |\n" + + // 函数默认值调用抛错:回退占位符 + "## FakeDefaultThrow\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "FakeDefaultThrow | string | 字符串类型 | 是 | [default value] | 否 |\n" + + // Zod 3 lazy(typeName) 包标量 + "## FakeZod3Lazy\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "FakeZod3Lazy | string | 字符串类型 | 是 | | 是 |\n" + + // Zod 3 lazy 包对象 → 展开内层字段 + "## FakeZod3LazyObj\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "z3 | boolean | 布尔类型 | 是 | | 是 |\n" + + // 内层 shape 为函数 → 调用后展开 + "## FakeLazyShapeFn\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "fn | Date | 日期类型 | 是 | | 是 |\n" + + // lazy 无 getter → 只输出标题与表头,无数据行 + "## FakeLazyNoGetter\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "## FakeLazyThrow\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "FakeLazyThrow | lazy | 延迟类型 | 是 | | 是 |\n" + + // 数组缺元素定义 → unknown[] + "## FakeArrayNoEl\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "FakeArrayNoEl | unknown[] | 数组类型 | 是 | | 是 |\n" + + // 空 options 联合 → 类型列输出 union + "## FakeUnionEmpty\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "FakeUnionEmpty | union | 联合类型 | 是 | | 是 |\n" + + // default 分支:typeName 去 Zod 前缀小写 + "## FakeZodCustom\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "FakeZodCustom | intersection | ZodIntersection 类型 | 是 | | 是 |\n" + + "## FakeLazyField\n" + + "字段 | 类型 | 备注 | 格式化 | 默认值 | 必填 | 参数 \n" + + "---|---|---|---|---|---|---\n" + + "lz | string | 字符串类型 | 是 | | 是 |\n" + + "lzthrow | lazy | 延迟类型 | 是 | | 是 |\n" + + "lznog | lazy | 延迟类型 | 是 | | 是 |\n" + + "## NoDefSchema" + ); + }); + + test("空注册表:仅输出一级标题,无注册类型/Schema 定义小节", () => { + const out = schemaDocs({ + info: { title: "t", description: "d", host: "http://x", basePath: "/api" }, + genTime: "g", + group: {}, + types: {}, + apis: {}, + typeManager: { has: () => false }, + schema: { has: () => false, registry: new Map() }, + } as any); + expect(out).toEqual("# 数据类型"); + expect(out).not.toContain("## 注册类型"); + expect(out).not.toContain("## Schema定义"); + }); +}); + +describe("Markdown 生成器 - API 文档(apis.ts)", () => { + test("全量字段接口:参数表(params/query/body/headers)、选填参数、字符串响应、使用示例", () => { + const data = buildApiData({ + "get_/user/:id": { + group: "User", + title: "获取用户", + method: "get", + path: "/user/:id", + realPath: "/api/user/:id", + description: " 第一行 \n 第二行 ", + tested: true, + requiredOneOf: [["a", "b"]], + paramsSchema: z.object({ id: z.number().describe("用户ID") }), + querySchema: z.object({ a: z.string().optional(), b: z.string().optional(), c: z.string() }), + bodySchema: z.object({ color: z.enum(["red", "blue"]).describe("颜色") }), + headersSchema: z.object({ "x-token": z.string() }), + response: "User[]", + examples: [ + { + name: "示例一", + path: "/api/user/1", + headers: { "x-token": "abc" }, + input: { id: 1, $secret: "hidden" }, + output: { ok: 1 }, + }, + { name: "示例二", path: "/api/user/2", input: { id: 2 } }, + ], + }, + }); + const { list, groupTitles } = apiDocs(data); + + expect(list).toHaveLength(1); + expect(list[0].name).toEqual("User"); + expect(list[0].content).toEqual( + "## 获取用户 ✅\n" + + "\n" + + "请求地址:**GET** `/api/user/:id`\n" + + // 多行描述逐行 trim + "第一行\n" + + "第二行\n" + + "\n" + + "### 参数:\n" + + "\n" + + "参数名 | 位置 | 类型 | 格式化 | 必填 | 说明 \n" + + "---|---|---|---|---|---\n" + + // 字段名反引号;describe 作为说明;必填“是” + "`id` | params | number | 否 | 是 | 用户ID\n" + + // requiredOneOf 命中 → “选填” + "`a` | query | string | 否 | 选填 | (无)\n" + + "`b` | query | string | 否 | 选填 | (无)\n" + + // 未命中 requiredOneOf → 普通必填 + "`c` | query | string | 否 | 是 | (无)\n" + + // enum:类型列 enum,说明追加取值 + "`color` | body | enum | 否 | 是 | 颜色 (red,blue)\n" + + "`x-token` | headers | string | 否 | 是 | (无)\n" + + "\n" + + "选填参数:\n" + + "\n" + + "- `a`, `b` 其中一个\n" + + "\n" + + // 字符串响应 → schema 锚点链接(去 "[]"、小写) + "### 返回结果:\n" + + "\n" + + "[User[]](/schema#user)\n" + + "\n" + + "### 使用示例:\n" + + "\n" + + "```javascript\n" + + "// 示例一 - /api/user/1 \n" + + "\n" + + 'headers = {\n "x-token": "abc"\n}\n' + + // $ 前缀键应从示例输入中剔除 + 'input = {\n "id": 1\n};\n' + + 'output = {\n "ok": 1\n};\n' + + "\n" + + // 无 headers 的示例:不输出 headers 行;无 output → 空对象 + "// 示例二 - /api/user/2 \n" + + 'input = {\n "id": 2\n};\n' + + "output = {};\n" + + "\n" + + "```" + ); + expect(groupTitles).toEqual({ + User: [" - GET `/user/:id` - 获取用户"], + Other: undefined, + }); + }); + + test("同组两个接口合并输出;无参数接口渲染“参数:无参数”与 zod 响应表", () => { + const data = buildApiData({ + "get_/user/:id": { + group: "User", + title: "获取用户", + method: "get", + path: "/user/:id", + realPath: "/api/user/:id", + tested: true, + requiredOneOf: [], + examples: [], + response: "User[]", + }, + "post_/plain": { + group: "User", + title: "无参数接口", + method: "post", + path: "/plain", + realPath: "/api/plain", + requiredOneOf: [], + tested: false, + examples: [], + response: z.object({ id: z.number(), name: z.string().optional() }), + }, + }); + const { list, groupTitles } = apiDocs(data); + + expect(list).toHaveLength(1); + expect(list[0].content).toEqual( + "## 获取用户 ✅\n\n" + + "请求地址:**GET** `/api/user/:id`\n\n" + + "参数:无参数\n\n" + + "### 返回结果:\n\n" + + "[User[]](/schema#user)\n\n" + + "## 无参数接口 ❌\n\n" + + "请求地址:**POST** `/api/plain`\n\n" + + "参数:无参数\n\n" + + "### 返回结果:\n\n" + + "参数名 | 类型 | 必填 | 说明 \n" + + "---|---|---|---\n" + + "`id` | number | 是 | (无)\n" + + "`name` | string | 否 | (无)" + ); + expect(groupTitles).toEqual({ + User: [" - GET `/user/:id` - 获取用户", " - POST `/plain` - 无参数接口"], + Other: undefined, + }); + }); + + test("非字符串且非 zod 的响应:不输出“返回结果”小节", () => { + const data = buildApiData({ + "get_/misc": { + group: "Other", + title: "杂项", + method: "delete", + path: "/misc", + realPath: "/api/misc", + tested: true, + requiredOneOf: [], + examples: [], + response: 123, + }, + }); + const { list } = apiDocs(data); + expect(list).toHaveLength(1); + expect(list[0].name).toEqual("Other"); + expect(list[0].content).toEqual("## 杂项 ✅\n\n请求地址:**DELETE** `/api/misc`\n\n参数:无参数"); + }); + + test("无响应接口:不输出“返回结果”小节", () => { + const data = buildApiData({ + "get_/none": { + group: "Other", + title: "无响应", + method: "get", + path: "/none", + realPath: "/api/none", + tested: true, + requiredOneOf: [], + examples: [], + }, + }); + const { list } = apiDocs(data); + expect(list[0].content).toEqual("## 无响应 ✅\n\n请求地址:**GET** `/api/none`\n\n参数:无参数"); + expect(list[0].content).not.toContain("返回结果"); + }); + + test("示例无 input 时不渲染 input 行;无 output 时输出空对象", () => { + const data = buildApiData({ + "get_/x": { + group: "Other", + title: "无输入", + method: "get", + path: "/x", + realPath: "/api/x", + tested: true, + requiredOneOf: [], + examples: [{ name: "只有名字", path: "/api/x" }], + }, + }); + const content = apiDocs(data).list[0].content; + expect(content).toContain("### 使用示例:\n\n```javascript\n// 只有名字 - /api/x "); + expect(content).not.toContain("input = "); + expect(content).toContain("output = {};"); + }); +}); + +describe("Markdown 生成器 - 总入口(index.ts)", () => { + const mdData = { + info: { title: "Demo", description: "描述", host: "http://127.0.0.1:3001", basePath: "/api" }, + genTime: "2026/1/1 00:00:00", + group: { UserGroup: "用户", EmptyGroup: "空组" }, + types: { + B1: { + name: "B1", + description: "内置", + isBuiltin: true, + checker: "chk", + formatter: "", + parser: "", + isParamsRequired: false, + }, + }, + apis: { + "get_/ping": { + group: "UserGroup", + title: "Ping", + method: "get", + path: "/ping", + realPath: "/api/ping", + tested: true, + requiredOneOf: [], + examples: [], + }, + }, + errorManager: { + forEach: (cb: (v: unknown) => void) => { + cb({ name: "BIG", code: 100, description: "大", isShow: false, isLog: true }); + }, + }, + schema: { has: () => false, registry: new Map() }, + typeManager: { has: () => false }, + } as any; + + test("wiki + index + all 全开:输出文件名与全部内容", () => { + const written: Array<[string, string]> = []; + generateMarkdown(mdData, "/out", { wiki: true, index: true, all: true } as any, (p, c) => written.push([p, c])); + + // 文件名:Home 特例大写,其余小写(含中文标题) + expect(written.map((w) => w[0])).toEqual([ + "/out/types.md", + "/out/errors.md", + "/out/schema.md", + "/out/index.md", + "/out/usergroup.md", + "/out/Home.md", + "/out/api文档-demo.md", + ]); + + const files: Record = Object.fromEntries(written); + + expect(files["/out/types.md"]).toEqual( + "## 默认数据类型\n\n类型 | 描述 | 检查 | 格式化 | 解析 \n---|---|---|---|---\n`B1` | 内置 | 是 | 否 | 否\n\n## 自定义数据类型\n\n类型 | 描述 | 检查 | 格式化 | 解析 \n---|---|---|---|---\n" + ); + expect(files["/out/errors.md"]).toEqual( + "# 错误类型\n错误 | 错误码 | 描述 | 显示 | 日志 \n---|---|---|---|---\n`BIG` | 100 | 大 | 否 | 是" + ); + expect(files["/out/schema.md"]).toEqual( + "# 数据类型\n\n## 注册类型\n类型名 | TypeScript类型 | 描述 | 格式化 | 默认值 | 可选 | 参数 \n---|---|---|---|---|---|---\nB1 | [unknown](#unknown) | 内置 | 否 | | 是 |" + ); + expect(files["/out/index.md"]).toEqual( + "# Demo\n\n描述\n\n测试服务器: http://127.0.0.1:3001/api\n\n生成时间: 2026/1/1 00:00:00\n\n文档列表:\n\n- [用户 ( UserGroup ) 相关文档](./usergroup.md)\n- [空组 ( EmptyGroup ) 相关文档](./emptygroup.md)" + ); + expect(files["/out/usergroup.md"]).toEqual( + "# 用户 ( UserGroup ) 相关文档\n\n## Ping ✅\n\n请求地址:**GET** `/api/ping`\n\n参数:无参数" + ); + // Home:wiki 前缀链接 + 分组内接口标题列表(空组无接口则无标题行) + expect(files["/out/Home.md"]).toEqual( + "# Demo\n\n描述\n\n测试服务器: http://127.0.0.1:3001/api\n\n生成时间: 2026/1/1 00:00:00\n\n文档列表:\n\n- [/user_group - 用户相关文档](wikiusergroup)\n - GET `/ping` - Ping\n- [/empty_group - 空组相关文档](wikiemptygroup)" + ); + // all-in-one:分组锚点 + 类型/错误小节 + expect(files["/out/api文档-demo.md"]).toEqual( + "# Demo\n\n描述\n\n测试服务器: http://127.0.0.1:3001/api\n\n生成时间: 2026/1/1 00:00:00\n\n文档列表:\n\n" + + "- [用户 ( UserGroup ) 相关](#usergroup)\n- [空组 ( EmptyGroup ) 相关](#emptygroup)\n- [类型相关文档](#types)\n- [错误信息文档](#errors)\n\n" + + '# 用户 ( UserGroup ) 相关文档\n\n## Ping ✅\n\n请求地址:**GET** `/api/ping`\n\n参数:无参数\n' + + '# 类型相关文档\n\n## 默认数据类型\n\n类型 | 描述 | 检查 | 格式化 | 解析 \n---|---|---|---|---\n`B1` | 内置 | 是 | 否 | 否\n\n## 自定义数据类型\n\n类型 | 描述 | 检查 | 格式化 | 解析 \n---|---|---|---|---\n\n' + + '# 错误信息文档\n\n# 错误类型\n错误 | 错误码 | 描述 | 显示 | 日志 \n---|---|---|---|---\n`BIG` | 100 | 大 | 否 | 是' + ); + }); + + test("SDK/Postman/Swagger/JSON 链接按开关输出", () => { + const written: Array<[string, string]> = []; + generateMarkdown( + mdData, + "/out", + { + wiki: false, + index: true, + all: false, + axios: "sdk.js", + postman: "p.json", + swagger: "s.json", + json: "doc.json", + } as any, + (p, c) => written.push([p, c]) + ); + expect(written.map((w) => w[0])).toEqual(["/out/index.md"]); + expect(written[0][1]).toEqual( + "# Demo\n\n描述\n\n测试服务器: http://127.0.0.1:3001/api\n\n生成时间: 2026/1/1 00:00:00\n\n" + + "JS-SDK( 基于 axios ):[jssdk.js](./jssdk.js)\n\n" + + "Postman 文件:[postman.json](./postman.json)\n\n" + + "Swagger 文件:[swagger.json](./swagger.json)\n\n" + + "JSON 描述文件:[doc.json](./doc.json)\n\n" + + "文档列表:\n\n- [用户 ( UserGroup ) 相关文档](./usergroup.md)\n- [空组 ( EmptyGroup ) 相关文档](./emptygroup.md)" + ); + }); + + test("仅开 index:不输出 wiki/all 相关文件", () => { + const written: Array<[string, string]> = []; + generateMarkdown(mdData, "/out2", { wiki: false, index: true, all: false } as any, (p, c) => written.push([p, c])); + expect(written.map((w) => w[0])).toEqual(["/out2/index.md"]); + expect(written[0][1]).not.toContain("jssdk.js"); + expect(written[0][1]).not.toContain("postman.json"); + expect(written[0][1]).not.toContain("swagger.json"); + expect(written[0][1]).not.toContain("doc.json"); + }); +}); + +// ---- extend/docs.ts 补测(buildDocData 组装 / genDocs 插件调度 / 落盘) ---- + +/** 快速构造 ERest 实例 + IAPIDoc */ +function makeDoc(options = {}) { + const svc = lib(options); + return { svc, doc: new IAPIDoc(svc as never) }; +} + +/** 捕获写盘的 writer */ +function captureWriter() { + const writes: Array<[string, string]> = []; + const writer: IDocWritter = (p, d) => writes.push([p, d]); + return { writes, writer }; +} + +/** 直接向实例的私有注册表注入伪造 schema(仅用于覆盖 _def 解析分支) */ +function injectType(svc: ReturnType, name: string, schema: unknown) { + (svc as unknown as { typeRegistry: Map }).typeRegistry.set(name, schema); +} + +/** 在 erest:docs debug 命名空间开启时执行 fn,捕获 stderr 输出(debug 引擎写 stderr) */ +function withDocsDebug(fn: () => void): string { + const chunks: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation(((chunk: Uint8Array | string) => { + chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); + return true; + }) as never); + Debug.enable("erest:docs"); + try { + fn(); + } finally { + Debug.disable(); + spy.mockRestore(); + } + return chunks.join(""); +} + +describe("extend/docs.ts: buildDocData 数据组装", () => { + test("genTime 为非空时间字符串", () => { + const { doc } = makeDoc({ docs: { markdown: false } }); + const d = doc.buildDocData(); + expect(typeof d.genTime).toBe("string"); + expect(d.genTime.length).toBeGreaterThan(0); + expect(d.genTime).toMatch(/\d/); + }); + + test("apis 结构:DOC_FIELD 全量拷贝(schema/示例/标题等按引用透传)", () => { + const { svc, doc } = makeDoc({ docs: { markdown: false } }); + const querySchema = z.object({ q: z.string() }); + const bodySchema = z.object({ b: z.number() }); + const paramsSchema = z.object({ id: z.string() }); + const headersSchema = z.object({ h: z.string() }); + const responseSchema = z.object({ ok: z.boolean() }); + const a = svc.api.get("/full").title("标题").description("描述"); + a.query(querySchema); + a.body(bodySchema); + a.params(paramsSchema); + a.headers(headersSchema); + a.response(responseSchema); + a.requiredOneOf(["q", "b"]); + a.example({ input: { q: "x" }, output: { ok: true } }); + + const d = doc.buildDocData(); + expect(Object.keys(d.apis)).toEqual(["GET_/full"]); + const o = d.apis["GET_/full"] as Record; + expect(o.method).toBe("get"); + expect(o.path).toBe("/full"); + expect(o.realPath).toBe("/full"); + expect(o.group).toBe(""); + expect(o.title).toBe("标题"); + expect(o.description).toBe("描述"); + expect(o.tested).toBe(false); + expect(o.querySchema).toBe(querySchema); + expect(o.bodySchema).toBe(bodySchema); + expect(o.paramsSchema).toBe(paramsSchema); + expect(o.headersSchema).toBe(headersSchema); + expect(o.response).toBe(responseSchema); + expect(o.requiredOneOf).toEqual([["q", "b"]]); + expect(o.middlewares).toBeInstanceOf(Set); + expect((o.middlewares as Set).size).toBe(0); + expect(o.examples).toEqual([{ input: { q: "x" }, output: { ok: true } }]); + }); + + test("未设置 docOutputFormat 时示例 output 原样保留,examples 缺失不报错", () => { + const { svc, doc } = makeDoc({ docs: { markdown: false } }); + svc.api.get("/plain").title("p"); + svc.api + .get("/ex") + .title("e") + .example({ input: {}, output: { v: 1 } }); + // 模拟极端情况:示例字段被外部删除 + const noEx = svc.api.get("/noex").title("n"); + noEx.example({ input: {}, output: {} }); + delete (svc.api.$apis.get("GET_/noex")!.options as Record).examples; + + expect(() => doc.buildDocData()).not.toThrow(); + const d = doc.buildDocData(); + expect((d.apis["GET_/plain"] as Record).examples).toEqual([]); + expect((d.apis["GET_/ex"] as Record).examples).toEqual([{ input: {}, output: { v: 1 } }]); + expect((d.apis["GET_/noex"] as Record).examples).toBeUndefined(); + }); + + test("自定义 docOutputFormat 应用于每个示例的 output", () => { + const { svc, doc } = makeDoc({ docs: { markdown: false } }); + svc.setDocOutputFormat((o) => ({ F: o })); + svc.api + .get("/f1") + .title("f1") + .example({ input: {}, output: { v: 1 } }); + svc.api + .get("/f2") + .title("f2") + .example({ input: {}, output: { w: 2 } }); + + const d = doc.buildDocData(); + const e1 = (d.apis["GET_/f1"] as Record).examples as Array>; + const e2 = (d.apis["GET_/f2"] as Record).examples as Array>; + expect(e1[0].output).toEqual({ F: { v: 1 } }); + expect(e2[0].output).toEqual({ F: { w: 2 } }); + }); + + test("示例列表中的非法条目(null / 字符串)被跳过且不影响其他条目格式化", () => { + const { svc, doc } = makeDoc({ docs: { markdown: false } }); + const api = svc.api.get("/weird").title("w"); + api.example({ input: {}, output: { ok: 1 } }); + (api.options as unknown as { examples: unknown[] }).examples.push(null, "not-an-object"); + svc.setDocOutputFormat((o) => ({ F: o })); + + const d = doc.buildDocData(); + const ex = (d.apis["GET_/weird"] as Record).examples as unknown[]; + expect(ex).toHaveLength(3); + expect((ex[0] as Record).output).toEqual({ F: { ok: 1 } }); + expect(ex[1]).toBeNull(); + expect(ex[2]).toBe("not-an-object"); + }); + + test("缓存:同一实例 buildDocData 返回同一对象,invalidateCache 后重建", () => { + const { svc, doc } = makeDoc({ docs: { markdown: false } }); + svc.api.get("/c").title("c"); + const d1 = doc.buildDocData(); + const d2 = doc.buildDocData(); + expect(d2).toBe(d1); + doc.invalidateCache(); + const d3 = doc.buildDocData(); + expect(d3).not.toBe(d1); + }); +}); + +describe("extend/docs.ts: 类型 / Schema 提取矩阵", () => { + test("真实 Zod schema 的 description 与 tsType 全类型矩阵", () => { + const { svc, doc } = makeDoc({ docs: { markdown: false } }); + injectType(svc, "TString", z.string()); + injectType(svc, "TNumber", z.number()); + injectType(svc, "TBoolean", z.boolean()); + injectType(svc, "TDate", z.date()); + injectType(svc, "TArray", z.array(z.string())); + injectType(svc, "TNumArray", z.array(z.number())); + injectType(svc, "TObject", z.object({ a: z.string() })); + injectType(svc, "TEnum", z.enum(["a", "b"])); + injectType(svc, "TNativeEnum", z.nativeEnum({ A: 1, B: 2 })); + injectType(svc, "TUnion", z.union([z.string(), z.number()])); + injectType(svc, "TOptional", z.string().optional()); + injectType(svc, "TNullable", z.number().nullable()); + + const d = doc.buildDocData(); + const expectType = (name: string, description: string, tsType: string) => { + expect(d.types[name]).toEqual({ + name, + description, + isBuiltin: false, + tsType, + isDefaultFormat: true, + isParamsRequired: false, + }); + }; + expectType("TString", "字符串类型", "string"); + expectType("TNumber", "数字类型", "number"); + expectType("TBoolean", "布尔类型", "boolean"); + expectType("TDate", "日期类型", "Date"); + expectType("TArray", "数组类型", "string[]"); + expectType("TNumArray", "数组类型", "number[]"); + expectType("TObject", "对象类型", "object"); + expectType("TEnum", "枚举类型", '"a" | "b"'); + expectType("TNativeEnum", "枚举类型", "1 | 2"); + expectType("TUnion", "联合类型", "string | number"); + expectType("TOptional", "可选类型", "string | undefined"); + expectType("TNullable", "可空类型", "number | null"); + }); + + test("typeName 风格(Zod 3 双轨)分支全部可达", () => { + const { svc, doc } = makeDoc({ docs: { markdown: false } }); + injectType(svc, "ZString", { _def: { typeName: "ZodString" } }); + injectType(svc, "ZNumber", { _def: { typeName: "ZodNumber" } }); + injectType(svc, "ZBoolean", { _def: { typeName: "ZodBoolean" } }); + injectType(svc, "ZDate", { _def: { typeName: "ZodDate" } }); + injectType(svc, "ZArray", { _def: { typeName: "ZodArray", element: z.string() } }); + injectType(svc, "ZObject", { _def: { typeName: "ZodObject" } }); + injectType(svc, "ZEnum", { _def: { typeName: "ZodEnum", values: ["x", "y"] } }); + injectType(svc, "ZUnion", { _def: { typeName: "ZodUnion", options: [z.string(), z.number()] } }); + injectType(svc, "ZOptional", { _def: { typeName: "ZodOptional", innerType: z.string() } }); + injectType(svc, "ZNullable", { _def: { typeName: "ZodNullable", innerType: z.number() } }); + + const d = doc.buildDocData(); + const expectType = (name: string, description: string, tsType: string) => { + expect(d.types[name].description).toBe(description); + expect(d.types[name].tsType).toBe(tsType); + }; + expectType("ZString", "字符串类型", "string"); + expectType("ZNumber", "数字类型", "number"); + expectType("ZBoolean", "布尔类型", "boolean"); + expectType("ZDate", "日期类型", "Date"); + expectType("ZArray", "数组类型", "string[]"); + expectType("ZObject", "对象类型", "object"); + expectType("ZEnum", "枚举类型", '"x" | "y"'); + expectType("ZUnion", "联合类型", "string | number"); + expectType("ZOptional", "可选类型", "string | undefined"); + expectType("ZNullable", "可空类型", "number | null"); + }); + + test("default 分支与伪造 _def 边界结构", () => { + const { svc, doc } = makeDoc({ docs: { markdown: false } }); + injectType(svc, "CustomKind", { _def: { type: "customThing" } }); + injectType(svc, "NoDef", {}); + injectType(svc, "ArrayNoElement", { _def: { type: "array" } }); + injectType(svc, "EnumArrayValues", { _def: { type: "enum", values: ["a", 1] } }); + // 数组带附加属性:map 只遍历索引,Object.values 会带上附加属性(区分两个分支) + const withExtra = ["a", 1] as unknown as { extra?: string }; + withExtra.extra = "x"; + injectType(svc, "EnumArrayExtra", { _def: { type: "enum", values: withExtra } }); + injectType(svc, "EnumStringValues", { _def: { type: "enum", values: "abc" } }); + injectType(svc, "EnumNoValues", { _def: { type: "enum" } }); + injectType(svc, "UnionNoOptions", { _def: { type: "union" } }); + + const d = doc.buildDocData(); + const expectType = (name: string, description: string, tsType: string) => { + expect(d.types[name].description).toBe(description); + expect(d.types[name].tsType).toBe(tsType); + }; + expectType("CustomKind", "Zod customThing 类型", "unknown"); + expectType("NoDef", "未知类型", "unknown"); + expectType("ArrayNoElement", "数组类型", "unknown[]"); + expectType("EnumArrayValues", "枚举类型", '"a" | 1'); + expectType("EnumArrayExtra", "枚举类型", '"a" | 1'); + expectType("EnumStringValues", "枚举类型", "string"); + expectType("EnumNoValues", "枚举类型", "string"); + expectType("UnionNoOptions", "联合类型", "unknown"); + }); + + test("注册表快照:非空注册表生成精确 typeRegistrySnapshot / schemaRegistrySnapshot", () => { + const { svc, doc } = makeDoc({ docs: { markdown: false } }); + svc.type.register("SnapStr", z.string()); + svc.type.register("SnapNum", z.number()); + svc.schema.register("SnapObj", z.object({ a: z.string() })); + + const d = doc.buildDocData(); + expect(d.typeRegistrySnapshot).toEqual({ + SnapStr: { description: "字符串类型", tsType: "string" }, + SnapNum: { description: "数字类型", tsType: "number" }, + }); + expect(d.schemaRegistrySnapshot).toEqual({ + SnapObj: { description: "对象类型", tsType: "object" }, + }); + // generateTypeDocumentation 同步生成 types + expect(Object.keys(d.types).toSorted()).toEqual(["SnapNum", "SnapObj", "SnapStr"]); + }); + + test("注册表快照:空注册表时快照为 undefined", () => { + const { doc } = makeDoc({ docs: { markdown: false } }); + const d = doc.buildDocData(); + expect(d.typeRegistrySnapshot).toBeUndefined(); + expect(d.schemaRegistrySnapshot).toBeUndefined(); + expect(d.types).toEqual({}); + }); + + test("注册表为 undefined 时不抛错,types 与快照均为空", () => { + const svc = lib({ docs: { markdown: false } }); + (svc as unknown as { typeRegistry?: unknown }).typeRegistry = undefined; + (svc as unknown as { schemaRegistry?: unknown }).schemaRegistry = undefined; + const doc = new IAPIDoc(svc as never); + expect(() => doc.buildDocData()).not.toThrow(); + const d = doc.buildDocData(); + expect(d.types).toEqual({}); + expect(d.typeRegistrySnapshot).toBeUndefined(); + expect(d.schemaRegistrySnapshot).toBeUndefined(); + }); +}); + +describe("extend/docs.ts: genDocs 插件注册与移除", () => { + test("genDocs 按文档选项注册插件,hasPlugin/removePlugin 行为正确", () => { + const svc = lib({ + docs: { markdown: true, swagger: true, postman: false, json: false, axios: false, all: false }, + }); + const doc = new IAPIDoc(svc as never); + expect(doc.hasPlugin("markdown")).toBe(false); // genDocs 前未注册 + doc.genDocs(); + expect(doc.hasPlugin("markdown")).toBe(true); + expect(doc.hasPlugin("swagger")).toBe(true); + expect(doc.hasPlugin("json")).toBe(false); + expect(doc.hasPlugin("postman")).toBe(false); + doc.registerPlugin("custom", () => {}); + expect(doc.hasPlugin("custom")).toBe(true); + expect(doc.removePlugin("custom")).toBe(true); + expect(doc.hasPlugin("custom")).toBe(false); + expect(doc.removePlugin("custom")).toBe(false); + }); +}); + +describe("extend/docs.ts: save 落盘与统计", () => { + test("目录入参校验:非字符串 / 空字符串 / 伪长度对象均拒绝,错误信息包含目录值", () => { + const { svc, doc } = makeDoc({ docs: { markdown: false } }); + svc.api.get("/x").title("x"); + expect(() => doc.save(123 as never)).toThrowError('文档存储目录"123"格式不正确:必须是字符串类型'); + expect(() => doc.save({ length: 5 } as never)).toThrowError( + '文档存储目录"[object Object]"格式不正确:必须是字符串类型' + ); + expect(() => doc.save("")).toThrowError('文档存储目录""格式不正确:必须是字符串类型'); + expect(doc.save("/docs")).toBe(doc); + }); + + test("apiInfo 统计:count/tested/untest 精确计数(含 examples 缺失的 API)", () => { + const svc = lib({ docs: { markdown: false, json: true } }); + svc.api.get("/plain").title("p"); + svc.api + .get("/t1") + .title("t1") + .example({ input: { a: 1 }, output: { b: 1 } }); + svc.api + .get("/t2") + .title("t2") + .example({ input: { a: 2 }, output: { b: 2 } }); + const noEx = svc.api.get("/noex").title("n"); + noEx.example({ input: {}, output: {} }); + delete (svc.api.$apis.get("GET_/noex")!.options as Record).examples; + + const doc = new IAPIDoc(svc as never); + doc.genDocs(); + const { writes, writer } = captureWriter(); + doc.setWritter(writer); + doc.save("/out"); + + expect(writes.map(([p]) => p)).toEqual(["/out/doc.json"]); + const parsed = JSON.parse(writes[0][1]) as Record; + expect(parsed.apiInfo).toEqual({ + count: 4, + tested: 2, + untest: ["GET_/plain", "GET_/noex"], + }); + expect(Object.keys(parsed.apis as object).toSorted()).toEqual(["GET_/noex", "GET_/plain", "GET_/t1", "GET_/t2"]); + }); + + test("doc.json 内容:去除 erest 循环引用,保留 info/group/genTime", () => { + const svc = lib({ docs: { markdown: false, json: true } }); + svc.api.get("/dj").title("dj"); + const doc = new IAPIDoc(svc as never); + doc.genDocs(); + const { writes, writer } = captureWriter(); + doc.setWritter(writer); + doc.save("/out"); + + expect(writes.map(([p]) => p)).toEqual(["/out/doc.json"]); + const parsed = JSON.parse(writes[0][1]) as Record; + expect((parsed.info as Record).title).toBe("erest-demo"); + expect((parsed.info as Record).basePath).toBe("/api"); + expect(parsed.group).toEqual({ Index: "首页", Index2: "首页2" }); + expect(typeof parsed.genTime).toBe("string"); + expect("erest" in parsed).toBe(false); + }); + + test("all.json 内容与文件名(含自定义文件名)", () => { + const svc = lib({ docs: { markdown: false, all: "my-all.json", json: "my-doc.json" } }); + svc.api.get("/aj").title("aj"); + const doc = new IAPIDoc(svc as never); + doc.genDocs(); + const { writes, writer } = captureWriter(); + doc.setWritter(writer); + doc.save("/out"); + + expect(writes.map(([p]) => p)).toEqual(["/out/my-doc.json", "/out/my-all.json"]); + const all = JSON.parse(writes[1][1]) as Record; + expect((all.info as Record).title).toBe("erest-demo"); + expect(Object.keys(all.apis as object)).toEqual(["GET_/aj"]); + expect("erest" in all).toBe(false); + }); + + test("默认 writer 直接写文件(memfs 卷可读到 doc.json/all.json/swagger.json/postman.json)", () => { + const { svc, doc } = makeDoc(); // lib 默认开启全部文档选项 + svc.api.get("/v1").title("v"); + doc.genDocs(); + doc.save("/docs"); + const files = Object.keys(vol.toJSON()); + expect(files).toContain("/docs/doc.json"); + expect(files).toContain("/docs/all.json"); + expect(files).toContain("/docs/swagger.json"); + expect(files).toContain("/docs/postman.json"); + const all = JSON.parse(vol.toJSON()["/docs/all.json"] as string) as Record; + expect((all.info as Record).title).toBe("erest-demo"); + }); + + test("插件收到 (data, dir, options, writer) 且 data 为缓存实例", () => { + const { svc, doc } = makeDoc({ docs: { markdown: false } }); + svc.api.get("/p").title("p"); + let captured: + | { + data: Record; + dir: string; + options: Record; + w: IDocWritter; + } + | undefined; + doc.registerPlugin("probe", (data, dir, options, w) => { + captured = { data: data as Record, dir, options: options as Record, w }; + }); + const { writer } = captureWriter(); + doc.setWritter(writer); + doc.save("/out"); + expect(captured).toBeDefined(); + expect(captured!.dir).toBe("/out"); + expect(captured!.w).toBe(writer); + expect(captured!.options.markdown).toBe(false); + expect(captured!.data).toBe(doc.buildDocData()); + expect(captured!.data.apis["GET_/p"]).toBeDefined(); + }); + + test("插件抛错被捕获(console.error 提示),后续插件继续执行", () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const { doc } = makeDoc({ docs: { markdown: false } }); + let goodRan = false; + doc.registerPlugin("bad", () => { + throw new Error("boom"); + }); + doc.registerPlugin("good", () => { + goodRan = true; + }); + expect(() => doc.save("/out")).not.toThrow(); + expect(goodRan).toBe(true); + expect(errSpy).toHaveBeenCalledWith('Plugin "bad" failed:', expect.any(Error)); + } finally { + errSpy.mockRestore(); + } + }); +}); + +describe("extend/docs.ts: debug 日志", () => { + test("buildDocData 输出 erest:docs data", () => { + const { doc } = makeDoc({ docs: { markdown: false } }); + const out = withDocsDebug(() => doc.buildDocData()); + expect(out).toContain("erest:docs data"); + }); + + test("save 输出 save: 与 build doc: <插件名>", () => { + const svc = lib({ docs: { markdown: false } }); + svc.api.get("/d").title("d"); + const doc = new IAPIDoc(svc as never); + doc.registerPlugin("mk", () => {}); + const out = withDocsDebug(() => doc.save("/outdir")); + expect(out).toContain("save: /outdir"); + expect(out).toContain("build doc: mk"); + }); +}); diff --git a/src/test/test-error-formatter.ts b/src/test/test-error-formatter.ts index a6cd30f..cfb8f57 100644 --- a/src/test/test-error-formatter.ts +++ b/src/test/test-error-formatter.ts @@ -1,9 +1,15 @@ /** * @file defaultErrorFormatter 测试 * 验证错误格式器对 ERestError / 普通 Error 输出一致的 {status, body} 结构 + * + * 另含 Stryker 存活 mutant 补测:默认错误表(default/errors.ts)、ErrorManager(manager/error.ts)、debug 模块(debug.ts)。 */ +import Debug from "debug"; import { describe, expect, it } from "vitest"; +import * as debugModule from "../lib/debug"; +import { defaultErrors } from "../lib/default/errors"; import { ERestError, defaultErrorFormatter } from "../lib/error.js"; +import { ErrorManager } from "../lib/manager/error"; describe("defaultErrorFormatter", () => { it("ERestError 输出 statusCode 与 code/message body", () => { @@ -34,3 +40,285 @@ describe("defaultErrorFormatter", () => { expect(out.body).toEqual({ error: "forbidden", code: "INTERNAL_ERROR" }); }); }); + +/** 临时开启 debug 命名空间并捕获输出(结束后恢复) */ +function captureDebugLogs(namespace: string, logger: Debug.Debugger, run: () => void): string[] { + const lines: string[] = []; + const originalLog = (logger as unknown as { log: unknown }).log; + Debug.enable(namespace); + (logger as unknown as { log: (fmt: unknown, ...args: unknown[]) => void }).log = (...args: unknown[]) => { + lines.push(args.map((a) => String(a)).join(" ")); + }; + try { + run(); + } finally { + (logger as unknown as { log: unknown }).log = originalLog; + Debug.disable(); + } + return lines; +} + +describe("默认错误表 defaultErrors", () => { + it("注册全部 10 个默认错误且字段精确", () => { + const em = new ErrorManager(); + defaultErrors(em); + const names: string[] = []; + em.forEach((_v, k) => names.push(k)); + expect(names).toEqual([ + "INTERNAL_ERROR", + "RATE_LIMITED", + "MISSING_PARAMETER", + "INVALID_PARAMETER", + "PERMISSIONS_ERROR", + "NOT_FOUND_ERROR", + "EXCE_INVALID_ERROR", + "DATABASE_ERROR", + "REPEAT_ERROR", + "DEPEND_ERROR", + ]); + + expect(em.get("INTERNAL_ERROR")).toEqual({ + name: "INTERNAL_ERROR", + code: -1000, + description: "内部错误", + status: 200, + isDefault: true, + isShow: true, + isLog: true, + }); + expect(em.get("RATE_LIMITED")).toEqual({ + name: "RATE_LIMITED", + code: -1001, + description: "请求过多", + status: 200, + isDefault: true, + isShow: true, + isLog: false, + }); + expect(em.get("MISSING_PARAMETER")).toEqual({ + name: "MISSING_PARAMETER", + code: -1002, + description: "缺少参数", + status: 200, + isDefault: true, + isShow: true, + isLog: false, + }); + expect(em.get("INVALID_PARAMETER")).toEqual({ + name: "INVALID_PARAMETER", + code: -1003, + description: "参数不合法", + status: 200, + isDefault: true, + isShow: true, + isLog: false, + }); + expect(em.get("PERMISSIONS_ERROR")).toEqual({ + name: "PERMISSIONS_ERROR", + code: -1004, + description: "权限不足", + status: 200, + isDefault: true, + isShow: true, + isLog: true, + }); + expect(em.get("NOT_FOUND_ERROR")).toEqual({ + name: "NOT_FOUND_ERROR", + code: -1005, + description: "找不到内容", + status: 200, + isDefault: true, + isShow: true, + isLog: false, + }); + expect(em.get("EXCE_INVALID_ERROR")).toEqual({ + name: "EXCE_INVALID_ERROR", + code: -1006, + description: "不合法执行", + status: 200, + isDefault: true, + isShow: true, + isLog: false, + }); + expect(em.get("DATABASE_ERROR")).toEqual({ + name: "DATABASE_ERROR", + code: -1007, + description: "数据库错误", + status: 200, + isDefault: true, + isShow: false, + isLog: true, + }); + expect(em.get("REPEAT_ERROR")).toEqual({ + name: "REPEAT_ERROR", + code: -1008, + description: "该记录已经存在", + status: 200, + isDefault: true, + isShow: true, + isLog: false, + }); + expect(em.get("DEPEND_ERROR")).toEqual({ + name: "DEPEND_ERROR", + code: -1009, + description: "数据存在依赖", + status: 200, + isDefault: true, + isShow: true, + isLog: false, + }); + }); +}); + +describe("ErrorManager 注册与查询", () => { + it("register 最小参数填充默认值并返回 this", () => { + const em = new ErrorManager(); + expect(em.register("MIN_ERR", {})).toBe(em); + expect(em.get("MIN_ERR")).toEqual({ + name: "MIN_ERR", + code: -1, + description: "", + status: 200, + isDefault: false, + isShow: false, + isLog: false, + }); + }); + + it("register 保留显式字段", () => { + const em = new ErrorManager(); + em.register("FULL_ERR", { code: 100, description: "自定义", status: 404, isShow: true, isLog: true }); + expect(em.get("FULL_ERR")).toEqual({ + name: "FULL_ERR", + code: 100, + description: "自定义", + status: 404, + isDefault: false, + isShow: true, + isLog: true, + }); + }); + + it("名称必须是大写字母或下划线的字符串", () => { + const em = new ErrorManager(); + expect(() => em.register("lower_case", {})).toThrowError(/name must be uppercase or _ but get lower_case/); + expect(() => em.register(123 as unknown as string, {})).toThrowError(/error name must be string/); + }); + + it("重复 code 拒绝注册", () => { + const em = new ErrorManager(); + em.register("A_ERR", { code: 42 }); + expect(() => em.register("B_ERR", { code: 42 })).toThrowError(/code already exits: 42/); + }); + + it("modify 仅允许已存在的默认错误", () => { + const em = new ErrorManager(); + em.register("D_ERR", { code: 1, isDefault: true }); + em.register("NORMAL", { code: 10 }); + expect(() => em.modify("NOPE", {})).toThrowError(/error not exits: NOPE/); + expect(() => em.modify("NORMAL", { code: 11 })).toThrowError(/only modify default error/); + expect(em.get("NORMAL")?.code).toBe(10); + }); + + it("modify 默认错误:字段被覆盖、isDefault 置 false、新 code 纳入查重", () => { + const em = new ErrorManager(); + em.register("D_ERR", { code: 1, isDefault: true, description: "旧描述" }); + em.modify("D_ERR", { code: 500, description: "新描述" }); + expect(em.get("D_ERR")).toEqual({ + name: "D_ERR", + code: 500, + description: "新描述", + status: 200, + isDefault: false, + isShow: false, + isLog: false, + }); + expect(() => em.register("DUP_ERR", { code: 500 })).toThrowError(/code already exits: 500/); + }); + + it("import 跳过无 name 的条目", () => { + const em = new ErrorManager(); + expect(() => em.import([{}, { code: 9 }])).not.toThrow(); + const names: string[] = []; + em.forEach((_v, k) => names.push(k)); + expect(names).toEqual([]); + }); + + it("register 的 debug 输出包含 register: 前缀", () => { + const em = new ErrorManager(); + const logs = captureDebugLogs("erest:core:error", debugModule.coreError, () => { + em.register("DBG_ERR", { code: 77 }); + }); + expect(logs.join("\n")).toContain("register:"); + }); +}); + +describe("debug 模块命名空间", () => { + it("create 生成 erest: 前缀命名空间", () => { + expect(debugModule.create("custom").namespace).toBe("erest:custom"); + }); + + it("预置命名空间精确值", () => { + expect(debugModule.core.namespace).toBe("erest:core"); + expect(debugModule.coreError.namespace).toBe("erest:core:error"); + expect(debugModule.coreType.namespace).toBe("erest:core:types"); + expect(debugModule.api.namespace).toBe("erest:api"); + expect(debugModule.params.namespace).toBe("erest:params"); + expect(debugModule.test.namespace).toBe("erest:test"); + expect(debugModule.docs.namespace).toBe("erest:docs"); + expect(debugModule.plugin.namespace).toBe("erest:plugin"); + }); +}); + +// ---- error.ts:ERestError 工厂方法 ---- + +describe("error: ERestError 工厂方法", () => { + test("missingParam:消息与 details", () => { + const err = ERestError.missingParam("name"); + expect(err.message).toBe("missing required parameter 'name'"); + expect(err.code).toBe("MISSING_PARAM"); + expect(err.statusCode).toBe(400); + expect(err.details).toEqual({ field: "name" }); + }); + + test("invalidParam:无 expected 时消息为 incorrect parameter", () => { + const err = ERestError.invalidParam("slug"); + expect(err.message).toBe("incorrect parameter 'slug'"); + expect(err.details).toEqual({ field: "slug", expected: undefined, received: undefined }); + }); + + test("invalidParam:有 expected 时消息带期望类型", () => { + const err = ERestError.invalidParam("slug", "int", "abc"); + expect(err.message).toBe("'slug' should be valid int"); + expect(err.details).toEqual({ field: "slug", expected: "int", received: "abc" }); + }); + + test("internal:消息前缀 internal error + 500", () => { + const err = ERestError.internal("boom"); + expect(err.message).toBe("internal error boom"); + expect(err.code).toBe("INTERNAL_ERROR"); + expect(err.statusCode).toBe(500); + expect(err.details).toBeUndefined(); + }); + + test("validation:消息透传", () => { + const err = ERestError.validation("custom message", { field: "f" }); + expect(err.message).toBe("custom message"); + expect(err.code).toBe("VALIDATION_ERROR"); + expect(err.details).toEqual({ field: "f" }); + }); + + test("原型链与 toJSON", () => { + const err = ERestError.missingParam("f"); + expect(err).toBeInstanceOf(ERestError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("ERestError"); + expect(err.toJSON()).toEqual({ + name: "ERestError", + code: "MISSING_PARAM", + message: "missing required parameter 'f'", + details: { field: "f" }, + statusCode: 400, + }); + }); +}); diff --git a/src/test/test-hooks.ts b/src/test/test-hooks.ts index 494e8a7..ef60664 100644 --- a/src/test/test-hooks.ts +++ b/src/test/test-hooks.ts @@ -3,6 +3,7 @@ import { expressAdapter } from "./adapters"; import express from "express"; import { describe, test, expect } from "vitest"; import { z } from "zod"; +import { hasHooks } from "../lib/hooks.js"; import lib from "./lib.js"; /** 启动 express 服务并发起请求的辅助 */ @@ -92,3 +93,28 @@ describe("Lifecycle Hooks", () => { expect(ret.body).toBe("ok"); }); }); + +describe("hasHooks 空订阅裁剪(单元级)", () => { + test("undefined 与空对象返回 false", () => { + expect(hasHooks(undefined)).toBe(false); + expect(hasHooks({})).toBe(false); + }); + + test("仅注册单个 hook 也返回 true", () => { + expect(hasHooks({ onRequest: () => {} })).toBe(true); + expect(hasHooks({ onValidate: () => {} })).toBe(true); + expect(hasHooks({ onError: () => {} })).toBe(true); + expect(hasHooks({ onResponse: () => {} })).toBe(true); + }); + + test("全部注册返回 true", () => { + expect( + hasHooks({ + onRequest: () => {}, + onValidate: () => {}, + onError: () => {}, + onResponse: () => {}, + }) + ).toBe(true); + }); +}); diff --git a/src/test/test-lib.ts b/src/test/test-lib.ts index a2213f0..74ee60c 100644 --- a/src/test/test-lib.ts +++ b/src/test/test-lib.ts @@ -1,10 +1,20 @@ import { expressAdapter } from "./adapters"; import express from "express"; +import { vi } from "vitest"; import { z } from "zod"; -import type { IApiOption } from "../lib"; +import type { FrameworkAdapter, IApiOption } from "../lib"; import ERest, { ERestError } from "../lib"; -import { getCallerSourceLine, getPath } from "../lib/utils"; +import { buildHandlerChain } from "../lib/adapters/utils"; +import { Manager } from "../lib/manager/manager"; +import { + camelCase2underscore, + getCallerSourceLine, + getPath, + getRealPath, + getSchemaKey, + jsonStringify, +} from "../lib/utils"; import lib, { GROUPS, INFO } from "./lib"; describe("ERest - 基础测试", () => { @@ -428,6 +438,15 @@ describe("ERest - 更多测试(完善覆盖率)", () => { expect(absolute).toBeUndefined(); }); + test("getCallerSourceLine:ESM/vitest 无括号栈帧(file:// URL)也能定位到源文件", () => { + // ESM 栈帧形如 `at /abs/path/file.ts:1:1`(无圆括号),修复前恒返回 undefined。 + // 传调用者所在目录(与 index.ts 内 getCallerSourceLine(__dirname) 用法一致), + // 跳过 utils.ts 自身帧后命中本测试文件。 + const { relative, absolute } = getCallerSourceLine(`${process.cwd()}/src/test`); + expect(absolute).toMatch(/src[/\\]test[/\\]test-lib\.ts$/); + expect(relative).toBe("test-lib.ts"); + }); + test("getPath", () => { const p = getPath("Yourtion", "/a"); expect(p).toBe("/a"); @@ -511,3 +530,375 @@ describe("ERestError - 自定义错误类", () => { expect(error instanceof ERestError).toBe(true); }); }); + +describe("ERest - 默认错误工厂", () => { + test("missingParameter 默认工厂应生成 MISSING_PARAM 错误", () => { + const s = new ERest({}); + const err = s.getError().missingParameter("'name'") as ERestError; + expect(err).toBeInstanceOf(ERestError); + expect(err.code).toBe("MISSING_PARAM"); + expect(err.message).toBe("missing required parameter 'name'"); + expect(err.details).toEqual({ field: "name" }); + }); + + test("invalidParameter 默认工厂应提取首个字段名", () => { + const s = new ERest({}); + const err = s.getError().invalidParameter("'age' should be valid") as ERestError; + expect(err.code).toBe("INVALID_PARAM"); + expect(err.message).toBe("incorrect parameter 'age' should be valid"); + expect(err.details).toEqual({ field: "age" }); + }); + + test("internalError 默认工厂应生成 500 错误", () => { + const s = new ERest({}); + const err = s.getError().internalError("boom") as ERestError; + expect(err.code).toBe("INTERNAL_ERROR"); + expect(err.message).toBe("internal error boom"); + expect(err.statusCode).toBe(500); + }); +}); + +describe("ERest - 门面行为与文档惰性初始化", () => { + test("未配置 hooks 时 hasHooks 应返回 false", () => { + const s = new ERest({}); + expect(s.hasHooks()).toBe(false); + }); + + test("groups 配置对象缺 name 时应归一化为空字符串", () => { + const s = new ERest({ groups: { Weird: {} } }); + expect(s.getInternalGroups().Weird).toBe(""); + }); + + test("groups 值为 undefined 时应归一化为空字符串与空初始结构", () => { + const s = new ERest({ groups: { U: undefined } }); + expect(s.getInternalGroups().U).toBe(""); + expect(s.getDocsView().groupInfo.U).toEqual({ middleware: [], before: [] }); + }); + + test("type.value 对未注册类型应返回失败信息", () => { + const s = new ERest({}); + const r = s.type.value("NoSuchType", 42); + expect(r.ok).toBe(false); + expect(r.message).toBe("Unknown type: NoSuchType"); + expect(r.value).toBe(42); + }); + + test("docs 选项应按默认值归一化", () => { + const s = new ERest({ docs: { markdown: "custom.md", index: true } }); + expect(s.getDocsView().docsOptions).toEqual({ + markdown: "custom.md", + wiki: "./", + index: true, + home: true, + swagger: false, + postman: false, + json: false, + axios: false, + all: false, + }); + }); + + test("重复注册相同 key 的 API 应报错并指明已注册", () => { + const s = new ERest({}); + s.api.get("/dup").register(() => {}); + expect(() => s.api.get("/dup").register(() => {})).toThrow(/失败,因为该API已在文件/); + s.api.define({ method: "get", path: "/dup2", title: "d", handler: () => {} }); + expect(() => s.api.define({ method: "get", path: "/dup2", title: "d", handler: () => {} })).toThrow( + /失败,因为该API已在文件/ + ); + }); + + test("initTest 二次调用应保持首次的 app 与 docs 实例", () => { + const s = new ERest({}); + const app1 = express(); + s.initTest(app1, "/tmp", "/tmp"); + const docs1 = s.api.docs; + const app2 = express(); + s.initTest(app2, "/tmp", "/tmp"); + expect(s.getTestView().app).toBe(app1); + expect(s.api.docs).toBe(docs1); + }); + + test("setResponseEnvelopers 应注册 testUnwrapper 到 formatOutputReverse", () => { + const s = new ERest({}); + const success = vi.fn(); + const errorFn = vi.fn(); + const unwrap = vi.fn(); + s.setResponseEnvelopers({ success, error: errorFn, testUnwrapper: unwrap }); + expect(s.api.formatOutputReverse).toBe(unwrap); + expect(s.api.successEnveloper).toBe(success); + expect(s.api.errorEnveloper).toBe(errorFn); + // 不带 testUnwrapper 时不应覆盖 formatOutputReverse + const s2 = new ERest({}); + s2.setResponseEnvelopers({ success: vi.fn(), error: vi.fn() }); + expect(s2.api.formatOutputReverse).toBeUndefined(); + }); + + test("docs 未初始化时 setDocWritter 与 addDocPlugin 应安全跳过", () => { + const s = new ERest({}); + expect(() => s.setDocWritter(vi.fn())).not.toThrow(); + expect(() => s.addDocPlugin("p", vi.fn())).not.toThrow(); + }); + + test("buildSwagger 应复用已初始化的 docs 实例", () => { + const s = new ERest({}); + const stub = { getSwaggerInfo: vi.fn(() => ({ stub: true })) }; + s.api.docs = stub as never; + expect(s.buildSwagger()).toEqual({ stub: true }); + expect(s.api.docs).toBe(stub); + }); + + test("genDocs 默认保存路径应为 cwd/docs/ 且走 saveOnExit", () => { + const s = new ERest({}); + const saveOnExit = vi.fn(); + const save = vi.fn(); + s.api.docs = { genDocs: vi.fn(), saveOnExit, save } as never; + s.genDocs(); + expect(saveOnExit).toHaveBeenCalledTimes(1); + expect(saveOnExit).toHaveBeenCalledWith(`${process.cwd()}/docs/`); + expect(save).not.toHaveBeenCalled(); + }); + + test("genDocs onExit=false 时应立即 save 到指定目录", () => { + const s = new ERest({}); + const saveOnExit = vi.fn(); + const save = vi.fn(); + s.api.docs = { genDocs: vi.fn(), saveOnExit, save } as never; + s.genDocs("/custom/docs", false); + expect(save).toHaveBeenCalledTimes(1); + expect(save).toHaveBeenCalledWith("/custom/docs"); + expect(saveOnExit).not.toHaveBeenCalled(); + }); +}); + +describe("ERest - group 注册与 bind 装配", () => { + test("group 传描述字符串应写入 groups 与 groupInfo 初始结构", () => { + const s = new ERest({ forceGroup: true }); + s.group("DescG", "描述分组"); + expect(s.getInternalGroups().DescG).toBe("描述分组"); + expect(s.getDocsView().groupInfo.DescG).toEqual({ + name: "描述分组", + prefix: "", + middleware: [], + before: [], + }); + }); + + test("group 无 prefix 时注册 key 应派生自驼峰转下划线的组名", () => { + const s = new ERest({ forceGroup: true }); + s.group("DescG", "描述分组") + .get("/k1") + .register(() => {}); + expect(s.api.$apis.has("GET_/desc_g/k1")).toBe(true); + }); + + test("forceGroup 模式 bind 时 API 缺 handler 应抛错", () => { + const s = new ERest({ forceGroup: true, groups: { g1: "G1" } }); + s.group("g1").get("/no-handler"); + expect(() => s.bind({ adapter: expressAdapter, app: express(), router: express.Router() })).toThrow( + /没有注册处理函数/ + ); + }); + + test("forceGroup 模式 bind 应按 group prefix 派生子路由并挂载", () => { + const route = { marker: "route" }; + const createGroupRouter = vi.fn(() => route); + const attachGroupRouter = vi.fn(); + const bindRoute = vi.fn(); + const adapter = { + name: "mock", + createGroupRouter, + attachGroupRouter, + bindRoute, + makeParamsChecker: () => vi.fn(), + } as unknown as FrameworkAdapter; + const s = new ERest({ + forceGroup: true, + groups: { g1: "G1", u: { name: "U", prefix: "/users" } }, + }); + s.group("g1") + .get("/a") + .register(() => {}); + s.group("u") + .post("/b") + .register(() => {}); + const app = {}; + const router = {}; + s.bind({ adapter, app, router }); + // 无 prefix 的 g1 派生为 camelCase2underscore("g1");u 用显式 prefix "/users" + expect(createGroupRouter.mock.calls.map((c) => c[1])).toEqual(["g1", "/users"]); + expect(attachGroupRouter).toHaveBeenCalledTimes(2); + expect(attachGroupRouter.mock.calls.map((c) => c[0])).toEqual([app, app]); + expect(attachGroupRouter.mock.calls.map((c) => c[1])).toEqual([route, route]); + expect(attachGroupRouter.mock.calls.map((c) => c[2])).toEqual(["g1", "/users"]); + expect(bindRoute).toHaveBeenCalledTimes(2); + }); + + test("非 forceGroup 模式 bind 缺 router 应抛错", () => { + const s = new ERest({}); + expect(() => s.bind({ adapter: expressAdapter })).toThrow("非 forceGroup 模式需要提供 router"); + }); + + test("非 forceGroup 模式 bind 时 API 缺 handler 应抛错", () => { + const s = lib(); + s.api.get("/no-handler-2").group("Index"); + expect(() => s.bind({ adapter: expressAdapter, router: express.Router() })).toThrow(/没有注册处理函数/); + }); +}); + +// ---- utils / adapters / manager 补测 ---- + +describe("utils: getSchemaKey", () => { + test("无 prefix:method 大写 + path", () => { + expect(getSchemaKey("get", "/path")).toBe("GET_/path"); + expect(getSchemaKey("delete", "/x")).toBe("DELETE_/x"); + }); + + test("prefix 不以 / 开头:转下划线并补 /", () => { + expect(getSchemaKey("post", "/path", "v1")).toBe("POST_/v1/path"); + expect(getSchemaKey("get", "/p", "apiV2")).toBe("GET_/api_v2/p"); + }); + + test("prefix 以 / 开头:原样使用", () => { + expect(getSchemaKey("get", "/path", "/v2")).toBe("GET_/v2/path"); + }); + + test("双斜杠合并、尾斜杠去除、根路径回退 /", () => { + expect(getSchemaKey("get", "/a//b")).toBe("GET_/a/b"); + expect(getSchemaKey("get", "/a/")).toBe("GET_/a"); + expect(getSchemaKey("get", "/")).toBe("GET_/"); + }); +}); + +describe("utils: camelCase2underscore", () => { + test("常规转换", () => { + expect(camelCase2underscore("camelCase")).toBe("camel_case"); + expect(camelCase2underscore("aB")).toBe("a_b"); + expect(camelCase2underscore("v1")).toBe("v1"); + expect(camelCase2underscore("X")).toBe("x"); + expect(camelCase2underscore("")).toBe(""); + }); + + test("连续大写逐个加下划线", () => { + expect(camelCase2underscore("APIV2")).toBe("a_p_i_v2"); + }); + + test("首字符仅匹配非空白(前导空格保留原样)", () => { + expect(camelCase2underscore(" AbC")).toBe(" _ab_c"); + }); +}); + +describe("utils: jsonStringify", () => { + test("紧凑输出", () => { + expect(jsonStringify({ a: 1 }, 0)).toBe('{"a":1}'); + }); + + test("缩进输出与 JSON.stringify 一致", () => { + expect(jsonStringify({ a: { b: [1] } }, 2)).toBe('{\n "a": {\n "b": [\n 1\n ]\n }\n}'); + }); + + test("数组与 null 值", () => { + expect(jsonStringify([1, "x", null], 0)).toBe('[1,"x",null]'); + }); +}); + +describe("utils: getPath / getRealPath", () => { + test("getPath:string 覆盖默认值,boolean 用默认值", () => { + expect(getPath("def")).toBe("def"); + expect(getPath("def", "opt")).toBe("opt"); + expect(getPath("def", true)).toBe("def"); + expect(getPath("def", false)).toBe("def"); + expect(getPath("def", undefined)).toBe("def"); + }); + + test("getRealPath:截取第一个 _ 后的路径", () => { + expect(getRealPath("GET_/a/b")).toBe("/a/b"); + expect(getRealPath("GET_/")).toBe("/"); + }); +}); + +// ============ error.ts ============ + +// ============ adapters/utils.ts ============ + +/** handler 链各位置的共享 no-op 桩(引用相等断言用) */ +const chainStub = { + globalBefore: () => {}, + groupBefore: () => {}, + apiBefore: () => {}, + checker: () => {}, + groupMiddleware: () => {}, + apiMiddleware: () => {}, + middleware: () => {}, + handler: () => {}, +}; + +describe("adapters/utils: buildHandlerChain", () => { + test("完整顺序:全局 before → 组 before → API before → checker → 组 middleware → API middleware → handler", () => { + const { globalBefore, groupBefore, apiBefore, checker, groupMiddleware, apiMiddleware, handler } = chainStub; + const chain = buildHandlerChain({ + beforeHooks: new Set([globalBefore]), + api: { options: { beforeHooks: new Set([apiBefore]), middlewares: [apiMiddleware], handler } } as never, + checker, + groupInfo: { before: [groupBefore], middleware: [groupMiddleware] } as never, + }); + expect(chain).toEqual([globalBefore, groupBefore, apiBefore, checker, groupMiddleware, apiMiddleware, handler]); + }); + + test("无 groupInfo:仅全局 before + checker + API 中间件 + handler", () => { + const { globalBefore, apiBefore, checker, middleware, handler } = chainStub; + const chain = buildHandlerChain({ + beforeHooks: new Set([globalBefore]), + api: { options: { beforeHooks: new Set([apiBefore]), middlewares: [middleware], handler } } as never, + checker, + }); + expect(chain).toEqual([globalBefore, apiBefore, checker, middleware, handler]); + }); + + test("非函数项(undefined/字符串)被过滤", () => { + const { checker, middleware, handler } = chainStub; + const chain = buildHandlerChain({ + beforeHooks: new Set(), + api: { + options: { + beforeHooks: new Set([undefined as never, "str" as never]), + middlewares: [null as never, middleware], + handler, + }, + } as never, + checker, + groupInfo: { before: [123 as never], middleware: [] } as never, + }); + expect(chain).toEqual([checker, middleware, handler]); + expect(chain.every((h) => typeof h === "function")).toBe(true); + }); +}); + +// ============ manager/manager.ts ============ + +describe("manager: Manager get/forEach", () => { + class TestManager extends Manager { + public set(key: string, value: string) { + (this as unknown as { map: Map }).map.set(key, value); + } + } + + test("get 按名读取,不存在返回 undefined", () => { + const m = new TestManager(); + m.set("a", "1"); + expect(m.get("a")).toBe("1"); + expect(m.get("missing")).toBeUndefined(); + }); + + test("forEach 按 Map 插入顺序遍历", () => { + const m = new TestManager(); + m.set("a", "1"); + m.set("b", "2"); + const seen: Array<[string, string]> = []; + m.forEach((value, key) => seen.push([key, value])); + expect(seen).toEqual([ + ["a", "1"], + ["b", "2"], + ]); + }); +}); diff --git a/src/test/test-test.ts b/src/test/test-test.ts index dbafa48..206a531 100644 --- a/src/test/test-test.ts +++ b/src/test/test-test.ts @@ -1,13 +1,25 @@ +import { createServer, type AddressInfo, type IncomingMessage, Server, type ServerResponse } from "node:http"; +import * as stream from "node:stream"; + import { expressAdapter } from "./adapters"; import express from "express"; import { vol } from "memfs"; -import { vi } from "vitest"; +import { afterAll, vi } from "vitest"; import { z } from "zod"; +import { SUPPORT_METHOD, type SUPPORT_METHODS } from "../lib/api.js"; +import { TestAgent } from "../lib/agent.js"; +import IAPITest from "../lib/extend/test"; +import { normalizeTestTarget } from "../lib/test-server.js"; import { apiAll, apiJson } from "./helper"; import lib from "./lib"; +/** ANSI 颜色码(ESC + [ + 数字 + m),用于校验/剥离 util.inspect colors 输出 */ +const ESC = String.fromCharCode(27); +const ANSI = new RegExp(`${ESC}\\[\\d+m`, "g"); +const stripAnsi = (s: string) => s.replace(ANSI, ""); + describe("ERest 测试套件", () => { let app: express.Application; let router: express.Router; @@ -401,3 +413,690 @@ describe("ERest 测试套件", () => { }); }); }); + +describe("TestAgent 行为(fetch 驱动)", () => { + const SRC = { absolute: "/virtual/test-test.ts", relative: "test-test.ts" }; + let erestA: ReturnType; + let seen: Array<{ method: string; url: string; headers: Record; body: string }>; + let baseUrl: string; + let server: ReturnType; + const last = () => seen[seen.length - 1]; + + const makeAgent = ( + erestIns: ReturnType, + method: SUPPORT_METHODS, + path: string, + key: string, + cookieHeader?: () => string | undefined, + onResponse?: (headers: Record) => void + ) => { + const a = new TestAgent(method, path, key, SRC, erestIns as never); + a.bindRequest( + async () => baseUrl, + async () => {}, + cookieHeader, + onResponse + ); + return a; + }; + + beforeAll(async () => { + erestA = lib(); + seen = []; + server = createServer((req, res) => { + let body = ""; + req.on("data", (c: Buffer) => (body += c.toString())); + req.on("end", () => { + seen.push({ method: req.method as string, url: req.url as string, headers: req.headers as never, body }); + if (req.url!.includes("plain-json")) { + res.setHeader("content-type", "text/plain"); + res.end('{"a":1}'); + return; + } + if (req.url!.includes("invalid-json")) { + res.setHeader("content-type", "application/json"); + res.end('{"unfinished":'); + return; + } + if (req.url!.includes("multi-set-cookie")) { + // 数组形式下发两个独立 Set-Cookie 头(验证 agent 端不再相互覆盖) + res.setHeader("Set-Cookie", ["sid=abc; Path=/", "token=xyz; Path=/"]); + } else if (req.url!.includes("set-cookie")) { + res.setHeader("Set-Cookie", "sid=abc; Path=/"); + } + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ method: req.method, url: req.url, body })); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + // 注册两个 API:echo-t / echo-err 用于 tested 与 Example 断言 + erestA.api + .get("/echo-t") + .group("Index") + .register(() => {}); + erestA.api + .get("/echo-err") + .group("Index") + .register(() => {}); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + test("构造函数应校验 method/path 入参", () => { + const ins = erestA as never; + expect(() => new TestAgent(123 as unknown as string, "/p", "k", SRC, ins)).toThrow("`method` must be string"); + expect(() => new TestAgent("bad" as SUPPORT_METHODS, "/p", "k", SRC, ins)).toThrow( + `\`method\` must be one of ${SUPPORT_METHOD}` + ); + expect(() => new TestAgent("get", 123 as unknown as string, "k", SRC, ins)).toThrow("`path` must be string"); + expect(() => new TestAgent("get", "no-slash", "k", SRC, ins)).toThrow('`path` must be start with "/"'); + }); + + test("未 bindRequest 时默认 getBaseUrl 应返回空字符串", async () => { + const a = new TestAgent("get", "/d", "GET_/d", SRC, erestA as never); + expect(await a.options.getBaseUrl()).toBe(""); + }); + + test("takeExample/headers 应记录状态并支持链式", () => { + const a = makeAgent(erestA, "get", "/st", "GET_/st"); + expect(a.takeExample("N1")).toBe(a); + expect(a.options.takeExample).toBe(true); + expect(a.options.agentTestName).toBe("N1"); + a.headers({ "x-t": "1" }); + expect(a.options.agentHeader).toEqual({ "x-t": "1" }); + }); + + test("attach 应把普通值字符串化、流挂起待 request() 消费且不丢失 agentInput", () => { + const a = makeAgent(erestA, "post", "/att", "POST_/att"); + const readable = new stream.Readable({ read() {} }); + a.attach({ f: "x", s: readable }); + const state = (a as unknown as { state: { form: FormData; pendingStreams: Array<[string, unknown]> } }).state; + // 普通值走 String() 序列化分支 + expect(state.form.get("f")).toBe("x"); + // 流不直接进 form(undici 会把非 Blob 对象隐式 String() 成 "[object Object]"),挂起待 request() + expect(state.form.get("s")).toBe(null); + expect(state.pendingStreams).toEqual([["s", readable]]); + // 流字段被 delete,不进入 agentInput;普通字段保留 + expect(a.options.agentInput).toEqual({ f: "x" }); + }); + + test("attach 流端到端:POST 后服务端收到流的完整内容(读尽为 Blob,不再丢失)", async () => { + const a = makeAgent(erestA, "post", "/upload", "POST_/upload"); + const rs = stream.Readable.from(["hello ", "stream"]); + await a.attach({ f: "x", s: rs }).success(); + const rec = last(); + // multipart 请求:content-type 带 boundary + expect(String(rec.headers["content-type"])).toContain("multipart/form-data; boundary="); + // 请求体包含流字段的真实内容与普通字段值(修复前流被 String() 成 "[object Object]") + expect(rec.body).toContain("hello stream"); + expect(rec.body).toContain('name="f"'); + }); + + test("GET + attach 应在请求时报错(GET 不能携带 body)", async () => { + const a = makeAgent(erestA, "get", "/att", "GET_/att"); + a.attach({ f: "x" }); + await expect(a.success()).rejects.toThrow(/attach\(\) 不支持 GET/); + }); + + test.each([ + ["Error", new Error("stream aborted")], + ["string", "stream aborted"], + ])("attach 流读取失败(%s)应保留字段名和 cause,且不发送部分请求", async (_kind, reason) => { + const a = makeAgent(erestA, "post", "/upload", "POST_/upload-error"); + const rs = stream.Readable.from( + (async function* () { + yield Buffer.from("partial content"); + throw reason; + })() + ); + const requestCount = seen.length; + await expect(a.attach({ upload: rs }).raw()).rejects.toMatchObject({ + message: 'attach() 读取流字段 "upload" 失败:stream aborted', + cause: reason, + }); + expect(seen).toHaveLength(requestCount); + }); + + test("多个 Set-Cookie 响应头不相互覆盖(getSetCookie 合并)", async () => { + let captured: Record = {}; + const a = makeAgent(erestA, "get", "/multi-set-cookie", "GET_/msc", undefined, (h) => { + captured = h; + }); + await a.success(); + // 修复前 forEach 逐条覆盖,jar 只剩最后一个;现在两个 cookie 都在合并后的头里 + expect(captured["set-cookie"]).toContain("sid=abc"); + expect(captured["set-cookie"]).toContain("token=xyz"); + }); + + test("未注册 key 上 saveExample 应安全跳过", () => { + const u = makeAgent(erestA, "get", "/nx", "GET_/nx"); + u.takeExample("X"); + expect(() => (u as unknown as { saveExample: () => void }).saveExample()).not.toThrow(); + expect(erestA.api.$apis.get("GET_/nx")).toBeUndefined(); + }); + + test("GET input 应序列化为 query string", async () => { + const g = makeAgent(erestA, "get", "/echo", "GET_/echo4"); + await g.input({ name: "N" }).raw(); + const r = last(); + expect(r.url).toBe("/echo?name=N"); + expect(r.method).toBe("GET"); + expect(r.body).toBe(""); + expect(r.headers["content-type"]).toBeUndefined(); + }); + + test("query 中 undefined/null 应被过滤,对象应 JSON 编码", async () => { + const a = makeAgent(erestA, "get", "/echo", "GET_/echo5"); + await a.query({ a: undefined, b: null, k: "v" }).raw(); + expect(last().url).toBe("/echo?k=v"); + + const b = makeAgent(erestA, "get", "/echo", "GET_/echo6"); + await b.query({ o: { x: 1 } }).raw(); + expect(last().url).toBe(`/echo?o=${encodeURIComponent('{"x":1}')}`); + }); + + test("POST input 应发送 JSON body 并设置 content-type", async () => { + const p = makeAgent(erestA, "post", "/echo", "POST_/echo7"); + await p.input({ age: 22 }).raw(); + const r = last(); + expect(r.method).toBe("POST"); + expect(r.body).toBe('{"age":22}'); + expect(r.headers["content-type"]).toBe("application/json"); + }); + + test("POST 未设置 input 时不应发送 body 与 content-type", async () => { + const p2 = makeAgent(erestA, "post", "/echo", "POST_/echo8"); + await p2.raw(); + const r = last(); + expect(r.body).toBe(""); + expect(r.headers["content-type"]).toBeUndefined(); + }); + + test("DELETE input 应归入 query 而非 body", async () => { + const d = makeAgent(erestA, "delete", "/echo", "DELETE_/echo9"); + await d.input({ id: 5 }).raw(); + const r = last(); + expect(r.method).toBe("DELETE"); + expect(r.url).toBe("/echo?id=5"); + expect(r.body).toBe(""); + expect(r.headers["content-type"]).toBeUndefined(); + }); + + test("attach 后应以 multipart form 发送", async () => { + const f = makeAgent(erestA, "post", "/echo", "POST_/echo10"); + await f.attach({ field: "v" }).raw(); + const r = last(); + expect(r.headers["content-type"]).toContain("multipart/form-data"); + expect(r.body).toContain('name="field"'); + }); + + test("cookieHeader 回调返回值应注入请求 cookie 头", async () => { + const c = makeAgent(erestA, "get", "/echo", "GET_/echo11", () => "sid=abc"); + await c.raw(); + expect(last().headers.cookie).toBe("sid=abc"); + }); + + test("单条 Set-Cookie 响应头应透传给 onResponse", async () => { + const onResponse = vi.fn(); + const a = makeAgent(erestA, "get", "/set-cookie", "GET_/set-cookie", undefined, onResponse); + await a.raw(); + expect(onResponse).toHaveBeenCalledTimes(1); + const headers = onResponse.mock.calls[0][0] as Record; + expect(headers["set-cookie"]).toBe("sid=abc; Path=/"); + }); + + test("非 JSON 响应(合法 JSON 文本的 text/plain)body 应保持 {}", async () => { + const k = makeAgent(erestA, "get", "/plain-json", "GET_/plain-json"); + const raw = (await k.raw()) as unknown as { + status: number; + text: string; + body: unknown; + headers: Record; + }; + expect(raw.status).toBe(200); + expect(raw.text).toBe('{"a":1}'); + expect(raw.body).toEqual({}); + expect(raw.headers["content-type"]).toBe("text/plain"); + }); + + test("JSON 响应应解析为对象并保留全部响应头", async () => { + const j = makeAgent(erestA, "get", "/echo", "GET_/echo12"); + const raw = (await j.raw()) as unknown as { + status: number; + body: Record; + headers: Record; + }; + expect(raw.status).toBe(200); + expect(raw.body.url).toBe("/echo"); + expect(raw.headers["content-type"]).toBe("application/json"); + }); + + test("损坏的 JSON 响应应保留原始文本,body 回退为空对象", async () => { + const a = makeAgent(erestA, "get", "/invalid-json", "GET_/invalid-json"); + const raw = (await a.raw()) as { status: number; text: string; body: unknown }; + expect(raw.status).toBe(200); + expect(raw.text).toBe('{"unfinished":'); + expect(raw.body).toEqual({}); + }); + + test("请求后应把对应 API 标记为 tested", async () => { + const apiIns = erestA.api.$apis.get("GET_/echo-t")!; + expect(apiIns.options.tested).toBe(false); + const t = makeAgent(erestA, "get", "/echo-t", "GET_/echo-t"); + await t.raw(); + expect(apiIns.options.tested).toBe(true); + }); + + test("未注册 key 的请求应正常完成(不写 tested 标记)", async () => { + const nx = makeAgent(erestA, "get", "/nx2", "GET_/nx2"); + const raw = (await nx.raw()) as unknown as { status: number }; + expect(raw.status).toBe(200); + }); + + test("success 应保存 takeExample 记录(含 headers/input/output)", async () => { + const n = makeAgent(erestA, "get", "/echo-t", "GET_/echo-t"); + await n.headers({ h: "1" }).input({ a: 1 }).takeExample("ExSuccess").success(); + const ex = erestA.api.$apis.get("GET_/echo-t")!.options.examples; + expect(ex.length).toBe(1); + expect(ex[0].name).toBe("ExSuccess"); + expect(ex[0].path).toBe("/echo-t?a=1"); + expect(ex[0].headers).toEqual({ h: "1" }); + expect(ex[0].input).toEqual({ a: 1 }); + expect((ex[0].output as Record).url).toBe("/echo-t?a=1"); + }); + + test("error 对成功响应应返回含提示的 Error 并保存 example", async () => { + const o1 = makeAgent(erestA, "get", "/echo", "GET_/echo13"); + const err1 = (await o1.error()) as Error; + expect(err1).toBeInstanceOf(Error); + expect(err1.message).toContain("期望API输出失败结果,但实际输出成功结果"); + + const o2 = makeAgent(erestA, "get", "/echo-err", "GET_/echo-err"); + await o2.takeExample("ExError").error(); + const ex2 = erestA.api.$apis.get("GET_/echo-err")!.options.examples; + expect(ex2.length).toBe(1); + expect(ex2[0].name).toBe("ExError"); + }); + + test("error 应透传 formatOutputReverse 返回的错误", async () => { + const erestB = lib(); + erestB.api + .get("/boom") + .group("Index") + .register(() => {}); + erestB.api.formatOutputReverse = () => [new Error("boom"), null]; + const b = makeAgent(erestB, "get", "/boom", "GET_/boom"); + const errB = (await b.error()) as Error; + expect(errB.message).toBe("boom"); + }); + + test("debug 通道应记录链式操作与请求方法", async () => { + // 用 POST:GET 携带 attach 的 form body 会被 fetch 拒绝 + const a = makeAgent(erestA, "post", "/dbg", "POST_/dbg"); + const dbg = vi.fn(); + a.debug = dbg as never; + a.takeExample("D1"); + a.headers({ d: "1" }); + a.query({ q: 1 }); + a.input({ i: 1 }); + a.attach({ f: "v" }); + (a as unknown as { saveExample: () => void }).saveExample(); + await a.raw(); + await a.success().catch(() => {}); + const firstArgs = dbg.mock.calls.map((c) => c[0] as string); + expect(firstArgs).toContain("takeExample: %s"); + expect(firstArgs).toContain("headers: %j"); + expect(firstArgs).toContain("query: %j"); + expect(firstArgs).toContain("input: %j"); + expect(firstArgs).toContain("attach: %j"); + expect(firstArgs).toContain("Save Example: %o"); + expect(firstArgs).toContain("raw"); + expect(firstArgs).toContain("success"); + }); + + test("success 失败时应以带颜色与完整深度的 inspect 输出错误详情", async () => { + const erestC = lib(); + erestC.api + .get("/deep") + .group("Index") + .register(() => {}); + erestC.api.formatOutputReverse = () => + [{ l1: { l2: { l3: "DEEPVAL" } } }, null] as unknown as [Error | null, unknown]; + const c = makeAgent(erestC, "get", "/deep", "GET_/deep"); + let msg = ""; + await c.success().catch((e: Error) => { + msg = e.message; + }); + expect(msg).toContain("期望API输出成功结果,但实际输出失败结果"); + // depth: 5 应展开第三层字段 + expect(stripAnsi(msg)).toContain("DEEPVAL"); + // colors: true 应包含 ANSI 颜色码(ESC + [) + expect(msg).toContain(ESC + "["); + }); +}); + +// ---- extend/test.ts 补测(IAPITest 请求引擎与 session cookie) ---- + +/** 构造记录请求的 echo 处理器(函数形态 app → http.createServer 包装,随机端口) */ +function echoHandler(log?: Array<{ method: string; url: string; cookie?: string }>) { + return (req: IncomingMessage, res: ServerResponse) => { + const cookie = req.headers.cookie; + if (log) log.push({ method: req.method!, url: req.url!, cookie }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ method: req.method, url: req.url, cookie: cookie ?? null })); + }; +} + +/** + * 等价于 initTest(app) 的最小装配:直接构造 IAPITest 并注入 app/testAgent, + * 避免触发 initTest → genDocs → saveOnExit 的 process exit 监听器堆积。 + * (initTest 属于 index.ts,不是本文件的变异目标) + */ +function setupTestApp(svc: ReturnType, app: unknown) { + const impl = new IAPITest(svc as never, "/tmp"); + (svc as unknown as { app: unknown }).app = app; + (svc as unknown as { testAgent: unknown }).testAgent = impl; + return impl; +} + +describe("extend/test.ts: 请求引擎", () => { + test("GET/POST 真实请求命中随机端口服务器并解析 JSON 响应", async () => { + const svc = lib(); + svc.api.get("/echo").title("e"); + svc.api.post("/submit").title("s"); + const test = setupTestApp(svc, echoHandler()); + + const got = (await test.get("/api/echo").success()) as Record; + expect(got.method).toBe("GET"); + expect(got.url).toBe("/api/echo"); + + const posted = (await test.post("/api/submit").input({ a: 1 }).success()) as Record; + expect(posted.method).toBe("POST"); + expect(posted.url).toBe("/api/submit"); + }); + + test("findApi 精确 key 命中优先于路径正则回退(tested 标记落在正确的 API 上)", async () => { + const svc = lib(); + svc.api.get("/users/:id").title("byId"); // 先注册:正则可匹配 /users/me + svc.api.get("/users/me").title("me"); + const test = setupTestApp(svc, echoHandler()); + + const out = (await test.get("/api/users/me").success()) as Record; + expect(out.url).toBe("/api/users/me"); + const byId = svc.api.$apis.get("GET_/users/:id")!; + const me = svc.api.$apis.get("GET_/users/me")!; + expect(me.options.tested).toBe(true); + expect(byId.options.tested).toBe(false); + }); + + test("未 initTest 设置 app 时:get 与 session 断言失败并给出中文提示", () => { + const svc = lib(); + svc.api.get("/x").title("x"); + const t = new IAPITest(svc as never, "/tmp"); + expect(() => t.get("/api/x")).toThrowError("请先调用 initTest(app) 设置 app 实例"); + expect(() => t.session()).toThrowError("请先调用 initTest(app) 设置 app 实例"); + }); + + test("session 创建后 app 被清空:首次请求时 getTarget 断言失败", async () => { + const svc = lib(); + svc.api.get("/y").title("y"); + const test = setupTestApp(svc, echoHandler()); + const session = test.session(); + (svc as unknown as { app?: unknown }).app = undefined; + await expect(session.get("/api/y").success()).rejects.toThrow(/请先调用 initTest\(app\) 设置 app 实例/); + }); + + test("测试目标懒初始化并缓存:一次请求只归一化一次 app", async () => { + const svc = lib(); + svc.api.get("/cache").title("c"); + const handler = echoHandler(); + const expressLike = { + listen: vi.fn(() => createServer(handler)), + handle: () => {}, + }; + const test = setupTestApp(svc, expressLike); + const out = (await test.get("/api/cache").success()) as Record; + expect(out.url).toBe("/api/cache"); + expect(expressLike.listen).toHaveBeenCalledTimes(1); + }); + + test("未 listen 的 http.Server:getBaseUrl 等待 ready 后返回 baseUrl,真实请求可用", async () => { + const svc = lib(); + svc.api.get("/ready").title("r"); + const server = createServer(echoHandler()); + const test = setupTestApp(svc, server); + // 直接调用注入给 TestAgent 的 baseUrl 提供者:必须等待 server listening 后才取地址 + const baseUrl = await (test as unknown as { getBaseUrl: () => Promise }).getBaseUrl(); + expect(baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + const res = (await test.get("/api/ready").success()) as Record; + expect(res.url).toBe("/api/ready"); + expect(res.method).toBe("GET"); + }); +}); + +describe("extend/test.ts: session cookie", () => { + /** 构造全新 session(cookie jar 位于 IAPITest 实例上,用例间需隔离) */ + function freshSession(handler: (req: IncomingMessage, res: ServerResponse) => void = echoHandler()) { + const svc = lib(); + svc.api.get("/ping").title("p"); + return setupTestApp(svc, handler).session(); + } + + test("无 cookie 时 getCookieHeader 返回 undefined", () => { + const session = freshSession(); + expect(session.$agent.getCookieHeader()).toBeUndefined(); + }); + + test("updateCookies 解析矩阵:分隔/日期逗号/无空格逗号/空白修剪", () => { + const session = freshSession(); + // 1) 常规:逗号+单空格分隔多个 Set-Cookie(多字符 cookie 名) + session.$agent.updateCookies({ "set-cookie": "sid=abc; Path=/, token=xyz; Path=/" }); + expect(session.$agent.getCookieHeader()).toBe("sid=abc; token=xyz"); + // 2) 逗号后无空格同样切分 + session.$agent.updateCookies({ "set-cookie": "c=3,d=4" }); + expect(session.$agent.getCookieHeader()).toBe("sid=abc; token=xyz; c=3; d=4"); + // 3) Expires 日期中的逗号不切分,其后新 cookie 正确切分 + session.$agent.updateCookies({ + "set-cookie": "e=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT, f=2", + }); + expect(session.$agent.getCookieHeader()).toBe("sid=abc; token=xyz; c=3; d=4; e=1; f=2"); + }); + + test("updateCookies:无法解析的段(缺少 = 前的名字 / 空白名)不写入 jar", () => { + const s1 = freshSession(); + s1.$agent.updateCookies({ "set-cookie": "a; b=2" }); + expect(s1.$agent.getCookieHeader()).toBeUndefined(); + + const s2 = freshSession(); + s2.$agent.updateCookies({ "set-cookie": " =1" }); + expect(s2.$agent.getCookieHeader()).toBeUndefined(); + }); + + test("updateCookies:name 与 value 两侧空白被 trim", () => { + const session = freshSession(); + session.$agent.updateCookies({ "set-cookie": " a = 1 ; Path=/" }); + expect(session.$agent.getCookieHeader()).toBe("a=1"); + }); + + test("真实请求链路:Set-Cookie 写入 jar,后续请求自动携带 cookie 头", async () => { + const seen: Array<{ cookie?: string }> = []; + const handler = (req: IncomingMessage, res: ServerResponse) => { + if (req.url === "/api/login") { + res.writeHead(200, { + "content-type": "application/json", + // 单个头内逗号合并形式(代理/网关常见形态,正是 updateCookies 切分逻辑的适用场景) + "set-cookie": "sid=abc; Path=/, token=xyz; Path=/", + }); + res.end(JSON.stringify({ ok: true })); + return; + } + seen.push({ cookie: req.headers.cookie }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ cookie: req.headers.cookie ?? null, url: req.url })); + }; + const svc = lib(); + svc.api.get("/login").title("l"); + svc.api.get("/whoami").title("w"); + const session = setupTestApp(svc, handler).session(); + const login = (await session.get("/api/login").success()) as Record; + expect(login).toEqual({ ok: true }); + expect(session.$agent.getCookieHeader()).toBe("sid=abc; token=xyz"); + + const who = (await session.get("/api/whoami").success()) as Record; + expect(who.cookie).toBe("sid=abc; token=xyz"); + expect(seen[seen.length - 1].cookie).toBe("sid=abc; token=xyz"); + }); +}); + +// ---- test-server.ts:normalizeTestTarget 与地址归一化 ---- + +const serversToClose: Server[] = []; +const onceListening = (server: Server) => new Promise((resolve) => server.once("listening", resolve)); + +afterAll(() => { + for (const server of serversToClose) server.close(); +}); + +describe("test-server: normalizeTestTarget 不支持的形态", () => { + test("null / {} / 数字 / undefined 抛『不支持的 app 形态』", () => { + expect(() => normalizeTestTarget(null)).toThrow(/不支持的 app 形态:object/); + expect(() => normalizeTestTarget({})).toThrow(/不支持的 app 形态:object/); + expect(() => normalizeTestTarget(123)).toThrow(/不支持的 app 形态:number/); + expect(() => normalizeTestTarget(undefined)).toThrow(/不支持的 app 形态:undefined/); + }); + + test("只有 handle 没有 listen 的对象 → 不支持", () => { + expect(() => normalizeTestTarget({ handle: () => {} })).toThrow(/不支持的 app 形态/); + }); + + test("只有 address 没有 listen 的对象 → 不支持(不被误判为 http.Server)", () => { + expect(() => normalizeTestTarget({ address: () => ({}) })).toThrow(/不支持的 app 形态/); + }); + + test("listen 为函数但 handle 不是 → 不支持", () => { + const realServer = createServer(); + serversToClose.push(realServer); + expect(() => normalizeTestTarget({ listen: () => realServer, handle: "not-fn" })).toThrow(/不支持的 app 形态/); + }); + + test("带 listen/address 属性的 function 走函数分支(不被误判为 http.Server)", () => { + const fn = ((req: unknown, res: unknown) => { + (res as { end: (s: string) => void }).end("fn"); + }) as (() => void) & { listen?: unknown; address?: unknown }; + fn.listen = () => {}; + fn.address = () => ({}); + const target = normalizeTestTarget(fn); + expect(target.server).toBeInstanceOf(Server); + serversToClose.push(target.server!); + }); +}); + +describe("test-server: normalizeTestTarget 支持的形态", () => { + test("Express 形态(listen + handle):listen(0) 后经 startServer 暴露 baseUrl", async () => { + const inner = createServer((req, res) => res.end("express-like")); + serversToClose.push(inner); + let listenPort: unknown; + const expressLike = { + listen: (p: unknown) => { + listenPort = p; + return inner; + }, + handle: () => {}, + }; + const target = normalizeTestTarget(expressLike); + expect(listenPort).toBe(0); + expect(target.server).toBe(inner); + await (target as { ready: () => Promise }).ready(); + const { port } = inner.address() as AddressInfo; + expect(target.baseUrl).toBe(`http://127.0.0.1:${port}`); + }); + + test("Express 形态:listen 返回无 unref 的裸对象也不抛错", () => { + expect(() => + normalizeTestTarget({ + listen: () => ({ listen: () => {} }), + handle: () => {}, + }) + ).not.toThrow(); + }); + + test("普通函数:包装为 http.Server", async () => { + const target = normalizeTestTarget((() => {}) as never); + expect(target.server).toBeInstanceOf(Server); + await (target as { ready: () => Promise }).ready(); + serversToClose.push(target.server!); + }); + + test("未 listen 的 http.Server:经 startServer 启动到随机端口", async () => { + const server = createServer(); + const target = normalizeTestTarget(server); + expect(target.server).toBe(server); + await (target as { ready: () => Promise }).ready(); + const { port } = server.address() as AddressInfo; + expect(target.baseUrl).toBe(`http://127.0.0.1:${port}`); + serversToClose.push(server); + }); + + test("已 listen 的 http.Server:直接取地址且不接管关闭职责", async () => { + const server = createServer(); + server.listen(0, "127.0.0.1"); + serversToClose.push(server); + await onceListening(server); + const { port } = server.address() as AddressInfo; + const target = normalizeTestTarget(server); + expect(target.server).toBeUndefined(); + expect(target.baseUrl).toBe(`http://127.0.0.1:${port}`); + }); + + test("server 关闭后读取 baseUrl:address() 为 null → 尚未 listening 抛错", async () => { + const target = normalizeTestTarget((() => {}) as never); + await (target as { ready: () => Promise }).ready(); + await new Promise((resolve) => target.server!.close(() => resolve())); + expect(() => target.baseUrl).toThrow(/尚未 listening/); + }); +}); + +describe("test-server: 地址归一化(addressToUrl)", () => { + test("通配 :: → 127.0.0.1", async () => { + const server = createServer(); + server.listen(0); + serversToClose.push(server); + await onceListening(server); + const { port } = server.address() as AddressInfo; + expect(normalizeTestTarget(server).baseUrl).toBe(`http://127.0.0.1:${port}`); + }); + + test("通配 0.0.0.0 → 127.0.0.1", async () => { + const server = createServer(); + server.listen(0, "0.0.0.0"); + serversToClose.push(server); + await onceListening(server); + const { port } = server.address() as AddressInfo; + expect(normalizeTestTarget(server).baseUrl).toBe(`http://127.0.0.1:${port}`); + }); + + test("非通配 IPv6(::1)→ 保留并加方括号", async () => { + const server = createServer(); + server.listen(0, "::1"); + serversToClose.push(server); + await onceListening(server); + const { port } = server.address() as AddressInfo; + expect(normalizeTestTarget(server).baseUrl).toBe(`http://[::1]:${port}`); + }); + + test("unix socket 地址(string)→ 127.0.0.1:0", async () => { + const server = createServer(); + const sockPath = `/tmp/erest-mutation-${process.pid}-${Date.now()}.sock`; + server.listen(sockPath); + serversToClose.push(server); + await onceListening(server); + const target = normalizeTestTarget(server); + expect(target.server).toBeUndefined(); + expect(target.baseUrl).toBe("http://127.0.0.1:0"); + }); +}); + +// ============ utils.ts ============ diff --git a/src/test/test-zod-meta.ts b/src/test/test-zod-meta.ts index 6748893..470ef64 100644 --- a/src/test/test-zod-meta.ts +++ b/src/test/test-zod-meta.ts @@ -9,7 +9,18 @@ */ import { describe, expect, test } from "vitest"; import { z } from "zod"; -import { extractDocFields, getZodInner, getZodShape, getZodTypeName } from "../lib/plugin/zod-meta"; +import { + extractDocFields, + getZodDefaultValue, + getZodElement, + getZodEnumValues, + getZodInner, + getZodLazyGetter, + getZodOptions, + getZodShape, + getZodTypeName, + resolveZodShape, +} from "../lib/plugin/zod-meta"; describe("zod-meta: getZodTypeName", () => { test("读取各基础类型的规范化类型名", () => { @@ -163,3 +174,213 @@ describe("zod-meta: extractDocFields", () => { expect(extractDocFields(z.string(), "body")).toEqual([]); }); }); + +// ============ 以下为 Stryker mutant 补测(源自 test-mutation-params-misc.ts 迁移) ============ + +/** 构造带任意 _def 的假 schema(顶层需通过 isZodSchema:有 _def + parse 函数) */ +const fakeSchema = (def: unknown) => ({ _def: def, parse: () => ({ success: true }) }) as never; + +/** 构造带指定 shape 的假 ZodObject */ +const fakeObject = (shape: Record) => fakeSchema({ type: "object", shape }); + +/** 从假对象提取单字段(便捷断言) */ +const oneField = (fieldDef: unknown, place = "body") => { + const [field] = extractDocFields(fakeObject({ f: { _def: fieldDef } }), place); + return field; +}; + +describe("zod-meta: getEnumEntries 双轨(values/entries)", () => { + test("Zod 4 entries 对象 → Object.keys", () => { + expect(oneField({ type: "enum", entries: { RED: 0, GREEN: 1 } }).enumValues).toEqual(["RED", "GREEN"]); + }); + + test("entries 为非对象(字符串)时忽略,回退 values → undefined", () => { + const field = oneField({ type: "enum", entries: "abc" }); + expect(field.type).toBe("enum"); + expect(field.enumValues).toBeUndefined(); + }); + + test("Zod 3 values 数组 → map(String)", () => { + expect(oneField({ type: "enum", values: ["x", "y"] }).enumValues).toEqual(["x", "y"]); + }); + + test("Zod 3 values 对象(nativeEnum 形态)→ Object.keys", () => { + expect(oneField({ type: "enum", values: { A: 1, B: 2 } }).enumValues).toEqual(["A", "B"]); + }); + + test("Zod 3 typeName=ZodEnum + entries → enum 类型 + 取值", () => { + const field = oneField({ typeName: "ZodEnum", entries: { RED: 0 } }); + expect(field.type).toBe("enum"); + expect(field.enumValues).toEqual(["RED"]); + }); + + test("真实 z.enum 仍正常", () => { + const [field] = extractDocFields(z.object({ c: z.enum(["a", "b"]) }), "query"); + expect(field.type).toBe("enum"); + expect(field.enumValues).toEqual(["a", "b"]); + }); +}); + +describe("zod-meta: inferTypeName 字面量/包装类型分支", () => { + test("literal → string(Zod 3 与 Zod 4 双轨)", () => { + expect(oneField({ typeName: "ZodLiteral" }).type).toBe("string"); + expect(oneField({ type: "literal" }).type).toBe("string"); + const [field] = extractDocFields(z.object({ l: z.literal("v") }), "body"); + expect(field.type).toBe("string"); + }); + + test("ZodDate → date + format date-time(Zod 3 大驼峰)", () => { + const field = oneField({ typeName: "ZodDate" }); + expect(field.type).toBe("date"); + expect(field.format).toBe("date-time"); + }); + + test("optional/default/nullable 递归内层类型(Zod 3 大驼峰)", () => { + expect(oneField({ typeName: "ZodOptional", innerType: z.string() }).type).toBe("string"); + expect(oneField({ typeName: "ZodDefault", innerType: z.number() }).type).toBe("number"); + expect(oneField({ typeName: "ZodNullable", innerType: z.boolean() }).type).toBe("boolean"); + }); + + test("真实 zod4 default 字段类型取内层(不再 'default')", () => { + const [field] = extractDocFields(z.object({ d: z.string().default("x") }), "body"); + expect(field.type).toBe("string"); + expect(field.required).toBe(false); + }); + + test("effects/pipeline 递归输入 schema(Zod 3 大驼峰 + Zod 4 小写)", () => { + expect(oneField({ typeName: "ZodEffects", schema: z.number() }).type).toBe("number"); + expect(oneField({ type: "effects", in: z.date() }).type).toBe("date"); + expect(oneField({ typeName: "ZodPipeline", in: z.array(z.string()) }).type).toBe("array"); + expect(oneField({ type: "pipe", in: z.string() }).type).toBe("string"); + }); + + test("union 取首个分支类型(Zod 3 大驼峰)", () => { + expect(oneField({ typeName: "ZodUnion", options: [z.number(), z.string()] }).type).toBe("number"); + }); + + test("optional 无 innerType → unknown(不抛错)", () => { + expect(oneField({ type: "optional" }).type).toBe("unknown"); + expect(oneField({ typeName: "ZodDefault" }).type).toBe("unknown"); + }); + + test("effects/pipe 无目标 schema → unknown(不抛错)", () => { + expect(oneField({ type: "pipe" }).type).toBe("unknown"); + expect(oneField({ typeName: "ZodEffects" }).type).toBe("unknown"); + }); + + test("union 无 options 或空 options → unknown(不抛错)", () => { + expect(oneField({ type: "union" }).type).toBe("unknown"); + expect(oneField({ type: "union", options: [] }).type).toBe("unknown"); + }); + + test("字段 schema 缺 _def → unknown(不抛 TypeError)", () => { + const [field] = extractDocFields(fakeObject({ f: {} }), "body"); + expect(field.type).toBe("unknown"); + expect(field.required).toBe(true); + }); +}); + +describe("zod-meta: inferTypeName default 兜底归一化", () => { + test("未识别的 Zod 4 小写类型名原样返回", () => { + const [field] = extractDocFields(z.object({ big: z.bigint() }), "body"); + expect(field.type).toBe("bigint"); + }); + + test("Zod 3 大驼峰类型名去掉 Zod 前缀 + 小写", () => { + expect(oneField({ typeName: "ZodBigInt" }).type).toBe("bigint"); + }); + + test("仅去掉开头的 Zod 前缀(正则锚定 ^)", () => { + expect(oneField({ typeName: "MyZodType" }).type).toBe("myzodtype"); + }); + + test("typeName 缺失 → unknown", () => { + expect(oneField({}).type).toBe("unknown"); + }); +}); + +describe("zod-meta: 空对象与形状读取", () => { + test("getZodShape:缺 _def / shape 为函数 → undefined(不抛错)", () => { + expect(getZodShape({})).toBeUndefined(); + expect(getZodShape(fakeSchema({ shape: () => ({}) }))).toBeUndefined(); + }); + + test("getZodInner:缺 _def → undefined(不抛错)", () => { + expect(getZodInner({} as never)).toBeUndefined(); + }); + + test("extractDocFields:非对象 schema 返回 []", () => { + expect(extractDocFields(z.string(), "body")).toEqual([]); + expect(extractDocFields({ parse: () => ({}) } as never, "body")).toEqual([]); + }); + + test("extractDocFields 字段全形状(含 Zod 3 typeName)", () => { + const fields = extractDocFields(fakeObject({ s: { _def: { typeName: "ZodString" } } }), "query"); + expect(fields).toStrictEqual([ + { + name: "s", + place: "query", + type: "string", + required: true, + comment: undefined, + enumValues: undefined, + format: undefined, + }, + ]); + }); +}); + +describe("zod-meta: 新增访问器(_def 读取集中的配套导出)", () => { + const obj = z.object({ a: z.string(), b: z.number().optional() }); + // Zod 3 风格的函数形态 shape(getter 惰性求值) + const fnShapeObj = { _def: { typeName: "ZodObject", shape: () => ({ x: { _def: { typeName: "ZodString" } } }) } }; + + test("resolveZodShape:对象形态直接返回,函数形态调用求值,非对象返回 undefined", () => { + expect(Object.keys(resolveZodShape(obj) ?? {})).toEqual(["a", "b"]); + expect(Object.keys(resolveZodShape(fnShapeObj) ?? {})).toEqual(["x"]); + expect(resolveZodShape(z.string())).toBeUndefined(); + expect(resolveZodShape(undefined)).toBeUndefined(); + // getZodShape(保守版)对函数形态返回 undefined,与 resolve 行为形成对照 + expect(getZodShape(fnShapeObj)).toBeUndefined(); + }); + + test("getZodElement:读取数组元素 schema,非数组返回 undefined", () => { + expect(getZodTypeName(getZodElement(z.array(z.string())))).toBe("string"); + expect(getZodElement(z.object({}))).toBeUndefined(); + expect(getZodElement(undefined)).toBeUndefined(); + }); + + test("getZodEnumValues:Zod 4 entries / Zod 3 values 数组 / values 对象 / 非枚举 undefined", () => { + expect(getZodEnumValues(z.enum(["red", "green"]))).toEqual(["red", "green"]); + expect(getZodEnumValues({ _def: { typeName: "ZodEnum", values: ["a", "b"] } })).toEqual(["a", "b"]); + expect(getZodEnumValues({ _def: { typeName: "ZodEnum", values: { A: 1, B: 2 } } })).toEqual(["A", "B"]); + expect(getZodEnumValues(z.string())).toBeUndefined(); + expect(getZodEnumValues(undefined)).toBeUndefined(); + }); + + test("getZodOptions:读取 union 分支,非 union 返回 undefined", () => { + const opts = getZodOptions(z.union([z.string(), z.number()])); + expect(opts?.map((o) => getZodTypeName(o))).toEqual(["string", "number"]); + expect(getZodOptions(z.object({}))).toBeUndefined(); + expect(getZodOptions(undefined)).toBeUndefined(); + }); + + test("getZodDefaultValue:读取默认值(含 falsy 0/false),函数默认值原样返回", () => { + expect(getZodDefaultValue(z.number().default(0))).toBe(0); + expect(getZodDefaultValue(z.boolean().default(false))).toBe(false); + expect(getZodDefaultValue(z.string().default("x"))).toBe("x"); + // zod 4 的 .default(fn) 立即求值存结果;函数形态仅 Zod 3 伪造结构可达 + expect(getZodDefaultValue(z.string().default(() => "lazy"))).toBe("lazy"); + const fn = () => "lazy"; + expect(getZodDefaultValue({ _def: { typeName: "ZodDefault", defaultValue: fn } })).toBe(fn); + expect(getZodDefaultValue(z.string())).toBeUndefined(); + expect(getZodDefaultValue(undefined)).toBeUndefined(); + }); + + test("getZodLazyGetter:读取 lazy 的 getter,非 lazy 返回 undefined", () => { + const inner = z.object({ a: z.string() }); + expect(getZodTypeName(getZodLazyGetter(z.lazy(() => inner))?.())).toBe("object"); + expect(getZodLazyGetter(z.string())).toBeUndefined(); + expect(getZodLazyGetter(undefined)).toBeUndefined(); + }); +}); diff --git a/src/test/test-zod-native.ts b/src/test/test-zod-native.ts index ee6d11a..cf2f358 100644 --- a/src/test/test-zod-native.ts +++ b/src/test/test-zod-native.ts @@ -1,6 +1,13 @@ -import { z } from "zod"; +import { z, type ZodType } from "zod"; import API from "../lib/api"; -import { isZodSchema } from "../lib/params"; +import { + compileValidate, + isZodSchema, + responseChecker, + zodTypeMap, + anyObject, + type ValidationErrorFactory, +} from "../lib/params"; import lib from "./lib"; // 创建测试用的 ERest 实例 @@ -70,3 +77,317 @@ describe("Zod Native Support Tests", () => { }); }); }); + +// ============ 以下为 Stryker mutant 补测(源自 test-mutation-params-misc.ts 迁移) ============ + +/** 精确错误工厂 stub:断言 compileValidate 传给工厂的消息内容 */ +const validationErrorFactory: ValidationErrorFactory = { + missingParameter: (msg) => new Error(`MISSING:${msg}`), + invalidParameter: (msg) => new Error(`INVALID:${msg}`), +}; + +/** 构造 safeParse 恒失败、issue 形状可控的假 schema(覆盖 isMissing 各分支) */ +const failWith = (issue: unknown): ZodType => + ({ safeParse: () => ({ success: false, error: { issues: [issue] } }) }) as unknown as ZodType; + +describe("params: isZodSchema 组合条件", () => { + test("缺 _def 但有 parse 的对象返回 false", () => { + expect(isZodSchema({ parse: () => ({}) })).toBe(false); + }); + + test("_def 为 null/undefined 时返回 false(有 parse 也不行)", () => { + expect(isZodSchema({ _def: null, parse: () => ({}) })).toBe(false); + expect(isZodSchema({ _def: undefined, parse: () => ({}) })).toBe(false); + }); + + test("有 _def 但缺 parse 方法返回 false", () => { + expect(isZodSchema({ _def: { type: "string" } })).toBe(false); + }); + + test("函数输入返回 false", () => { + expect(isZodSchema(() => {})).toBe(false); + }); +}); + +describe("params: compileValidate 错误路径(isMissing 分支)", () => { + test("缺失字段(message 含 received undefined)→ missingParameter", () => { + const compiled = compileValidate(validationErrorFactory, { bodySchema: z.object({ name: z.string() }) }); + expect(() => compiled.validate({ body: {} })).toThrow("MISSING:'name'"); + }); + + test("类型错误(message 不含 received undefined)→ invalidParameter", () => { + const compiled = compileValidate(validationErrorFactory, { bodySchema: z.object({ age: z.number() }) }); + expect(() => compiled.validate({ body: { age: "abc" } })).toThrow("INVALID:'age' should be valid"); + }); + + test("嵌套 union 的缺失字段:invalid_union + 分支 errors 均含 received undefined → missingParameter", () => { + const schema = z.object({ + u: z.union([z.object({ a: z.string() }), z.object({ b: z.string() })]), + }); + const compiled = compileValidate(validationErrorFactory, { bodySchema: schema }); + expect(() => compiled.validate({ body: {} })).toThrow("MISSING:'u'"); + }); + + test("嵌套 union 的类型错误:分支 errors 不含 received undefined → invalidParameter", () => { + const schema = z.object({ u: z.union([z.string(), z.number()]) }); + const compiled = compileValidate(validationErrorFactory, { bodySchema: schema }); + expect(() => compiled.validate({ body: { u: true } })).toThrow("INVALID:'u' should be valid"); + }); + + test("invalid_union 分支部分含 received undefined(some 语义)→ missingParameter", () => { + const issue = { + code: "invalid_union", + path: ["u"], + errors: [ + [{ message: "Invalid input: expected object, received undefined" }], + [{ message: "Invalid input: expected string, received number" }], + ], + }; + const compiled = compileValidate(validationErrorFactory, { bodySchema: failWith(issue) }); + expect(() => compiled.validate({ body: {} })).toThrow("MISSING:'u'"); + }); + + test("invalid_union 单分支内部分 entry 含 received undefined(some 语义)→ missingParameter", () => { + const issue = { + code: "invalid_union", + path: ["u"], + errors: [[{ message: "first" }, { message: "second: received undefined" }]], + }; + const compiled = compileValidate(validationErrorFactory, { bodySchema: failWith(issue) }); + expect(() => compiled.validate({ body: {} })).toThrow("MISSING:'u'"); + }); + + test("invalid_union 分支 entry 无 message 字段时不抛 TypeError → missingParameter", () => { + const issue = { + code: "invalid_union", + path: ["u"], + errors: [[{}], [{ message: "expected string, received undefined" }]], + }; + const compiled = compileValidate(validationErrorFactory, { bodySchema: failWith(issue) }); + expect(() => compiled.validate({ body: {} })).toThrow("MISSING:'u'"); + }); + + test("invalid_union 分支非数组(无嵌套 issue)→ invalidParameter", () => { + const issue = { code: "invalid_union", path: ["u"], errors: [42, "not-array"] }; + const compiled = compileValidate(validationErrorFactory, { bodySchema: failWith(issue) }); + expect(() => compiled.validate({ body: {} })).toThrow("INVALID:'u' should be valid"); + }); + + test("非 invalid_union 的 code 即使带 received undefined errors 也走 invalidParameter", () => { + const issue = { + code: "custom", + message: "boom", + path: ["x"], + errors: [[{ message: "received undefined" }]], + }; + const compiled = compileValidate(validationErrorFactory, { bodySchema: failWith(issue) }); + expect(() => compiled.validate({ body: {} })).toThrow("INVALID:'x' should be valid"); + }); + + test("code 为空字符串时不匹配 invalid_union → invalidParameter", () => { + const issue = { code: "", path: ["x"], errors: [[{ message: "received undefined" }]] }; + const compiled = compileValidate(validationErrorFactory, { bodySchema: failWith(issue) }); + expect(() => compiled.validate({ body: {} })).toThrow("INVALID:'x' should be valid"); + }); + + test("issue 无 message 字段时不抛 TypeError → invalidParameter", () => { + const issue = { code: "custom", path: ["x"] }; + const compiled = compileValidate(validationErrorFactory, { bodySchema: failWith(issue) }); + expect(() => compiled.validate({ body: {} })).toThrow("INVALID:'x' should be valid"); + }); + + test("issue path 为空时字段名回退为 'value'", () => { + const compiled = compileValidate(validationErrorFactory, { bodySchema: z.string() }); + expect(() => compiled.validate({ body: 123 })).toThrow("INVALID:'value' should be valid"); + }); + + test("headers 层 schema:缺失与通过", () => { + const compiled = compileValidate(validationErrorFactory, { headersSchema: z.object({ token: z.string() }) }); + expect(() => compiled.validate({ headers: {} })).toThrow("MISSING:'token'"); + expect(compiled.validate({ headers: { token: "t" } }).headers).toEqual({ token: "t" }); + }); + + test("四层 schema 同时校验", () => { + const compiled = compileValidate(validationErrorFactory, { + paramsSchema: z.object({ id: z.string() }), + querySchema: z.object({ limit: z.coerce.number() }), + bodySchema: z.object({ name: z.string() }), + headersSchema: z.object({ token: z.string() }), + }); + expect( + compiled.validate({ + params: { id: "a" }, + query: { limit: "3" }, + body: { name: "n" }, + headers: { token: "t" }, + }) + ).toEqual({ + params: { id: "a" }, + query: { limit: 3 }, + body: { name: "n" }, + headers: { token: "t" }, + }); + }); +}); + +describe("params: responseChecker", () => { + test("校验通过返回 ok + success + 解析后的值", () => { + const result = responseChecker(null as never, { a: 1 }, z.object({ a: z.number() })); + expect(result).toEqual({ ok: true, message: "success", value: { a: 1 } }); + }); + + test("value 为 safeParse 输出(含 coerce 转换)", () => { + const result = responseChecker(null as never, { a: "5" }, z.object({ a: z.coerce.number() })); + expect(result.ok).toBe(true); + expect(result.value).toEqual({ a: 5 }); + }); + + test("校验失败:message 为 'path: message' 且 value 保留原始数据", () => { + const data = { a: 1 }; + const result = responseChecker(null as never, data, z.object({ a: z.string() })); + expect(result.ok).toBe(false); + expect(result.message).toBe("a: Invalid input: expected string, received number"); + expect(result.value).toBe(data); + }); + + test("多个 issue 用 '; ' 连接,嵌套 path 用 '.' 连接", () => { + const schema = z.object({ a: z.string(), b: z.object({ c: z.number() }) }); + const result = responseChecker(null as never, { a: 1, b: { c: "x" } }, schema); + expect(result.ok).toBe(false); + expect(result.message).toBe( + "a: Invalid input: expected string, received number; b.c: Invalid input: expected number, received string" + ); + }); + + test("safeParse 抛错时返回 ok:false + 错误消息", () => { + const boom = { + safeParse: () => { + throw new Error("boom"); + }, + } as unknown as ZodType; + const data = { a: 1 }; + const result = responseChecker(null as never, data, boom); + expect(result).toEqual({ ok: false, message: "boom", value: { a: 1 } }); + }); + + test("safeParse 抛无消息错误时回退 'Unknown error'", () => { + const silent = { + safeParse: () => { + throw new Error(""); + }, + } as unknown as ZodType; + const result = responseChecker(null as never, { a: 1 }, silent); + expect(result.ok).toBe(false); + expect(result.message).toBe("Unknown error"); + }); +}); + +describe("params: zodTypeMap 类型别名", () => { + test("string / String / TrimString", () => { + expect(zodTypeMap.string.parse("s")).toBe("s"); + expect(zodTypeMap.String.parse("s")).toBe("s"); + expect(zodTypeMap.TrimString.parse(" x ")).toBe("x"); + }); + + test("number / Number:字符串转数字,NaN 抛错", () => { + expect(zodTypeMap.number.parse(42)).toBe(42); + expect(zodTypeMap.number.parse("42")).toBe(42); + expect(zodTypeMap.Number.parse("42")).toBe(42); + expect(() => zodTypeMap.number.parse("abc")).toThrow(/Invalid number/); + expect(() => zodTypeMap.Number.parse("abc")).toThrow(/Invalid number/); + }); + + test("Integer:整数与整数字符串,小数/非数字字符串抛错", () => { + expect(zodTypeMap.Integer.parse(7)).toBe(7); + expect(zodTypeMap.Integer.parse("12")).toBe(12); + expect(() => zodTypeMap.Integer.parse("1.5")).toThrow(/Invalid integer/); + expect(() => zodTypeMap.Integer.parse("x")).toThrow(/Invalid integer/); + expect(() => zodTypeMap.Integer.parse(1.5)).toThrow(); + }); + + test("Float:数字与浮点字符串", () => { + expect(zodTypeMap.Float.parse(2)).toBe(2); + expect(zodTypeMap.Float.parse("3.5")).toBe(3.5); + expect(() => zodTypeMap.Float.parse("x")).toThrow(/Invalid float/); + }); + + test("boolean / Boolean:布尔与 'true'/'false' 字符串,其余抛错", () => { + expect(zodTypeMap.boolean.parse(true)).toBe(true); + expect(zodTypeMap.boolean.parse("true")).toBe(true); + expect(zodTypeMap.boolean.parse("false")).toBe(false); + expect(zodTypeMap.Boolean.parse("false")).toBe(false); + expect(() => zodTypeMap.boolean.parse("yes")).toThrow(/Invalid boolean/); + expect(() => zodTypeMap.Boolean.parse("yes")).toThrow(/Invalid boolean/); + }); + + test("date / Date:Date 实例与日期字符串", () => { + const d = new Date("2024-01-02T03:04:05.000Z"); + expect(zodTypeMap.date.parse(d)).toBe(d); + expect(zodTypeMap.Date.parse("2024-01-02T03:04:05.000Z")).toEqual(d); + }); + + test("email / url / uuid:格式校验", () => { + expect(zodTypeMap.email.safeParse("a@b.com").success).toBe(true); + expect(zodTypeMap.email.safeParse("not-email").success).toBe(false); + expect(zodTypeMap.url.safeParse("https://a.com").success).toBe(true); + expect(zodTypeMap.URL.safeParse("nope").success).toBe(false); + expect(zodTypeMap.uuid.safeParse("123e4567-e89b-12d3-a456-426614174000").success).toBe(true); + expect(zodTypeMap.uuid.safeParse("x").success).toBe(false); + }); + + test("ENUM:仅接受 placeholder", () => { + expect(zodTypeMap.ENUM.parse("placeholder")).toBe("placeholder"); + expect(zodTypeMap.ENUM.safeParse("other").success).toBe(false); + }); + + test("array / Array / any / Object / JSONString", () => { + expect(zodTypeMap.array.parse([1, "a"])).toEqual([1, "a"]); + expect(zodTypeMap.Array.parse([])).toEqual([]); + expect(zodTypeMap.any.parse(123)).toBe(123); + expect(zodTypeMap.Any.parse("x")).toBe("x"); + expect(zodTypeMap.Object.parse({ k: 1 })).toEqual({ k: 1 }); + expect(zodTypeMap.object.parse({})).toEqual({}); + expect(() => zodTypeMap.object.parse("not-object")).toThrow(); + expect(zodTypeMap.JSON.parse({ a: 1 })).toEqual({ a: 1 }); + expect(zodTypeMap.JSONString.parse("raw")).toBe("raw"); + }); + + test("IntArray:逗号字符串去空格+升序,或整数数组", () => { + expect(zodTypeMap.IntArray.parse("3, 1, 2")).toEqual([1, 2, 3]); + expect(zodTypeMap.IntArray.parse([5, 2])).toEqual([5, 2]); + }); + + test("StringArray:逗号字符串 / 字符串数组 / 任意数组转字符串", () => { + expect(zodTypeMap.StringArray.parse(" a , b ")).toEqual(["a", "b"]); + expect(zodTypeMap.StringArray.parse(["x"])).toEqual(["x"]); + expect(zodTypeMap.StringArray.parse([1, true])).toEqual(["1", "true"]); + }); + + test("NullableString / NullableInteger", () => { + expect(zodTypeMap.NullableString.parse(null)).toBe(null); + expect(zodTypeMap.NullableString.parse("s")).toBe("s"); + expect(zodTypeMap.NullableInteger.parse(null)).toBe(null); + expect(zodTypeMap.NullableInteger.parse("15")).toBe(15); + expect(zodTypeMap.NullableInteger.parse(15)).toBe(15); + expect(() => zodTypeMap.NullableInteger.parse("1.5")).toThrow(/Invalid integer/); + }); + + test("MongoIdString / Alpha / AlphaNumeric / Domain / Ascii / Base64", () => { + expect(zodTypeMap.MongoIdString.safeParse("507f1f77bcf86cd799439011").success).toBe(true); + expect(zodTypeMap.MongoIdString.safeParse("xyz").success).toBe(false); + expect(zodTypeMap.Alpha.safeParse("abc").success).toBe(true); + expect(zodTypeMap.Alpha.safeParse("ab1").success).toBe(false); + expect(zodTypeMap.AlphaNumeric.safeParse("a1").success).toBe(true); + expect(zodTypeMap.AlphaNumeric.safeParse("a-1").success).toBe(false); + expect(zodTypeMap.Domain.parse("example.com")).toBe("example.com"); + expect(zodTypeMap.Ascii.parse("plain")).toBe("plain"); + expect(zodTypeMap.Base64.parse("YQ==")).toBe("YQ=="); + }); + + test("anyObject:等价 z.object({}).catchall(z.unknown()),保留未知字段", () => { + const schema = anyObject(); + expect(schema.parse({ a: 1, b: "x" })).toEqual({ a: 1, b: "x" }); + // 普通 z.object({}) 会剥离未知字段(zod 4 默认 strip) + expect(z.object({}).parse({ a: 1 })).toEqual({}); + }); +}); diff --git a/stryker.config.json b/stryker.config.json new file mode 100644 index 0000000..b8fb4b3 --- /dev/null +++ b/stryker.config.json @@ -0,0 +1,16 @@ +{ + "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", + "testRunner": "vitest", + "plugins": ["@stryker-mutator/vitest-runner"], + "vitest": { + "configFile": "./vitest.config.ts", + "related": false + }, + "mutate": ["src/lib/**/*.ts", "!src/lib/**/*.d.ts"], + "reporters": ["html", "clear-text", "progress", "json"], + "ignorePatterns": ["coverage", "dist", "docs", "examples"], + "incremental": true, + "incrementalFile": "reports/stryker-incremental.json", + "concurrency": 4, + "timeoutMS": 10000 +} diff --git a/vitest.config.ts b/vitest.config.ts index b014269..76c6312 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,23 +1,31 @@ -import { defineConfig } from 'vitest/config' +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + // 源码模式下,adapter 内部的包名导入也必须解析到源码,避免依赖 dist。 + // 未设 ISLIB 的发布产物测试仍走 package.json 的 exports。 + alias: process.env.ISLIB + ? [{ find: /^erest$/, replacement: fileURLToPath(new URL("./src/lib/index.ts", import.meta.url)) }] + : [], + }, test: { globals: true, - environment: 'node', - include: ['src/test/test*.ts'], - setupFiles: ['src/test/setup.ts'], + environment: "node", + include: ["src/test/test*.ts"], + setupFiles: ["src/test/setup.ts"], typecheck: { - tsconfig: './tsconfig.test.json' + tsconfig: "./tsconfig.test.json", }, coverage: { - provider: 'v8', - include: ['src/lib/**/*.ts'], + provider: "v8", + include: ["src/lib/**/*.ts"], thresholds: { branches: 80, functions: 95, lines: 80, - statements: 80 - } - } - } -}) + statements: 80, + }, + }, + }, +});