diff --git a/.cursor/rules/hotime-doc-tdd.mdc b/.cursor/rules/hotime-doc-tdd.mdc new file mode 100644 index 0000000..c7d4a46 --- /dev/null +++ b/.cursor/rules/hotime-doc-tdd.mdc @@ -0,0 +1,32 @@ +--- +description: HoTime Doc-Driven + TDD 总门禁 +alwaysApply: true +--- + +# HoTime 工作流 + +**不可跳过。** Doc-Driven 与 TDD 缺一不可。日常五步:①定位 → ②修改 → ③补/改行为测试并针对性跑至绿 → ④边界 → ⑤回写文档。未测绿或未回写 = **未完成**。中文回复。 + +## 定位 + +- 先锁约 **3~4 个文件**(文档 1~2 + 源码 1~2);跨模块扩到合计约 **4~6**,禁止散改全仓。本次新增/修改的**测试文件计入锁定**。先读相关 docs 再改。口径不清 **先问**。 +- 入口:`docs/README.md`、仓根 `README.md`;测试细则 → `docs/Testing_API测试框架.md` / skill `hotime-tdd-testing`。 + +## 改与测 + +- 改接口、ctr、业务逻辑或测试框架:对应 `*_test` **无则补、有则改**,跑通至绿;**禁**只改实现不补测;**禁全量**;编译不算测完。 +- 样板(`example` 根):`go test ./app/ -count=1 -run 'TestApi/app//'`。可收窄到子用例;**改哪测哪**。用户要求全量或以编译代替行为测时,**明确拒绝**并仍按本条。 + +## 文档 + +- **有旧改旧**;无篇目且确有缺口才新建。禁不查就建、禁同一入口重复建档。旧文只补受影响节;只写最终态;对照源码,禁臆测。 +- 路径用相对 Markdown 链接;**禁** `D:\`、`file:///`、`~/`。新建须回写 `docs/README.md`。 + +## 回复 + +- 先结论;摘要 + 文档路径 + **改动定位(文件·位置·原因)**;禁大段复述 docs。 +- 凡改代码或报完成:末尾必须一行 `【门禁】锁定:… | 补测并跑绿:是/否 | 文档回写:是/否/无 | 新建or旧文:…`;缺一不可。闲聊不要求。 + +## 批量补文档(仅整包) + +一文一题;最少 2 轮自检。日常小改不适用。 diff --git a/docs/README.md b/docs/README.md index b2b3c13..f1757ba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,7 @@ # HoTime 文档索引 +本仓采用 **Doc-Driven + TDD**:行为一变必须回写 docs;改接口必须用 `-run` 精确测试验证(见 [Testing_API测试框架.md](Testing_API测试框架.md) 文首铁律与仓规则 `hotime-doc-tdd`)。 + 推荐按路径阅读,避免同一概念在多份文档里来回找。 ## 阅读路径 @@ -8,7 +10,7 @@ 2. **类型与工具** → [Common_工具类.md](Common_工具类.md) 3. **数据库 ORM** → [HoTimeDB_使用说明.md](HoTimeDB_使用说明.md)(教程)→ [HoTimeDB_API参考.md](HoTimeDB_API参考.md)(速查) 4. **开表 / 代码生成** → [DatabaseDesign_数据库设计规范.md](DatabaseDesign_数据库设计规范.md)(前置)→ [CodeGen_代码生成.md](CodeGen_代码生成.md) -5. **接口测试** → [Testing_API测试框架.md](Testing_API测试框架.md)(日常用 `-run` 单测;全量见文末) +5. **接口测试** → [Testing_API测试框架.md](Testing_API测试框架.md)(日常用 `-run` 单测;局限见文末;全量见文末) 6. **运维** → [GracefulShutdown_优雅停机.md](GracefulShutdown_优雅停机.md)、[Seq_日志集成.md](Seq_日志集成.md) 7. **规划** → [ROADMAP_改进规划.md](ROADMAP_改进规划.md) @@ -22,7 +24,7 @@ | HoTimeDB_API参考 | ORM 方法与条件语法速查 | | DatabaseDesign_数据库设计规范 | 表/字段/COMMENT 规范(CodeGen 输入契约) | | CodeGen_代码生成 | 生成器用法 + codeConfig / rule 配置 | -| Testing_API测试框架 | API 测试;优先 `-run`,全量沉底 | +| Testing_API测试框架 | API 测试、Flows、控制台;优先 `-run`;已知局限 | | GracefulShutdown_优雅停机 | 优雅停机与滚动重启 | | Seq_日志集成 | Seq 结构化日志 | | ROADMAP_改进规划 | 待改进项 | diff --git a/docs/ROADMAP_改进规划.md b/docs/ROADMAP_改进规划.md index ed535fd..3ebcef5 100644 --- a/docs/ROADMAP_改进规划.md +++ b/docs/ROADMAP_改进规划.md @@ -266,6 +266,7 @@ a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) // |--------|--------|------| | 高 | 备注语法优化(`\|` 分隔符) | 待设计 | | 高 | 测试框架:响应字段部分匹配断言 | 待实现 | +| 高 | 测试框架其它缺口(DB 断言、FromCase 无模板失败、Header API 等) | 见 [Testing 已知局限](Testing_API测试框架.md#已知局限与后续) | | 中 | 软删除/日志表支持 | 待设计 | | 低 | 多对多关联表增强 | 待评估 | | 低 | 自动填充字段扩展 | 待评估 | @@ -276,6 +277,7 @@ a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) // | 日期 | 内容 | |------|------| +| 2026-07-13 | 测试框架文档补齐 Flows/控制台;缺口清单链到 Testing | | 2026-01-24 | 初始版本,记录分析结果和待改进项 | | 2026-03-15 | 新增测试框架响应字段断言改进规划(七) | | 2026-03-20 | 完成达梦(DM8)数据库完整支持:Dialect 适配(MERGE INTO Upsert、双引号标识符)、LastInsertId、保留字自动引号、schema 正确处理、代码生成器 DM 兼容、缓存表自动建表、测试框架 DM 适配 | diff --git a/docs/Testing_API测试框架.md b/docs/Testing_API测试框架.md index 83b672d..c5b5b9b 100644 --- a/docs/Testing_API测试框架.md +++ b/docs/Testing_API测试框架.md @@ -14,18 +14,16 @@ - [快速开始](#快速开始) - [运行测试(推荐:-run 单测)](#运行测试推荐-run-单测) - [用例编写范式](#用例编写范式) - - [错误用例编写规范](#1-错误用例编写规范) - - [测试数据准备](#2-测试数据准备) - - [正确请求与响应结构校验](#3-正确请求与响应结构校验) - - [Verify 统一校验](#4-verify-统一校验) - - [简写支持说明](#简写支持说明) - [核心类型](#核心类型) + - [业务流程联调(Flows)](#业务流程联调flows) + - [优雅停测](#优雅停测) - [链式 API 参考](#链式-api-参考) - [事务隔离机制](#事务隔离机制) - [API 调试控制台](#api-调试控制台) - [并发保护与缓存隔离](#并发保护与缓存隔离) - [二进制与非 JSON 响应校验](#二进制与非-json-响应校验) - [全量回归与覆盖率(仅 CI / 发版前)](#全量回归与覆盖率仅-ci--发版前) +- [已知局限与后续](#已知局限与后续) --- @@ -41,8 +39,10 @@ HoTime 内置 API 测试框架,核心能力: | **并发保护** | `testMu` 互斥锁自动保护 `testTx` 单连接,业务代码中的 `go func()` 协程不会导致 `busy buffer` | | **缓存隔离** | 测试启动时自动禁用 DB/Redis 缓存,避免缓存操作与测试事务锁冲突 | | **链式 API** | `a.JSON(...).Post("描述", 期望status)` 一行完成:数据组装 + 请求 + 断言 | -| **覆盖率** | 自动对比路由注册表与测试定义,输出哪些接口已覆盖、哪些未覆盖 | -| **调试控制台** | 按模块生成独立的交互式 API 调试控制台,支持在线测试、认证管理、参数编辑 | +| **Flows** | 多接口业务流程联调;`Group`/`Desc`/`Prep`;`FromCase` 绑定单接口用例并回写 | +| **覆盖率** | 自动对比路由注册表与测试定义(**不含 Flow 步骤**,只统计单接口 path) | +| **调试控制台** | 增量写入 swagger;流程分组 UI;发送结果 cURL 实时同步左侧参数 | +| **优雅停测** | SIGINT/SIGTERM / `RequestStop`:停收新测、跑完当前、回滚事务、保留已落盘文档 | --- @@ -184,15 +184,15 @@ var ProjectTest = ProjTest{ var testApp *TestApp func TestMain(m *testing.M) { - os.Chdir("..") // 确保工作目录为项目根目录 - testApp = NewTestApp("config/config.json", TestProj{ - "app": {Proj: Project, Tests: ProjectTest}, - }) - code := m.Run() - // 以下两行适合 CI / 全量回归;日常用 -run 单测时也会执行,但覆盖率主要在全量时有意义 - testApp.PrintCoverage() - testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt")) - os.Exit(code) + testApp = Test("config/config.json"). + WorkDir(".."). + Swagger("My API", "1.0.0"). + Proj(TestProj{ + "app": {Proj: Project, Tests: ProjectTest}, + }) + // 可选:.Flows(FlowTest{ "checkout": {Desc: "下单支付跑通", Func: checkoutFlow} }) + + os.Exit(testApp.Run(m)) } func TestApi(t *testing.T) { @@ -200,6 +200,8 @@ func TestApi(t *testing.T) { } ``` +`Run(m)` 内部会:注册优雅停测信号 → `m.Run()` → 覆盖率 → Swagger 收尾(接口级增量写入)。 +`WorkDir` / `Swagger` / `Flows` 均可省略;不写 `Swagger` 则不生成调试控制台。 ### 推荐目录结构 ``` @@ -233,7 +235,7 @@ go test ./app/ -v -run "TestApi/app/(user|order)" go test ./app/ -v -run "TestApi/.*/.*未登录" ``` -> **注意**:`TestMain` 中的 `os.Chdir("..")` 确保 `go test ./app/` 的工作目录为项目根目录,使配置文件路径和模板输出路径正确。 +> **注意**:`WorkDir("..")` 在 Init 前切换工作目录,确保 `go test ./app/` 时配置与模板输出路径正确。 > > 全量 `go test ./app/... -v`(不带 `-run`)会跑完所有用例并触发覆盖率扫描,项目变大后很慢且容易牵连无关失败。**仅在 CI / 发版前使用**,说明见文末。 @@ -511,25 +513,82 @@ type ApiTestDef struct { ### TestApp ```go -// NewTestApp 创建测试应用 -// configPath: 配置文件路径 -// projects: TestProj 注册所有项目的路由和测试 -// listeners: 可选,与 SetConnectListener 相同的请求拦截器 -func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp +// Test 链式入口(延迟 Init,便于先 WorkDir) +func Test(configPath string, listeners ...func(*Context) bool) *TestApp + +func (app *TestApp) WorkDir(dir string) *TestApp +func (app *TestApp) Swagger(args ...string) *TestApp // title / version / outputDir 均可选 +func (app *TestApp) Proj(projects TestProj) *TestApp +func (app *TestApp) Flows(flows FlowTest) *TestApp // 可选:多接口业务流程 +func (app *TestApp) Run(m *testing.M) int // 信号停测 + m.Run + 覆盖率 + Swagger 收尾 -// RunTests 运行所有注册的测试(每个方法级别自动事务隔离) func (app *TestApp) RunTests(t *testing.T) - -// PrintCoverage 输出覆盖率报告,返回报告结构体 func (app *TestApp) PrintCoverage() CoverageReport - -// GenerateSwagger 生成 API 调试控制台(api-spec.json + index.html) func (app *TestApp) GenerateSwagger(title, version, outputDir string) error - -// DB 获取测试应用的数据库实例(在事务内) func (app *TestApp) DB() *HoTimeDB ``` +### 业务流程联调(Flows) + +与单接口 `CtrTest` 并列,用于多接口串起来的业务跑通。整条 Flow 共用一次测试事务,结束回滚。 + +**FlowDef 字段** + +| 字段 | 说明 | +|------|------| +| `Group` | 控制台侧栏分组(如 `order`) | +| `Desc` | 流程短标题 | +| `Prep` | 业务说明 / 前置与数据准备(控制台「业务说明」) | +| `Func` | `func(f *Flow)` | + +**Flow API** + +| 方法 | 说明 | +|------|------| +| `f.Step(name, path)` | 新步骤,返回 `*Api`(记录归入 flows) | +| `f.AtPath(path)` | 同流程内换 path,不增步骤序号 | +| `f.WithSession(s)` | 后续步骤携带 session | +| `f.DB()` / `f.Resp()` | 流程事务库 / 最近一步响应 | + +```go +Flows(FlowTest{ + "checkout": { + Group: "order", + Desc: "下单支付跑通", + Prep: "先创建订单再支付;步骤可用 FromCase 绑定单接口验收用例。", + Func: func(f *Flow) { + f.Step("创建订单", "/app/order/create"). + FromCase("正常创建"). + Note("创建后取 order id 供支付步骤使用"). + WithSession(Map{"admin_id": 1}). + Post("创建", 0, Map{"id": int64(1)}) + orderID := f.Resp().GetBody().GetMap("result").GetCeilInt64("id") + f.Step("支付", "/app/order/pay"). + WithSession(Map{"admin_id": 1}). + JSON(Map{"order_id": orderID}). + Post("支付成功", 0) + }, + }, +}) +``` + +**FromCase** + +- 按 path + 用例名加载请求模板:优先本轮已跑的单接口记录,否则读已有 `api-spec.json` +- 步骤跑完后回写该单接口 case 的 response / passed +- 无模板时当前为**静默不填**(见 [已知局限](#已知局限与后续));建议全量或先跑绑定接口再跑 flow +- 示例:`example/app/flow_test.go` + +控制台:侧栏「业务流程 → group → flow 名」;步骤左栏含关联验收用例 / 备注 / 请求,右栏响应;点 path 弹窗看接口只读详情。 +`-run`:`go test ./app/ -run "TestApi/flows/checkout" -v` + +### 优雅停测 + +`Run(m)` 内监听 SIGINT/SIGTERM;测试代码可调 `testApp.RequestStop()` 模拟。 + +行为:停收后续接口/流程 → 当前子测跑完 → 回滚测试事务 → 已 flush 的 swagger **保留**(不全量 prune)。 +示例:`example/app/app_test.go` 的 `TestInterruptStop`。 + --- ## 链式 API 参考 @@ -546,7 +605,9 @@ func (app *TestApp) DB() *HoTimeDB | `a.File(field, name, content)` | 设置上传文件 | `*ApiCase` | | `a.Note(note)` | 为下一条用例设置备注,备注显示在调试控制台 | `*ApiCase` | | `a.Verify(fn)` | 设置请求后的校验函数(等同于先创建 ApiCase 再 Verify,适合纯 GET 无数据场景) | `*ApiCase` | +| `a.FromCase(name)` | 绑定单接口验收用例:加载其请求模板;跑通后回写 response/passed | `*ApiCase` | | `a.WithSession(s)` | 返回携带 session 的新 Api | `*Api` | +| `a.RunSub(desc, fn)` | 接口 Func 内再分子场景,便于 `-run` 收窄 | — | | `a.Get(desc, status, expect...)` | 直接 GET 请求(无数据场景) | `*ApiResponse` | | `a.Post(desc, status, expect...)` | 直接 POST 请求(无数据场景) | `*ApiResponse` | | `a.Put(desc, status, expect...)` | 直接 PUT 请求 | `*ApiResponse` | @@ -566,6 +627,7 @@ func (app *TestApp) DB() *HoTimeDB | `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` | | `c.Note(note)` | 为本条用例添加可选备注,备注显示在调试控制台用例详情中 | `*ApiCase` | | `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` | +| `c.FromCase(name)` | 绑定单接口验收用例名并加载请求模板(若尚未加载) | `*ApiCase` | | `c.Verify(fn)` | 设置请求后的统一校验函数,可用 `a.Resp()` 断言响应值 + `a.DB()` 校验数据库(详见 [Verify 统一校验](#4-verify-统一校验)) | `*ApiCase` | | `c.Get(desc, status, expect...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` | | `c.Post(desc, status, expect...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` | @@ -720,39 +782,33 @@ that.Db.Action(func(db HoTimeDB) bool { ## API 调试控制台 -`GenerateSwagger` 按模块生成独立的交互式 API 调试控制台(暗色高对比度主题),每个模块一个子目录,支持独立分发。 +通过链式 `.Swagger(title, version)` 启用。 -```go -testApp.GenerateSwagger( - "My API", // 文档标题 - "2.1.0", // 版本号 - testApp.Config.GetString("tpt"), // 输出目录(如 "tpt") -) -``` +**增量写入**:每个接口 / 流程跑完即 flush;本轮无记录则不覆盖成「未测」;未全量跑完不 prune 已删接口;仅全量完成才清理孤儿模块。Ctrl+C / SIGTERM 走优雅停测后再收尾。 + +输出目录默认 `config` 中的 `tpt`。 ### 生成目录结构 -每个模块(`TestProj` 中的项目名)生成独立子目录,同时自动生成根导航页: - ``` {outputDir}/swagger/ -├── index.html ← 根导航页(API 文档中心,卡片式链接到各模块) -├── api/ -│ ├── api-spec.json ← api 模块的 API 规范(路由、测试用例、请求/响应数据) -│ └── index.html ← api 模块的调试控制台 -├── admin/ -│ ├── api-spec.json +├── index.html ← 根导航页 +├── app/ +│ ├── api-spec.json ← endpoints + flows │ └── index.html -└── portal/ - ├── api-spec.json - └── index.html +└── ... ``` -访问地址:`http://localhost:8081/swagger/`(根导航页),`http://localhost:8081/swagger/api/`(api 模块) +访问:`http://localhost:8081/swagger/`(根导航),`http://localhost:8081/swagger/app/`(模块)。 -> **独立分发**:每个模块目录可以单独拷贝和部署,不依赖其他模块。根导航页自动扫描子目录生成链接。 +### 控制台能力 -控制台支持三级导航、关键字搜索、用例预填、全局认证(Header/URL/Cookie)、cURL 生成、覆盖率可视化等功能。 +| 区域 | 行为 | +|------|------| +| 侧栏 | 业务流程 → group → flow 名;接口仍按 project/ctr 三级 | +| 流程页 | Prep 业务说明;步骤默认展开;左备注/关联验收用例/请求(cURL),右响应;点 path 弹窗只读详情(Esc 关闭) | +| 单接口 | 用例预填、全局认证;**发送结果 cURL 随左侧 Header/Query/JSON/Form 实时同步** | +| 发送 | 浏览器 `fetch` 打当前源站,**需已启动服务**(与 httptest 零启动测试不同) | --- @@ -761,7 +817,7 @@ testApp.GenerateSwagger( 框架自动处理以下问题,编写测试时无需关心: - **并发保护**:`testTx` 是单连接,框架通过 `testMu` 互斥锁自动序列化所有数据库操作,业务代码中的 `go func()` 协程不会导致 `busy buffer` 错误 -- **缓存隔离**:`NewTestApp` 启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 `WithSession()` 直接注入 Memory 缓存 +- **缓存隔离**:启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 `WithSession()` 直接注入 Memory 缓存 ### 一次性数据的处理 @@ -865,21 +921,21 @@ a.Verify(func(a *Api) error { go test ./app/... -v ``` -`TestMain` 里常见的收尾逻辑(全量跑完后才有意义): +`TestMain` 推荐写法(`Run` 已含覆盖率与 Swagger 收尾): ```go func TestMain(m *testing.M) { - // ... - code := m.Run() - testApp.PrintCoverage() // 全量跑完后输出覆盖率 - testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt")) - os.Exit(code) + testApp = Test("config/config.json"). + WorkDir(".."). + Swagger("My API", "1.0.0"). + Proj(TestProj{"app": {Proj: Project, Tests: ProjectTest}}) + os.Exit(testApp.Run(m)) } ``` ### 覆盖率报告 -`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由): +`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由)。**Flow 步骤不计入覆盖率**(`FlowName != ""` 的记录会被跳过)。 **输出示例(全部通过):** @@ -937,3 +993,20 @@ func TestMain(m *testing.M) { ] } ``` + +--- + +## 已知局限与后续 + +| 优先级 | 缺口 | 说明 | +|--------|------|------| +| 高 | 值级断言助手 | 结构校验有,值断言靠 Verify 手写 | +| 高 | DB 断言助手 | 无 AssertCount / AssertRow 一类 | +| 高 | FromCase 无模板显式失败 | 现静默不填,易空 body 误测 | +| 中 | 自定义 Header / Cookie API | 控制台有,测试链无 `WithHeader` | +| 中 | HTTP 状态码 / 响应头断言 | StatusCode 有写入,无公开断言 | +| 中 | 覆盖率含 Flow | PrintCoverage 跳过 Flow 记录 | +| 低 | PATCH、多文件上传 | 当前边界 | +| 低 | 框架级 Seed/Fixture | 依赖业务自写 `a.DB()` 准备 | + +规划表见 [ROADMAP_改进规划.md](ROADMAP_改进规划.md)。 diff --git a/example/app/app_test.go b/example/app/app_test.go index ff452cd..2b67c30 100644 --- a/example/app/app_test.go +++ b/example/app/app_test.go @@ -1,7 +1,10 @@ package app import ( + "encoding/json" "os" + "path/filepath" + "strings" "testing" . "code.hoteas.com/golang/hotime" @@ -18,27 +21,91 @@ var ProjectTest = ProjTest{ var testApp *TestApp func TestMain(m *testing.M) { - // Go test 的 CWD 是包目录 example/app/,切回 example/ 使相对路径正确 - _ = os.Chdir("..") - - testApp = NewTestApp("config/config.json", - TestProj{ + testApp = Test("config/config.json"). + WorkDir(".."). + Swagger("My API", "1.0.0"). + Proj(TestProj{ "app": { Proj: Project, Tests: ProjectTest, }, - }, - ) + }). + Flows(DemoFlows) - // 在测试前初始化各数据库的测试表和种子数据 + // 仅本 example:空库时灌演示数据。须连专用测试库,勿对生产库调用。 SetupDatabase(&testApp.Db) - code := m.Run() - testApp.PrintCoverage() - testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt")) - os.Exit(code) + os.Exit(testApp.Run(m)) } func TestApi(t *testing.T) { testApp.RunTests(t) } + +// TestInterruptStop 模拟提前中断:string_result 跑完后 RequestStop,zzz_should_skip 应 Skip; +// swagger 中 string_result 的 cases 应保留。注意:TestMain 已 Chdir 到 example/,此处不再 WorkDir。 +func TestInterruptStop(t *testing.T) { + var app *TestApp + secondRan := false + + app = Test("config/config.json"). + Swagger("Interrupt Demo", "1.0.0"). + Proj(TestProj{ + "app": { + Proj: Project, + Tests: ProjTest{ + "expect_demo": CtrTest{ + "string_result": {Desc: "先跑并停测", Func: func(a *Api) { + ExpectDemoTest["string_result"].Func(a) + app.RequestStop() + }}, + "zzz_should_skip": {Desc: "应被跳过", Func: func(a *Api) { + secondRan = true + t.Error("zzz_should_skip 在 RequestStop 后不应执行") + }}, + }, + }, + }, + }) + + SetupDatabase(&app.Db) + app.RunTests(t) + + if secondRan { + t.Fatal("停测后仍执行了后续接口") + } + _ = app.Db.RollbackTestTx() + + // 收尾写盘(中断路径下可能未跑满 GenerateSwagger,显式补一次增量合并) + if err := app.GenerateSwagger("Interrupt Demo", "1.0.0", app.Config.GetString("tpt")); err != nil { + t.Fatalf("GenerateSwagger: %v", err) + } + + specPath := filepath.Join(app.Config.GetString("tpt"), "swagger", "app", "api-spec.json") + data, err := os.ReadFile(specPath) + if err != nil { + t.Fatalf("应已生成 swagger %s: %v", specPath, err) + } + var spec struct { + Endpoints []struct { + Path string `json:"path"` + Cases json.RawMessage `json:"cases"` + } `json:"endpoints"` + } + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("解析 api-spec 失败: %v", err) + } + found := false + for _, ep := range spec.Endpoints { + if ep.Path != "/app/expect_demo/string_result" { + continue + } + found = true + if !strings.Contains(string(ep.Cases), `"name"`) { + t.Fatalf("string_result 应保留测试 cases,实际: %s", string(ep.Cases)) + } + } + if !found { + t.Fatal("swagger 中应包含已跑完的 /app/expect_demo/string_result") + } +} diff --git a/example/app/expect_demo_test.go b/example/app/expect_demo_test.go index 801dae8..9c9df9a 100644 --- a/example/app/expect_demo_test.go +++ b/example/app/expect_demo_test.go @@ -231,25 +231,6 @@ var ExpectDemoTest = CtrTest{ }). Post("写入并校验DB+响应", 0, Map{"id": int64(1), "name": "sample"}) - // ======== 演示故意失败的 Verify ======== - a.Form(Map{"name": "verify_fail"}). - Verify(func(a *Api) error { - result := a.Resp().GetBody().GetMap("result") - if result == nil { - return fmt.Errorf("result 为 nil") - } - if result.GetCeilInt64("id") <= 0 { - return fmt.Errorf("返回的 id 必须大于 0, 实际 %d", result.GetCeilInt64("id")) - } - row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "verify_fail", "title": "verify_demo"}}) - if row == nil { - return fmt.Errorf("test_batch 表未找到 name=verify_fail 的记录") - } - if row.GetCeilInt64("state") != 99 { - return fmt.Errorf("test_batch.state 期望 99, 实际 %d(接口写入 0,故意校验 99 演示失败效果)", row.GetCeilInt64("state")) - } - return nil - }). - Post("写入并校验DB-故意失败", 0, Map{"id": int64(1), "name": "sample"}) + // Verify 返回 error 会使该用例失败并写入 failReason(见覆盖率报告「未通过的接口」) }}, } diff --git a/example/app/flow_test.go b/example/app/flow_test.go new file mode 100644 index 0000000..408e9c1 --- /dev/null +++ b/example/app/flow_test.go @@ -0,0 +1,44 @@ +package app + +import ( + "fmt" + + . "code.hoteas.com/golang/hotime" + . "code.hoteas.com/golang/hotime/common" +) + +// DemoFlows 多接口业务流程联调示例(整条 Flow 共用一次测试事务,结束回滚) +var DemoFlows = FlowTest{ + "expect_chain": { + Group: "expect_demo", + Desc: "expect_demo 多接口联调", + Prep: "依次调用 string_result 与 map_result。\n步骤通过 FromCase 复用单接口验收用例的请求模板,跑通后回写该用例的响应与通过状态。", + Func: func(f *Flow) { + f.Step("字符串结果", "/app/expect_demo/string_result"). + FromCase("result是字符串"). + Note("校验字符串 result:期望返回「操作成功」。"). + Verify(func(a *Api) error { + if a.Resp().GetBody().GetString("result") != "操作成功" { + return fmt.Errorf("result 期望 操作成功") + } + return nil + }). + Get("步骤1-字符串", 0, "样本") + + f.Step("Map结果", "/app/expect_demo/map_result"). + FromCase("result是Map-校验字段名+类型+值"). + Note("校验对象 result:至少包含 id=1,用于串联后续步骤前的结构确认。"). + Verify(func(a *Api) error { + result := a.Resp().GetBody().GetMap("result") + if result == nil { + return fmt.Errorf("result 为 nil") + } + if result.GetCeilInt64("id") != 1 { + return fmt.Errorf("id 期望 1") + } + return nil + }). + Get("步骤2-Map", 0, Map{"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true}) + }, + }, +} diff --git a/example/app/test.go b/example/app/test.go index 7689aa6..0141b8c 100644 --- a/example/app/test.go +++ b/example/app/test.go @@ -145,9 +145,10 @@ func testBasicCRUD(that *Context) Map { now := nowFunc(that) insertTest := Map{"name": "Insert 插入测试 (admin表)"} + uniq := time.Now().UnixNano() adminId := that.Db.Insert("admin", Map{ - "name": "测试管理员_" + fmt.Sprintf("%d", time.Now().Unix()), - "phone": fmt.Sprintf("138%d", time.Now().Unix()%100000000), + "name": "测试管理员_" + fmt.Sprintf("%d", uniq), + "phone": fmt.Sprintf("139%08d", uniq%100000000), "state": 1, "password": "test123456", "role_id": 1, diff --git a/example/tpt/swagger/app/api-spec.json b/example/tpt/swagger/app/api-spec.json index f8c4c40..4237e95 100644 --- a/example/tpt/swagger/app/api-spec.json +++ b/example/tpt/swagger/app/api-spec.json @@ -1,9 +1,122 @@ { "endpoints": [ { - "cases": null, + "cases": [ + { + "name": "缓存全部测试", + "method": "GET", + "passed": true, + "response": { + "result": { + "cache_mode": "compatible", + "cleanup": "已清理所有测试缓存数据", + "name": "数据库缓存测试", + "success": true, + "tests": [ + { + "cached_value": { + "active": true, + "count": 123, + "name": "测试数据" + }, + "name": "1. 基础 set/get 测试", + "result": true + }, + { + "after_exists": false, + "before_exists": true, + "name": "2. delete 删除缓存测试", + "result": true + }, + { + "immediate_exists": true, + "name": "3. 过期时间测试(短超时)", + "note": "设置了2秒过期,可等待后再次访问验证过期", + "result": true + }, + { + "name": "4. 不存在的 key 读取测试", + "result": true, + "value": null + }, + { + "first": "第一次值", + "name": "5. 重复 set 同一个 key 测试", + "result": true, + "second": "第二次值", + "third": { + "version": 3 + } + }, + { + "after": { + "a": false, + "b": false, + "c": false + }, + "before": { + "a": true, + "b": true, + "c": true + }, + "name": "6. 通配删除测试 (key*)", + "result": true + }, + { + "bool": true, + "float": 3.14159, + "int": 12345, + "map": { + "key": "value", + "num": 100 + }, + "name": "7. 不同数据类型存储测试", + "result": true, + "slice": [ + 1, + 2, + 3, + "four", + { + "five": 5 + } + ], + "string": "字符串值" + }, + { + "name": "8. 自定义超时时间参数测试", + "result": true, + "value": "长期数据" + }, + { + "name": "9. 缓存表状态查询", + "new_table_count": 29, + "new_table_name": "hotime_cache", + "result": true + } + ] + }, + "status": 0 + }, + "expect": { + "result": { + "cache_mode": "sample", + "cleanup": "sample", + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample", + "result": true + } + ] + }, + "status": 0 + } + } + ], "ctr": "cache", - "method": "POST", + "method": "GET", "params": [], "path": "/app/cache/all", "project": "app", @@ -11,9 +124,27 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "批量缓存测试", + "method": "GET", + "passed": true, + "response": { + "result": { + "message": "批量缓存测试完成,请查看控制台输出和日志文件" + }, + "status": 0 + }, + "expect": { + "result": { + "message": "sample" + }, + "status": 0 + } + } + ], "ctr": "cache", - "method": "POST", + "method": "GET", "params": [], "path": "/app/cache/batch", "project": "app", @@ -21,9 +152,126 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "兼容模式测试", + "method": "GET", + "passed": true, + "response": { + "result": { + "success": true, + "test_name": "兼容模式白盒测试", + "tests": [ + { + "mode": "compatible", + "name": "1. 查询当前缓存模式", + "result": true + }, + { + "count": 0, + "data": [], + "legacy_table": "cached", + "name": "2. 查询老表cached现有数据", + "result": true + }, + { + "count": 5, + "data": [ + { + "create_time": "2026-04-18 05:26:58", + "end_time": "2026-07-14 12:37:13", + "id": 8, + "key": "INFORMATION_SCHEMA.TABLES:111a1ad16d7b315170077c5f0d2ed79f", + "modify_time": "2026-07-13 16:37:13", + "state": 0, + "value": "[{\"label\":\"\",\"name\":\"体育\"},{\"label\":\"\",\"name\":\"军事\"},{\"label\":\"\",\"name\":\"历史\"},{\"label\":\"\",\"name\":\"地理\"},{\"label\":\"\",\"name\":\"外语\"},{\"label\":\"\",\"name\":\"天文\"},{\"label\":\"\",\"name\":\"影音\"},{\"label\":\"\",\"name\":\"数学\"},{\"label\":\"\",\"name\":\"数学1\"},{\"label\":\"\",\"name\":\"文学\"},{\"label\":\"\",\"name\":\"生物\"},{\"label\":\"\",\"name\":\"综合\"},{\"label\":\"管理员表\",\"name\":\"admin\"},{\"label\":\"文章表\",\"name\":\"article\"},{\"label\":\"\",\"name\":\"auth_role\"},{\"label\":\"\",\"name\":\"auth_user\"},{\"label\":\"老版本缓存表\",\"name\":\"cached\"},{\"label\":\"分类表\",\"name\":\"ctg\"},{\"label\":\"\",\"name\":\"history\"},{\"label\":\"缓存管理\",\"name\":\"hotime_cache\"},{\"label\":\"\",\"name\":\"ibang0420_iqiyi\"},{\"label\":\"\",\"name\":\"question\"},{\"label\":\"\",\"name\":\"role\"},{\"label\":\"\",\"name\":\"role_user\"},{\"label\":\"\",\"name\":\"stroage\"},{\"label\":\"批量测试表\",\"name\":\"test_batch\"},{\"label\":\"\",\"name\":\"user\"},{\"label\":\"\",\"name\":\"version\"}]" + }, + { + "create_time": "2026-04-18 05:26:58", + "end_time": "2026-07-14 12:37:13", + "id": 9, + "key": "INFORMATION_SCHEMA.COLUMNS:a8e3500cce5681cb71ce9e5cb57edddd", + "modify_time": "2026-07-13 16:37:13", + "state": 0, + "value": "[{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qname\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase1\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase2\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase3\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase4\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qanswer\",\"type\":\"double\"},{\"dflt_value\":\"7\",\"label\":\"\",\"must\":\"YES\",\"name\":\"qcid\",\"type\":\"int(11)\"},{\"dflt_value\":\"1526019499\",\"label\":\"\",\"must\":\"YES\",\"name\":\"qtime\",\"type\":\"int(11)\"}]" + }, + { + "create_time": "2026-04-18 05:27:02", + "end_time": "2026-07-14 12:37:13", + "id": 10, + "key": "INFORMATION_SCHEMA.COLUMNS:15df4912fb74d22d53ac3000217e50ad", + "modify_time": "2026-07-13 16:37:13", + "state": 0, + "value": "[{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qname\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase1\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase2\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase3\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase4\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qanswer\",\"type\":\"double\"},{\"dflt_value\":\"10\",\"label\":\"\",\"must\":\"YES\",\"name\":\"qcid\",\"type\":\"int(11)\"},{\"dflt_value\":\"1526019499\",\"label\":\"\",\"must\":\"YES\",\"name\":\"qtime\",\"type\":\"int(11)\"}]" + }, + { + "create_time": "2026-04-18 05:27:02", + "end_time": "2026-07-14 12:37:13", + "id": 11, + "key": "INFORMATION_SCHEMA.COLUMNS:bfaf5c253bab96452cb68392e4e37e58", + "modify_time": "2026-07-13 16:37:13", + "state": 0, + "value": "[{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qname\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase1\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase2\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase3\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase4\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qanswer\",\"type\":\"double\"},{\"dflt_value\":\"4\",\"label\":\"\",\"must\":\"YES\",\"name\":\"qcid\",\"type\":\"int(4)\"},{\"dflt_value\":\"1526019499\",\"label\":\"\",\"must\":\"YES\",\"name\":\"qtime\",\"type\":\"int(11)\"}]" + }, + { + "create_time": "2026-04-18 05:27:03", + "end_time": "2026-07-14 12:37:13", + "id": 12, + "key": "INFORMATION_SCHEMA.COLUMNS:d79863ef6c6082a111b1e474d25ef746", + "modify_time": "2026-07-13 16:37:13", + "state": 0, + "value": "[{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qname\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase1\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase2\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase3\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qcase4\",\"type\":\"varchar(255)\"},{\"dflt_value\":null,\"label\":\"\",\"must\":\"YES\",\"name\":\"qanswer\",\"type\":\"double\"},{\"dflt_value\":\"8\",\"label\":\"\",\"must\":\"YES\",\"name\":\"qcid\",\"type\":\"int(11)\"},{\"dflt_value\":\"1526019499\",\"label\":\"\",\"must\":\"YES\",\"name\":\"qtime\",\"type\":\"int(11)\"}]" + } + ], + "name": "3. 查询新表hotime_cache现有数据", + "new_table": "hotime_cache", + "result": true + }, + { + "cache_api_result": null, + "key_in_new_table": false, + "name": "4. 测试兼容模式老表回退读取", + "result": false, + "test_key": "test_compat_fallback_1783934340909146200" + }, + { + "name": "5. 测试兼容模式写新删老", + "result": false, + "step1_legacy_before": true, + "step2_new_after": false, + "step3_legacy_after_deleted": false + }, + { + "after_legacy_deleted": false, + "after_new_deleted": false, + "before_legacy": true, + "before_new": true, + "name": "6. 测试兼容模式删除(删除两表)", + "result": false + } + ], + "timestamp": "2026-07-13 17:19:00" + }, + "status": 0 + }, + "expect": { + "result": { + "success": true, + "test_name": "sample", + "tests": [ + { + "name": "sample", + "result": true + } + ], + "timestamp": "sample" + }, + "status": 0 + } + } + ], "ctr": "cache", - "method": "POST", + "method": "GET", "params": [], "path": "/app/cache/compat", "project": "app", @@ -61,9 +309,23 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "result是布尔", + "method": "GET", + "passed": true, + "response": { + "result": true, + "status": 0 + }, + "expect": { + "result": true, + "status": 0 + } + } + ], "ctr": "expect_demo", - "method": "POST", + "method": "GET", "params": [], "path": "/app/expect_demo/bool_result", "project": "app", @@ -71,19 +333,96 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "名称为空-缺少必填字段", + "method": "POST", + "passed": true, + "form": { + "name": "" + }, + "response": { + "error": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "result": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "status": 3 + }, + "expect": { + "error": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "result": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "status": 3 + } + }, + { + "name": "名称正确-结构+值校验", + "method": "POST", + "passed": true, + "form": { + "name": "测试商品" + }, + "response": { + "result": { + "created": true, + "id": 1, + "name": "测试商品" + }, + "status": 0 + }, + "expect": { + "result": { + "created": true, + "id": 1, + "name": "sample" + }, + "status": 0 + } + } + ], "ctr": "expect_demo", "method": "POST", - "params": [], + "params": [ + { + "name": "name", + "required": true, + "type": "string", + "example": "测试商品", + "in": "form" + } + ], "path": "/app/expect_demo/error_demo", "project": "app", "summary": "错误先行+正确请求完整校验", "tested": true }, { - "cases": null, + "cases": [ + { + "name": "result是小数", + "method": "GET", + "passed": true, + "response": { + "result": 3.14, + "status": 0 + }, + "expect": { + "result": 1, + "status": 0 + } + } + ], "ctr": "expect_demo", - "method": "POST", + "method": "GET", "params": [], "path": "/app/expect_demo/float_result", "project": "app", @@ -91,9 +430,33 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "result是Map-校验字段名+类型+值", + "method": "GET", + "passed": true, + "response": { + "result": { + "id": 1, + "in_stock": true, + "name": "示例商品", + "price": 19.9 + }, + "status": 0 + }, + "expect": { + "result": { + "id": 1, + "in_stock": true, + "name": "sample", + "price": 1 + }, + "status": 0 + } + } + ], "ctr": "expect_demo", - "method": "POST", + "method": "GET", "params": [], "path": "/app/expect_demo/map_result", "project": "app", @@ -101,9 +464,73 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "深层嵌套Map+Slice+Map", + "method": "GET", + "passed": true, + "response": { + "result": { + "customer": { + "id": 1, + "name": "张三", + "phone": "13800138000" + }, + "extra": { + "delivery": { + "address": "XX路1号", + "fee": 5 + } + }, + "id": 100, + "items": [ + { + "goods_name": "苹果", + "id": 1, + "price": 3.5, + "quantity": 5 + }, + { + "goods_name": "香蕉", + "id": 2, + "price": 2, + "quantity": 10 + } + ], + "title": "测试订单" + }, + "status": 0 + }, + "expect": { + "result": { + "customer": { + "id": 1, + "name": "sample", + "phone": "sample" + }, + "extra": { + "delivery": { + "address": "sample", + "fee": 1 + } + }, + "id": 1, + "items": [ + { + "goods_name": "sample", + "id": 1, + "price": 1, + "quantity": 1 + } + ], + "title": "sample" + }, + "status": 0 + } + } + ], "ctr": "expect_demo", - "method": "POST", + "method": "GET", "params": [], "path": "/app/expect_demo/nested_result", "project": "app", @@ -111,9 +538,29 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "有Verify值断言", + "method": "GET", + "passed": true, + "response": { + "result": { + "data": "无预期校验", + "id": 1 + }, + "status": 0 + }, + "expect": { + "result": { + "data": "sample", + "id": 1 + }, + "status": 0 + } + } + ], "ctr": "expect_demo", - "method": "POST", + "method": "GET", "params": [], "path": "/app/expect_demo/no_expect", "project": "app", @@ -121,9 +568,23 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "result是整数", + "method": "GET", + "passed": true, + "response": { + "result": 42, + "status": 0 + }, + "expect": { + "result": 1, + "status": 0 + } + } + ], "ctr": "expect_demo", - "method": "POST", + "method": "GET", "params": [], "path": "/app/expect_demo/number_result", "project": "app", @@ -131,9 +592,37 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "result直接是Slice", + "method": "GET", + "passed": true, + "response": { + "result": [ + { + "id": 1, + "name": "标签A" + }, + { + "id": 2, + "name": "标签B" + } + ], + "status": 0 + }, + "expect": { + "result": [ + { + "id": 1, + "name": "sample" + } + ], + "status": 0 + } + } + ], "ctr": "expect_demo", - "method": "POST", + "method": "GET", "params": [], "path": "/app/expect_demo/slice_result", "project": "app", @@ -141,9 +630,23 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "result是字符串", + "method": "GET", + "passed": true, + "response": { + "result": "操作成功", + "status": 0 + }, + "expect": { + "result": "样本", + "status": 0 + } + } + ], "ctr": "expect_demo", - "method": "POST", + "method": "GET", "params": [], "path": "/app/expect_demo/string_result", "project": "app", @@ -151,29 +654,390 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "名称为空", + "method": "POST", + "passed": true, + "form": { + "name": "" + }, + "response": { + "error": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "result": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "status": 3 + }, + "expect": { + "error": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "result": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "status": 3 + } + }, + { + "name": "写入并校验DB+响应", + "method": "POST", + "passed": true, + "form": { + "name": "verify_test" + }, + "response": { + "result": { + "id": 213, + "name": "verify_test" + }, + "status": 0 + }, + "expect": { + "result": { + "id": 1, + "name": "sample" + }, + "status": 0 + } + } + ], "ctr": "expect_demo", "method": "POST", - "params": [], + "params": [ + { + "name": "name", + "required": true, + "type": "string", + "example": "verify_test", + "in": "form" + } + ], "path": "/app/expect_demo/verify_demo", "project": "app", "summary": "Verify完整范式演示", "tested": true }, { - "cases": null, + "cases": [ + { + "name": "不传table参数", + "method": "GET", + "passed": true, + "response": { + "error": { + "msg": "请提供 table 参数", + "type": "内部系统异常" + }, + "result": { + "msg": "请提供 table 参数", + "type": "内部系统异常" + }, + "status": 1 + }, + "expect": { + "error": { + "msg": "请提供 table 参数", + "type": "内部系统异常" + }, + "result": { + "msg": "请提供 table 参数", + "type": "内部系统异常" + }, + "status": 1 + } + }, + { + "name": "DESCRIBE admin", + "method": "GET", + "passed": true, + "query": { + "table": "admin" + }, + "response": { + "result": { + "columns": [ + { + "Default": null, + "Extra": "auto_increment", + "Field": "id", + "Key": "PRI", + "Null": "NO", + "Type": "int(11) unsigned" + }, + { + "Default": null, + "Extra": "", + "Field": "name", + "Key": "", + "Null": "YES", + "Type": "varchar(100)" + }, + { + "Default": null, + "Extra": "", + "Field": "phone", + "Key": "UNI", + "Null": "YES", + "Type": "varchar(20)" + }, + { + "Default": "0", + "Extra": "", + "Field": "state", + "Key": "", + "Null": "YES", + "Type": "int(2)" + }, + { + "Default": null, + "Extra": "", + "Field": "password", + "Key": "", + "Null": "YES", + "Type": "varchar(100)" + }, + { + "Default": "0", + "Extra": "", + "Field": "role_id", + "Key": "", + "Null": "YES", + "Type": "int(11)" + }, + { + "Default": null, + "Extra": "", + "Field": "title", + "Key": "", + "Null": "YES", + "Type": "varchar(100)" + }, + { + "Default": null, + "Extra": "", + "Field": "create_time", + "Key": "", + "Null": "YES", + "Type": "datetime" + }, + { + "Default": null, + "Extra": "", + "Field": "modify_time", + "Key": "", + "Null": "YES", + "Type": "datetime" + } + ], + "sample_data": [ + { + "create_time": "2026-03-20 02:32:47", + "id": 1, + "modify_time": "2026-03-20 02:32:47", + "name": "超级管理员", + "password": "admin123", + "phone": "13800000001", + "role_id": 1, + "state": 1, + "title": "系统管理员" + }, + { + "create_time": "2026-03-20 02:32:47", + "id": 2, + "modify_time": "2026-03-20 02:32:47", + "name": "编辑员", + "password": "editor123", + "phone": "13800000002", + "role_id": 2, + "state": 1, + "title": "内容编辑" + }, + { + "create_time": "2026-03-20 02:32:47", + "id": 3, + "modify_time": "2026-03-20 02:32:47", + "name": "审核员", + "password": "review123", + "phone": "13800000003", + "role_id": 3, + "state": 1, + "title": "内容审核" + }, + { + "create_time": "2026-03-20 02:43:03", + "id": 6, + "modify_time": "2026-03-20 02:43:03", + "name": "测试管理员_1773974584", + "password": "test123456", + "phone": "13873974584", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + }, + { + "create_time": "2026-03-20 02:43:09", + "id": 9, + "modify_time": "2026-03-20 02:43:09", + "name": "测试管理员_1773974590", + "password": "test123456", + "phone": "13873974590", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + }, + { + "create_time": "2026-03-20 02:50:47", + "id": 12, + "modify_time": "2026-03-20 02:50:47", + "name": "测试管理员_1773975048", + "password": "test123456", + "phone": "13873975048", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + }, + { + "create_time": "2026-03-20 02:57:19", + "id": 18, + "modify_time": "2026-03-20 02:57:19", + "name": "测试管理员_1773975440", + "password": "test123456", + "phone": "13873975440", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + }, + { + "create_time": "2026-03-29 16:47:48", + "id": 22, + "modify_time": "2026-03-29 16:47:48", + "name": "测试管理员_1774802868", + "password": "test123456", + "phone": "13874802868", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + }, + { + "create_time": "2026-03-29 16:47:49", + "id": 25, + "modify_time": "2026-03-29 16:47:50", + "name": "测试管理员_1774802869", + "password": "test123456", + "phone": "13874802869", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + }, + { + "create_time": "2026-03-29 16:54:20", + "id": 28, + "modify_time": "2026-03-29 16:54:20", + "name": "测试管理员_1774803260", + "password": "test123456", + "phone": "13874803260", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + } + ], + "table": "admin" + }, + "status": 0 + }, + "expect": { + "result": { + "columns": [], + "sample_data": [], + "table": "sample" + }, + "status": 0 + } + } + ], "ctr": "mysql", - "method": "POST", - "params": [], + "method": "GET", + "params": [ + { + "name": "table", + "required": false, + "type": "string", + "example": "admin", + "in": "query" + } + ], "path": "/app/mysql/describe", "project": "app", "summary": "查询 MySQL 表结构", "tested": true }, { - "cases": null, + "cases": [ + { + "name": "MySQL原生SQL", + "method": "GET", + "passed": true, + "response": { + "result": { + "name": "MySQL原生SQL测试", + "success": true, + "tests": [ + { + "count": 5, + "name": "Query 原生查询 (反引号)", + "result": true + }, + { + "affected": 1, + "name": "Exec 原生执行 (反引号)", + "result": true + }, + { + "count": 5, + "lastQuery": "SELECT id, title FROM `article` WHERE id IN (?, ?, ?, ?, ?) LIMIT 10", + "name": "IN (?) 非空数组展开", + "result": true + }, + { + "count": 0, + "lastQuery": "SELECT id, title FROM `article` WHERE 1=0 LIMIT 10", + "name": "IN (?) 空数组替换为1=0", + "result": true + }, + { + "count": 10, + "lastQuery": "SELECT id, title FROM `article` WHERE 1=1 LIMIT 10", + "name": "NOT IN (?) 空数组替换为1=1", + "result": true + } + ] + }, + "status": 0 + }, + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample", + "result": true + } + ] + }, + "status": 0 + } + } + ], "ctr": "mysql", - "method": "POST", + "method": "GET", "params": [], "path": "/app/mysql/rawsql", "project": "app", @@ -181,9 +1045,112 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "SHOW TABLES", + "method": "GET", + "passed": true, + "response": { + "result": { + "tables": [ + { + "Tables_in_test": "体育" + }, + { + "Tables_in_test": "军事" + }, + { + "Tables_in_test": "历史" + }, + { + "Tables_in_test": "地理" + }, + { + "Tables_in_test": "外语" + }, + { + "Tables_in_test": "天文" + }, + { + "Tables_in_test": "影音" + }, + { + "Tables_in_test": "数学" + }, + { + "Tables_in_test": "数学1" + }, + { + "Tables_in_test": "文学" + }, + { + "Tables_in_test": "生物" + }, + { + "Tables_in_test": "综合" + }, + { + "Tables_in_test": "admin" + }, + { + "Tables_in_test": "article" + }, + { + "Tables_in_test": "auth_role" + }, + { + "Tables_in_test": "auth_user" + }, + { + "Tables_in_test": "cached" + }, + { + "Tables_in_test": "ctg" + }, + { + "Tables_in_test": "history" + }, + { + "Tables_in_test": "hotime_cache" + }, + { + "Tables_in_test": "ibang0420_iqiyi" + }, + { + "Tables_in_test": "question" + }, + { + "Tables_in_test": "role" + }, + { + "Tables_in_test": "role_user" + }, + { + "Tables_in_test": "stroage" + }, + { + "Tables_in_test": "test_batch" + }, + { + "Tables_in_test": "user" + }, + { + "Tables_in_test": "version" + } + ] + }, + "status": 0 + }, + "expect": { + "result": { + "tables": [] + }, + "status": 0 + } + } + ], "ctr": "mysql", - "method": "POST", + "method": "GET", "params": [], "path": "/app/mysql/tables", "project": "app", @@ -191,9 +1158,124 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "聚合函数", + "method": "GET", + "passed": true, + "response": { + "result": { + "name": "聚合函数测试", + "success": true, + "tests": [ + { + "count": 11, + "name": "Count 总数统计", + "result": true + }, + { + "count": 9, + "name": "Count 条件统计", + "result": true + }, + { + "lastQuery": "SELECT SUM(click_num) FROM `article` WHERE `state` =? ;", + "name": "Sum 求和 (单字段名)", + "result": true, + "sum": 12311 + }, + { + "avg": 1367.8889, + "lastQuery": "SELECT AVG(click_num) FROM `article` WHERE `state` =? ;", + "name": "Avg 平均值 (单字段名)", + "result": true + }, + { + "lastQuery": "SELECT MAX(click_num) FROM `article` WHERE `state` =? ;", + "max": 9999, + "name": "Max 最大值 (单字段名)", + "result": true + }, + { + "lastQuery": "SELECT MIN(sort) FROM `article` WHERE `state` =? ;", + "min": 1, + "name": "Min 最小值 (单字段名)", + "result": true + }, + { + "name": "GROUP BY 分组统计", + "result": true, + "stats": [ + { + "article_count": 4, + "avg_clicks": 450, + "ctg_id": 2, + "total_clicks": 1800 + }, + { + "article_count": 3, + "avg_clicks": 3461, + "ctg_id": 1, + "total_clicks": 10383 + }, + { + "article_count": 1, + "avg_clicks": 32, + "ctg_id": 3, + "total_clicks": 32 + }, + { + "article_count": 1, + "avg_clicks": 96, + "ctg_id": 4, + "total_clicks": 96 + } + ] + }, + { + "lastQuery": "SELECT SUM(`article`.`click_num`) FROM `article` WHERE `state` =? ;", + "match_single_field": false, + "name": "Sum 求和 (table.column 格式)", + "result": true, + "sum": 0 + }, + { + "name": "聚合函数一致性验证", + "result": false, + "summary": "Sum: 12311=0, Avg: 1367.8889=0, Max: 9999=0, Min: 1=0" + }, + { + "lastQuery": "SELECT SUM(`article`.`click_num`) FROM `article` LEFT JOIN `ctg` ON `article`.`ctg_id` = `ctg`.`id` WHERE `article`.`state` =? ;", + "name": "Sum 带 JOIN (table.column 格式)", + "result": true, + "sum": 0 + }, + { + "count": 9, + "lastQuery": "SELECT COUNT(*) FROM `article` LEFT JOIN `ctg` ON `article`.`ctg_id` = `ctg`.`id` WHERE `article`.`state` =? ;", + "name": "Count 带 JOIN", + "result": true + } + ] + }, + "status": 0 + }, + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + } + } + ], "ctr": "test", - "method": "POST", + "method": "GET", "params": [], "path": "/app/test/aggregate", "project": "app", @@ -201,9 +1283,593 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "全部测试", + "method": "GET", + "passed": true, + "response": { + "result": { + "1_basic_crud": { + "name": "基础CRUD测试", + "success": true, + "tests": [ + { + "adminId": 76, + "lastQuery": "INSERT INTO `admin` (`name`,`phone`,`state`,`password`,`role_id`,`title`,`create_time`,`modify_time`) VALUES (?,?,?,?,?,?,NOW(),NOW());", + "name": "Insert 插入测试 (admin表)", + "result": true + }, + { + "admin": { + "create_time": "2026-07-13 09:19:02", + "id": 76, + "modify_time": "2026-07-13 09:19:02", + "name": "测试管理员_1783934341158219100", + "password": "test123456", + "phone": "13958219100", + "role_id": 1, + "state": 1, + "title": "测试职位" + }, + "name": "Get 获取单条记录测试", + "result": true + }, + { + "count": 5, + "name": "Select 单条件查询测试", + "result": true + }, + { + "count": 5, + "lastQuery": "SELECT * FROM `admin` WHERE `role_id` \u003e? AND `state` =? ORDER BY id DESC LIMIT 5 ;", + "name": "Select 多条件自动AND测试", + "result": true + }, + { + "affected": 1, + "name": "Update 更新测试", + "result": true + }, + { + "affected": 1, + "name": "Delete 删除测试", + "result": true + } + ] + }, + "2_condition_syntax": { + "name": "条件查询语法测试", + "success": true, + "tests": [ + { + "count": 3, + "name": "等于条件 (=)", + "result": true + }, + { + "count": 3, + "lastQuery": "SELECT id,title,state FROM `article` WHERE `state` !=? LIMIT 3 ;", + "name": "不等于条件 ([!])", + "result": true + }, + { + "count": 3, + "name": "大于小于条件 ([\u003e], [\u003c])", + "result": true + }, + { + "count": 3, + "name": "大于等于小于等于条件 ([\u003e=], [\u003c=])", + "result": true + }, + { + "count": 1, + "lastQuery": "SELECT id,title FROM `article` WHERE `title` LIKE ? LIMIT 3 ;", + "name": "LIKE 模糊查询 ([~])", + "result": true + }, + { + "count": 0, + "name": "右模糊查询 ([~!])", + "result": true + }, + { + "count": 3, + "lastQuery": "SELECT id,title,click_num FROM `article` WHERE `click_num` BETWEEN ? AND ? LIMIT 3 ;", + "name": "BETWEEN 区间查询 ([\u003c\u003e])", + "result": true + }, + { + "count": 3, + "name": "NOT BETWEEN 查询 ([\u003e\u003c])", + "result": true + }, + { + "count": 5, + "lastQuery": "SELECT id,title FROM `article` WHERE (`id` BETWEEN ? AND ? ) LIMIT 5 ;", + "name": "IN 查询", + "result": true + }, + { + "count": 5, + "name": "NOT IN 查询 ([!])", + "result": true + }, + { + "count": 3, + "name": "IS NULL 查询", + "result": true + }, + { + "count": 3, + "name": "IS NOT NULL 查询 ([!])", + "result": true + }, + { + "count": 3, + "lastQuery": "SELECT id,title,create_time FROM `article` WHERE create_time \u003e DATE_SUB(NOW(), INTERVAL 365 DAY) LIMIT 3 ;", + "name": "直接 SQL 片段查询 ([##])", + "result": true + }, + { + "count": 3, + "name": "显式 AND 条件", + "result": true + }, + { + "count": 5, + "name": "OR 条件", + "result": true + }, + { + "count": 5, + "lastQuery": "SELECT id,title,sort,state FROM `article` WHERE (( `click_num` \u003e? OR `sort` \u003e=? ) AND `state` =?) LIMIT 5 ;", + "name": "嵌套 AND/OR 条件", + "result": true + } + ] + }, + "3_chain_query": { + "name": "链式查询测试", + "success": true, + "tests": [ + { + "count": 9, + "name": "基本链式查询 Table().Where().Select()", + "result": true + }, + { + "count": 9, + "name": "链式 And 条件", + "result": true + }, + { + "count": 9, + "name": "链式 Or 条件", + "result": true + }, + { + "count": 5, + "name": "链式 Order 排序", + "result": true + }, + { + "count": 3, + "name": "链式 Limit 限制", + "result": true + }, + { + "article": { + "author": "管理员", + "id": 1, + "title": "数据库入门指南" + }, + "name": "链式 Get 获取单条", + "result": true + }, + { + "count": 9, + "name": "链式 Count 统计", + "result": true + }, + { + "count": 5, + "name": "链式 Page 分页", + "result": true + }, + { + "name": "链式 Group 分组", + "result": true, + "stats": [ + { + "cnt": 3, + "ctg_id": 1 + }, + { + "cnt": 4, + "ctg_id": 2 + }, + { + "cnt": 1, + "ctg_id": 3 + }, + { + "cnt": 1, + "ctg_id": 4 + } + ] + }, + { + "affected": 1, + "name": "链式 Update 更新", + "result": true + } + ] + }, + "4_join_query": { + "name": "JOIN查询测试", + "success": true, + "tests": [ + { + "count": 5, + "data": [ + { + "ctg_name": "新闻资讯", + "id": 1, + "title": "数据库入门指南" + }, + { + "ctg_name": "新闻资讯", + "id": 2, + "title": "新闻发布系统上线公告" + }, + { + "ctg_name": "新闻资讯", + "id": 11, + "title": "高点击量文章" + }, + { + "ctg_name": "技术文档", + "id": 3, + "title": "Go语言最佳实践" + }, + { + "ctg_name": "技术文档", + "id": 4, + "title": "API接口设计规范" + } + ], + "name": "LEFT JOIN 链式查询", + "result": true + }, + { + "count": 5, + "lastQuery": "SELECT `article`.`id`, `article`.`title`, `ctg`.`name` as ctg_name FROM `article` LEFT JOIN `ctg` ON `article`.`ctg_id` = `ctg`.`id` WHERE `article`.`state` =? LIMIT 5 ;", + "name": "传统 JOIN 语法", + "result": true + }, + { + "count": 5, + "data": [ + { + "admin_name": "超级管理员", + "ctg_name": "新闻资讯", + "id": 1, + "title": "数据库入门指南" + }, + { + "admin_name": "编辑员", + "ctg_name": "新闻资讯", + "id": 2, + "title": "新闻发布系统上线公告" + }, + { + "admin_name": "编辑员", + "ctg_name": "新闻资讯", + "id": 11, + "title": "高点击量文章" + }, + { + "admin_name": "超级管理员", + "ctg_name": "技术文档", + "id": 3, + "title": "Go语言最佳实践" + }, + { + "admin_name": "审核员", + "ctg_name": "技术文档", + "id": 4, + "title": "API接口设计规范" + } + ], + "name": "多表 JOIN", + "result": true + }, + { + "count": 5, + "name": "INNER JOIN", + "result": true + } + ] + }, + "5_aggregate": { + "name": "聚合函数测试", + "success": true, + "tests": [ + { + "count": 11, + "name": "Count 总数统计", + "result": true + }, + { + "count": 9, + "name": "Count 条件统计", + "result": true + }, + { + "lastQuery": "SELECT SUM(click_num) FROM `article` WHERE `state` =? ;", + "name": "Sum 求和 (单字段名)", + "result": true, + "sum": 12311 + }, + { + "avg": 1367.8889, + "lastQuery": "SELECT AVG(click_num) FROM `article` WHERE `state` =? ;", + "name": "Avg 平均值 (单字段名)", + "result": true + }, + { + "lastQuery": "SELECT MAX(click_num) FROM `article` WHERE `state` =? ;", + "max": 9999, + "name": "Max 最大值 (单字段名)", + "result": true + }, + { + "lastQuery": "SELECT MIN(sort) FROM `article` WHERE `state` =? ;", + "min": 1, + "name": "Min 最小值 (单字段名)", + "result": true + }, + { + "name": "GROUP BY 分组统计", + "result": true, + "stats": [ + { + "article_count": 4, + "avg_clicks": 450, + "ctg_id": 2, + "total_clicks": 1800 + }, + { + "article_count": 3, + "avg_clicks": 3461, + "ctg_id": 1, + "total_clicks": 10383 + }, + { + "article_count": 1, + "avg_clicks": 32, + "ctg_id": 3, + "total_clicks": 32 + }, + { + "article_count": 1, + "avg_clicks": 96, + "ctg_id": 4, + "total_clicks": 96 + } + ] + }, + { + "lastQuery": "SELECT SUM(`article`.`click_num`) FROM `article` WHERE `state` =? ;", + "match_single_field": false, + "name": "Sum 求和 (table.column 格式)", + "result": true, + "sum": 0 + }, + { + "name": "聚合函数一致性验证", + "result": false, + "summary": "Sum: 12311=0, Avg: 1367.8889=0, Max: 9999=0, Min: 1=0" + }, + { + "lastQuery": "SELECT SUM(`article`.`click_num`) FROM `article` LEFT JOIN `ctg` ON `article`.`ctg_id` = `ctg`.`id` WHERE `article`.`state` =? ;", + "name": "Sum 带 JOIN (table.column 格式)", + "result": true, + "sum": 0 + }, + { + "count": 9, + "lastQuery": "SELECT COUNT(*) FROM `article` LEFT JOIN `ctg` ON `article`.`ctg_id` = `ctg`.`id` WHERE `article`.`state` =? ;", + "name": "Count 带 JOIN", + "result": true + } + ] + }, + "6_pagination": { + "name": "分页查询测试", + "success": true, + "tests": [ + { + "count": 5, + "name": "PageSelect 分页查询", + "result": true + }, + { + "count": 4, + "name": "PageSelect 第二页", + "result": true + }, + { + "count": 3, + "name": "链式 Page 分页", + "result": true + }, + { + "count": 3, + "lastQuery": "SELECT id,title FROM `article` WHERE (`state` =?) LIMIT 3 OFFSET 2 ;", + "name": "Offset 偏移查询", + "result": true + } + ] + }, + "7_batch_insert": { + "name": "批量插入测试", + "success": true, + "tests": [ + { + "affected": 3, + "lastQuery": "INSERT INTO `test_batch` (`name`, `state`, `title`) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?)", + "name": "Inserts 批量插入", + "result": true + }, + { + "affected": 2, + "name": "Inserts 带 [#] 标记", + "result": true + } + ] + }, + "8_upsert": { + "name": "Upsert测试", + "success": true, + "tests": [ + { + "affected": 1, + "lastQuery": "INSERT INTO `admin` (`password`, `role_id`, `title`, `create_time`, `modify_time`, `name`, `phone`, `state`) VALUES (?, ?, ?, NOW(), NOW(), ?, ?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `state` = VALUES(`state`), `title` = VALUES(`title`), `modify_time` = NOW()", + "name": "Upsert 插入新记录 (admin表)", + "result": true + }, + { + "affected": 2, + "name": "Upsert 更新已存在记录", + "result": true, + "updatedAdmin": { + "create_time": "2026-07-13 09:19:02", + "id": 77, + "modify_time": "2026-07-13 09:19:02", + "name": "Upsert更新后管理员", + "password": "test123", + "phone": "19983934341", + "role_id": 2, + "state": 1, + "title": "更新后职位" + } + } + ] + }, + "9_transaction": { + "name": "事务测试", + "success": true, + "tests": [ + { + "name": "事务成功提交", + "recordExists": true, + "result": true + }, + { + "name": "事务回滚", + "recordRolledBack": false, + "result": true + } + ] + } + }, + "status": 0 + }, + "expect": { + "result": { + "1_basic_crud": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample", + "result": true + } + ] + }, + "2_condition_syntax": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample", + "result": true + } + ] + }, + "3_chain_query": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "4_join_query": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "5_aggregate": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "6_pagination": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "7_batch_insert": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "8_upsert": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "9_transaction": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + } + }, + "status": 0 + } + } + ], "ctr": "test", - "method": "POST", + "method": "GET", "params": [], "path": "/app/test/all", "project": "app", @@ -211,9 +1877,47 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "批量插入", + "method": "GET", + "passed": true, + "response": { + "result": { + "name": "批量插入测试", + "success": true, + "tests": [ + { + "affected": 3, + "lastQuery": "INSERT INTO `test_batch` (`name`, `state`, `title`) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?)", + "name": "Inserts 批量插入", + "result": true + }, + { + "affected": 2, + "name": "Inserts 带 [#] 标记", + "result": true + } + ] + }, + "status": 0 + }, + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + } + } + ], "ctr": "test", - "method": "POST", + "method": "GET", "params": [], "path": "/app/test/batch", "project": "app", @@ -221,9 +1925,107 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "链式查询", + "method": "GET", + "passed": true, + "response": { + "result": { + "name": "链式查询测试", + "success": true, + "tests": [ + { + "count": 9, + "name": "基本链式查询 Table().Where().Select()", + "result": true + }, + { + "count": 9, + "name": "链式 And 条件", + "result": true + }, + { + "count": 9, + "name": "链式 Or 条件", + "result": true + }, + { + "count": 5, + "name": "链式 Order 排序", + "result": true + }, + { + "count": 3, + "name": "链式 Limit 限制", + "result": true + }, + { + "article": { + "author": "管理员", + "id": 1, + "title": "数据库入门指南" + }, + "name": "链式 Get 获取单条", + "result": true + }, + { + "count": 9, + "name": "链式 Count 统计", + "result": true + }, + { + "count": 5, + "name": "链式 Page 分页", + "result": true + }, + { + "name": "链式 Group 分组", + "result": true, + "stats": [ + { + "cnt": 3, + "ctg_id": 1 + }, + { + "cnt": 4, + "ctg_id": 2 + }, + { + "cnt": 1, + "ctg_id": 3 + }, + { + "cnt": 1, + "ctg_id": 4 + } + ] + }, + { + "affected": 0, + "name": "链式 Update 更新", + "result": true + } + ] + }, + "status": 0 + }, + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + } + } + ], "ctr": "test", - "method": "POST", + "method": "GET", "params": [], "path": "/app/test/chain", "project": "app", @@ -231,9 +2033,123 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "条件查询", + "method": "GET", + "passed": true, + "response": { + "result": { + "name": "条件查询语法测试", + "success": true, + "tests": [ + { + "count": 3, + "name": "等于条件 (=)", + "result": true + }, + { + "count": 3, + "lastQuery": "SELECT id,title,state FROM `article` WHERE `state` !=? LIMIT 3 ;", + "name": "不等于条件 ([!])", + "result": true + }, + { + "count": 3, + "name": "大于小于条件 ([\u003e], [\u003c])", + "result": true + }, + { + "count": 3, + "name": "大于等于小于等于条件 ([\u003e=], [\u003c=])", + "result": true + }, + { + "count": 1, + "lastQuery": "SELECT id,title FROM `article` WHERE `title` LIKE ? LIMIT 3 ;", + "name": "LIKE 模糊查询 ([~])", + "result": true + }, + { + "count": 0, + "name": "右模糊查询 ([~!])", + "result": true + }, + { + "count": 3, + "lastQuery": "SELECT id,title,click_num FROM `article` WHERE `click_num` BETWEEN ? AND ? LIMIT 3 ;", + "name": "BETWEEN 区间查询 ([\u003c\u003e])", + "result": true + }, + { + "count": 3, + "name": "NOT BETWEEN 查询 ([\u003e\u003c])", + "result": true + }, + { + "count": 5, + "lastQuery": "SELECT id,title FROM `article` WHERE (`id` BETWEEN ? AND ? ) LIMIT 5 ;", + "name": "IN 查询", + "result": true + }, + { + "count": 5, + "name": "NOT IN 查询 ([!])", + "result": true + }, + { + "count": 3, + "name": "IS NULL 查询", + "result": true + }, + { + "count": 3, + "name": "IS NOT NULL 查询 ([!])", + "result": true + }, + { + "count": 3, + "lastQuery": "SELECT id,title,create_time FROM `article` WHERE create_time \u003e DATE_SUB(NOW(), INTERVAL 365 DAY) LIMIT 3 ;", + "name": "直接 SQL 片段查询 ([##])", + "result": true + }, + { + "count": 3, + "name": "显式 AND 条件", + "result": true + }, + { + "count": 5, + "name": "OR 条件", + "result": true + }, + { + "count": 5, + "lastQuery": "SELECT id,title,sort,state FROM `article` WHERE (( `click_num` \u003e? OR `sort` \u003e=? ) AND `state` =?) LIMIT 5 ;", + "name": "嵌套 AND/OR 条件", + "result": true + } + ] + }, + "status": 0 + }, + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample", + "result": true + } + ] + }, + "status": 0 + } + } + ], "ctr": "test", - "method": "POST", + "method": "GET", "params": [], "path": "/app/test/condition", "project": "app", @@ -241,9 +2157,79 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "CRUD测试", + "method": "GET", + "passed": true, + "response": { + "result": { + "name": "基础CRUD测试", + "success": true, + "tests": [ + { + "adminId": 79, + "lastQuery": "INSERT INTO `admin` (`phone`,`state`,`password`,`role_id`,`title`,`create_time`,`modify_time`,`name`) VALUES (?,?,?,?,?,NOW(),NOW(),?);", + "name": "Insert 插入测试 (admin表)", + "result": true + }, + { + "admin": { + "create_time": "2026-07-13 09:19:02", + "id": 79, + "modify_time": "2026-07-13 09:19:02", + "name": "测试管理员_1783934341599114700", + "password": "test123456", + "phone": "13999114700", + "role_id": 1, + "state": 1, + "title": "测试职位" + }, + "name": "Get 获取单条记录测试", + "result": true + }, + { + "count": 5, + "name": "Select 单条件查询测试", + "result": true + }, + { + "count": 5, + "lastQuery": "SELECT * FROM `admin` WHERE `role_id` \u003e? AND `state` =? ORDER BY id DESC LIMIT 5 ;", + "name": "Select 多条件自动AND测试", + "result": true + }, + { + "affected": 1, + "name": "Update 更新测试", + "result": true + }, + { + "affected": 1, + "name": "Delete 删除测试", + "result": true + } + ] + }, + "status": 0 + }, + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample", + "result": true + } + ] + }, + "status": 0 + } + } + ], "ctr": "test", - "method": "POST", + "method": "GET", "params": [], "path": "/app/test/crud", "project": "app", @@ -251,9 +2237,116 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "JOIN查询", + "method": "GET", + "passed": true, + "response": { + "result": { + "name": "JOIN查询测试", + "success": true, + "tests": [ + { + "count": 5, + "data": [ + { + "ctg_name": "新闻资讯", + "id": 1, + "title": "数据库入门指南" + }, + { + "ctg_name": "新闻资讯", + "id": 2, + "title": "新闻发布系统上线公告" + }, + { + "ctg_name": "新闻资讯", + "id": 11, + "title": "高点击量文章" + }, + { + "ctg_name": "技术文档", + "id": 3, + "title": "Go语言最佳实践" + }, + { + "ctg_name": "技术文档", + "id": 4, + "title": "API接口设计规范" + } + ], + "name": "LEFT JOIN 链式查询", + "result": true + }, + { + "count": 5, + "lastQuery": "SELECT `article`.`id`, `article`.`title`, `ctg`.`name` as ctg_name FROM `article` LEFT JOIN `ctg` ON `article`.`ctg_id` = `ctg`.`id` WHERE `article`.`state` =? LIMIT 5 ;", + "name": "传统 JOIN 语法", + "result": true + }, + { + "count": 5, + "data": [ + { + "admin_name": "超级管理员", + "ctg_name": "新闻资讯", + "id": 1, + "title": "数据库入门指南" + }, + { + "admin_name": "编辑员", + "ctg_name": "新闻资讯", + "id": 2, + "title": "新闻发布系统上线公告" + }, + { + "admin_name": "编辑员", + "ctg_name": "新闻资讯", + "id": 11, + "title": "高点击量文章" + }, + { + "admin_name": "超级管理员", + "ctg_name": "技术文档", + "id": 3, + "title": "Go语言最佳实践" + }, + { + "admin_name": "审核员", + "ctg_name": "技术文档", + "id": 4, + "title": "API接口设计规范" + } + ], + "name": "多表 JOIN", + "result": true + }, + { + "count": 5, + "name": "INNER JOIN", + "result": true + } + ] + }, + "status": 0 + }, + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + } + } + ], "ctr": "test", - "method": "POST", + "method": "GET", "params": [], "path": "/app/test/join", "project": "app", @@ -261,9 +2354,57 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "分页查询", + "method": "GET", + "passed": true, + "response": { + "result": { + "name": "分页查询测试", + "success": true, + "tests": [ + { + "count": 5, + "name": "PageSelect 分页查询", + "result": true + }, + { + "count": 4, + "name": "PageSelect 第二页", + "result": true + }, + { + "count": 3, + "name": "链式 Page 分页", + "result": true + }, + { + "count": 3, + "lastQuery": "SELECT id,title FROM `article` WHERE (`state` =?) LIMIT 3 OFFSET 2 ;", + "name": "Offset 偏移查询", + "result": true + } + ] + }, + "status": 0 + }, + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + } + } + ], "ctr": "test", - "method": "POST", + "method": "GET", "params": [], "path": "/app/test/pagination", "project": "app", @@ -271,9 +2412,37 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "固定返回错误码2", + "method": "GET", + "passed": true, + "response": { + "error": { + "msg": "dsadasd", + "type": "访问权限异常" + }, + "result": { + "msg": "dsadasd", + "type": "访问权限异常" + }, + "status": 2 + }, + "expect": { + "error": { + "msg": "dsadasd", + "type": "访问权限异常" + }, + "result": { + "msg": "dsadasd", + "type": "访问权限异常" + }, + "status": 2 + } + } + ], "ctr": "test", - "method": "POST", + "method": "GET", "params": [], "path": "/app/test/test", "project": "app", @@ -281,9 +2450,46 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "事务测试", + "method": "GET", + "passed": true, + "response": { + "result": { + "name": "事务测试", + "success": true, + "tests": [ + { + "name": "事务成功提交", + "recordExists": true, + "result": true + }, + { + "name": "事务回滚", + "recordRolledBack": false, + "result": true + } + ] + }, + "status": 0 + }, + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + } + } + ], "ctr": "test", - "method": "POST", + "method": "GET", "params": [], "path": "/app/test/transaction", "project": "app", @@ -291,9 +2497,58 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "name": "Upsert测试", + "method": "GET", + "passed": true, + "response": { + "result": { + "name": "Upsert测试", + "success": true, + "tests": [ + { + "affected": 1, + "lastQuery": "INSERT INTO `admin` (`create_time`, `modify_time`, `name`, `phone`, `state`, `password`, `role_id`, `title`) VALUES (NOW(), NOW(), ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `state` = VALUES(`state`), `title` = VALUES(`title`), `modify_time` = NOW()", + "name": "Upsert 插入新记录 (admin表)", + "result": true + }, + { + "affected": 2, + "name": "Upsert 更新已存在记录", + "result": true, + "updatedAdmin": { + "create_time": "2026-07-13 09:19:02", + "id": 80, + "modify_time": "2026-07-13 09:19:02", + "name": "Upsert更新后管理员", + "password": "test123", + "phone": "19983934341", + "role_id": 2, + "state": 1, + "title": "更新后职位" + } + } + ] + }, + "status": 0 + }, + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + } + } + ], "ctr": "test", - "method": "POST", + "method": "GET", "params": [], "path": "/app/test/upsert", "project": "app", @@ -301,6 +2556,73 @@ "tested": true } ], + "flows": [ + { + "desc": "expect_demo 多接口联调", + "group": "expect_demo", + "name": "expect_chain", + "passed": true, + "prep": "依次调用 string_result 与 map_result。\n步骤通过 FromCase 复用单接口验收用例的请求模板,跑通后回写该用例的响应与通过状态。", + "steps": [ + { + "bindCase": "result是字符串", + "caseName": "步骤1-字符串", + "expect": { + "result": "样本", + "status": 0 + }, + "failReason": "", + "form": null, + "index": 1, + "json": null, + "method": "GET", + "name": "字符串结果", + "note": "校验字符串 result:期望返回「操作成功」。", + "passed": true, + "path": "/app/expect_demo/string_result", + "query": null, + "response": { + "result": "操作成功", + "status": 0 + }, + "verifyError": "" + }, + { + "bindCase": "result是Map-校验字段名+类型+值", + "caseName": "步骤2-Map", + "expect": { + "result": { + "id": 1, + "in_stock": true, + "name": "sample", + "price": 1 + }, + "status": 0 + }, + "failReason": "", + "form": null, + "index": 2, + "json": null, + "method": "GET", + "name": "Map结果", + "note": "校验对象 result:至少包含 id=1,用于串联后续步骤前的结构确认。", + "passed": true, + "path": "/app/expect_demo/map_result", + "query": null, + "response": { + "result": { + "id": 1, + "in_stock": true, + "name": "示例商品", + "price": 19.9 + }, + "status": 0 + }, + "verifyError": "" + } + ] + } + ], "title": "My API", "version": "1.0.0" } \ No newline at end of file diff --git a/example/tpt/swagger/app/index.html b/example/tpt/swagger/app/index.html index 85bd93a..145b4d0 100644 --- a/example/tpt/swagger/app/index.html +++ b/example/tpt/swagger/app/index.html @@ -47,11 +47,24 @@ input,select,textarea,button{font:inherit}pre{margin:0} .cs{border:1px solid var(--border);border-radius:var(--r);margin-bottom:6px;overflow:hidden} .csh{padding:6px 10px;display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;background:var(--bg2)} .csh:hover{background:var(--bg3)}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0} -.dot.ok{background:var(--green)}.dot.er{background:var(--red)}.csh .ml{margin-left:auto;color:#555;font-size:11px} +.dot.ok{background:var(--green)}.dot.er{background:var(--red)}.csh .ml{margin-left:auto;color:#555;font-size:11px;display:flex;align-items:center;gap:6px;flex-shrink:0;max-width:55%} +.path-link{color:var(--accent);cursor:pointer;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px;display:inline-block;vertical-align:bottom} +.path-link:hover{text-decoration:underline} +.bind-tag{font-size:10px;color:#888;padding:1px 6px;border:1px solid var(--border);border-radius:10px;white-space:nowrap;max-width:160px;overflow:hidden;text-overflow:ellipsis} +.flow-prep{margin-top:8px;padding:8px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);color:var(--fg2);font-size:12px;line-height:1.6;white-space:pre-wrap} +.flow-prep .lbl{display:inline-block;color:var(--accent);font-size:10px;margin-bottom:4px;letter-spacing:.5px} +.flow-note{color:#aaa;font-size:12px;line-height:1.5;margin-bottom:8px;white-space:pre-wrap} +.flow-note.empty{color:#555} .csb{display:none;border-top:1px solid var(--border);font-size:12px}.csb.o{display:block} .csr{display:flex}.csr>.csc{flex:1;padding:8px 10px;min-width:0}.csr>.csc+.csc{border-left:1px solid var(--border)} .csc h5{color:var(--accent);margin:0 0 4px;font-size:11px;letter-spacing:.5px;display:flex;align-items:center} .csc h5 .cp-sm{margin-left:auto}.csc h5:not(:first-child){margin-top:8px} +.modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px} +.modal-box{background:var(--bg);border:1px solid var(--border);border-radius:8px;width:min(920px,96vw);max-height:88vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(0,0,0,.45)} +.modal-hd{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0} +.modal-hd .x{margin-left:auto;background:var(--bg3);border:1px solid var(--border);color:var(--fg2);border-radius:var(--r);cursor:pointer;padding:2px 10px;font-size:14px} +.modal-hd .x:hover{color:var(--accent);border-color:var(--accent)} +.modal-bd{overflow:auto;padding:12px;flex:1} pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;line-height:1.4;color:#aaa;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow:auto;font-family:Consolas,Monaco,monospace} .resp-wrap{display:flex;gap:0}.resp-main{flex:1;min-width:0} .resp-main .rsp-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px} @@ -136,7 +149,7 @@ function epPass(e){if(!e.cases?.length)return false;return e.cases.every(x=>x.pa function epFail(e){if(!e.cases?.length)return e.tested;return e.cases.some(x=>!x.passed);} function render(){ if(!D)return;const q=document.getElementById('q').value.toLowerCase();const T={}; - for(const e of D.endpoints){ + for(const e of D.endpoints||[]){ if(F==='yes'&&!e.tested)continue; if(F==='no'&&e.tested)continue; if(F==='pass'&&!epPass(e))continue; @@ -145,13 +158,35 @@ function render(){ if(!T[e.project])T[e.project]={};if(!T[e.project][e.ctr])T[e.project][e.ctr]=[];T[e.project][e.ctr].push(e); } let h=''; + const flows=(D.flows||[]).filter(f=>{ + if(q&&!f.name.toLowerCase().includes(q)&&!(f.desc||'').toLowerCase().includes(q))return false; + if(F==='pass'&&!f.passed)return false; + if(F==='fail'&&f.passed)return false; + if(F==='no')return false; + return true; + }); + if(flows.length){ + const byG={}; + for(const f of flows){const g=f.group||'default';if(!byG[g])byG[g]=[];byG[g].push(f);} + let fh=''; + for(const g of Object.keys(byG).sort()){ + let gh=''; + for(const f of byG[g]){ + const sc=f.steps?.length||0; + const tg=f.passed?'通过':'失败'; + gh+='
FLOW'+esc(f.name)+''+tg+''+sc+'
'; + } + fh+='
'+esc(g)+''+byG[g].length+'
'+gh+'
'; + } + h+='
业务流程'+flows.length+'
'+fh+'
'; + } for(const p of Object.keys(T).sort()){let ph=''; for(const c of Object.keys(T[p]).sort()){const es=T[p][c];let eh=''; for(const e of es){const m=e.method.toLowerCase(); let tg='';if(!e.tested)tg='未测试'; else if(e.cases?.length){const pc=e.cases.filter(x=>x.passed).length;tg=pc===e.cases.length?''+pc+'/'+e.cases.length+'':''+pc+'/'+e.cases.length+'';} const mn=e.path.split('/').pop();const lbl=e.tested&&e.summary!==mn?mn+' '+e.summary:e.summary; - eh+='
'+e.method+''+lbl+''+tg+'
'; + eh+='
'+e.method+''+lbl+''+tg+'
'; } ph+='
'+c+''+es.length+'
'+eh+'
'; } @@ -162,7 +197,92 @@ function render(){ } function sf(f,b){F=f;document.querySelectorAll('.sft button').forEach(x=>x.classList.remove('on'));b.classList.add('on');render();} function tog(e){e.querySelector('.ar').classList.toggle('o');e.nextElementSibling.classList.toggle('o');} -function go(el){C=D.endpoints.find(e=>e.path===el.dataset.p);if(!C)return;document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');show();} +function go(el){C=D.endpoints.find(e=>e.path===el.dataset.p);if(!C)return;C.flowName=undefined;document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');show();} +function goFlow(el){const f=(D.flows||[]).find(x=>x.name===el.dataset.flow);if(!f)return;C={flowName:f.name,flow:f};document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');showFlow(f);} +function stepCurl(s){ + const base=window.location.origin; + let url=base+(s.path||''); + const m=s.method||'POST'; + if(s.query){const qs=Object.keys(s.query).filter(k=>s.query[k]!==undefined&&s.query[k]!=='').map(k=>encodeURIComponent(k)+'='+encodeURIComponent(s.query[k])).join('&');if(qs)url+='?'+qs;} + let cmd="curl -X "+m+" '"+url+"'"; + if(s.json){cmd+="\\\n -H 'Content-Type: application/json'";cmd+="\\\n -d '"+JSON.stringify(s.json).replace(/'/g,"'\\''")+"'";} + else if(s.form){const fd=Object.keys(s.form).map(k=>"-F '"+k+"="+s.form[k]+"'").join("\\\n ");if(fd)cmd+="\\\n "+fd;} + return cmd; +} +function hasReqData(s){return !!(s&&((s.query&&Object.keys(s.query).length)||s.json||(s.form&&Object.keys(s.form).length)));} +function closeEpModal(){const m=document.getElementById('flow-ep-modal');if(m)m.remove();} +function openEpModal(path,bindCase){ + const e=(D.endpoints||[]).find(x=>x.path===path); + closeEpModal(); + const mask=document.createElement('div');mask.id='flow-ep-modal';mask.className='modal-mask'; + mask.onclick=ev=>{if(ev.target===mask)closeEpModal();}; + let hd=''; + let bd=''; + mask.innerHTML=''; + document.body.appendChild(mask); +} +document.addEventListener('keydown',e=>{if(e.key==='Escape')closeEpModal();}); +function showFlow(f){ + let h='
FLOW'+esc(f.name)+''; + if(f.desc)h+=''+esc(f.desc)+''; + h+=''+(f.passed?'通过':'失败')+''; + h+=''+(f.steps?.length||0)+'个步骤
'; + if(f.prep)h+='
业务说明
'+esc(f.prep)+'
'; + h+='
'; + h+='
'; + if(!f.steps?.length){h+='
暂无步骤
';} + else{for(let i=0;i
'+esc((s.index||i+1)+'. '+(s.name||s.caseName||''))+''; + h+=''+(s.method||'')+''; + if(s.path)h+=''+esc(s.path)+''; + h+='
'; + h+='
'; + h+='
'; + if(s.bindCase)h+='
关联验收用例
'; + h+='
备注
'; + if(s.note)h+='
'+esc(s.note)+'
'; + else h+='
无备注
'; + if(!s.passed&&s.failReason)h+='
失败原因'+esc(s.failReason)+'
'; + else if(s.verifyError)h+='
Verify'+esc(s.verifyError)+'
'; + h+='
请求
'; + h+='
'+esc(stepCurl(s))+'
'; + if(hasReqData(s)){ + if(s.query)h+='
Query
'+fj(s.query)+'
'; + if(s.json)h+='
JSON
'+fj(s.json)+'
'; + if(s.form)h+='
Form
'+fj(s.form)+'
'; + }else{h+='
无请求参数
';} + h+='
'; + h+='
响应'+(s.passed?'通过':'失败')+'
'; + if(s.response)h+='
'+fj(s.response)+'
'; + else h+='无响应'; + h+='
'; + }} + h+=''; + document.getElementById('ct').innerHTML=h; + const failEl=document.querySelector('#ct .flow-step-fail'); + if(failEl)failEl.scrollIntoView({block:'nearest',behavior:'smooth'}); +} +function flowStepToggle(el){ + const bd=el.nextElementSibling;if(!bd)return; + bd.classList.toggle('o'); + el.querySelector('.ar')?.classList.toggle('o'); +} function show(){ const e=C;if(!e)return;const m=e.method.toLowerCase();const cnt=e.cases?.length||0; @@ -184,6 +304,8 @@ function show(){ let defIdx=-1; if(cnt>0){for(let i=e.cases.length-1;i>=0;i--){if(e.cases[i].passed){defIdx=i;break;}}if(defIdx<0)defIdx=e.cases.length-1;} pfSelect(defIdx>=0?defIdx:''); + bindLiveCurl(); + refreshLiveCurl(); } function rswitch(i){ document.querySelectorAll('.rtab').forEach((t,j)=>t.classList.toggle('on',j===i)); @@ -225,8 +347,8 @@ function cases(e){ return h; } function accordion(el){ - const bd=el.nextElementSibling;const wasOpen=bd.classList.contains('o'); - const panel=el.closest('.rpn'); + const bd=el.nextElementSibling;if(!bd)return;const wasOpen=bd.classList.contains('o'); + const panel=el.closest('.modal-bd')||el.closest('.rpn')||el.closest('#ct')||document; panel.querySelectorAll('.csb').forEach(b=>b.classList.remove('o')); panel.querySelectorAll('.csh .ar').forEach(a=>a.classList.remove('o')); if(!wasOpen){bd.classList.add('o');el.querySelector('.ar').classList.add('o');} @@ -236,7 +358,7 @@ function respPanel(){ let r=''; r+=''; r+='
cURL
'; - r+='
发送请求后生成
'; + r+='
根据左侧参数实时生成
'; r+='
响应
'; r+='
发送请求后显示
'; return r; @@ -253,15 +375,15 @@ function buildLeft(e){ l+='
    '; l+=''; l+=''; - let defH='
    '; - if(tk&&at==='header')defH+='
    '; - if(tk&&at==='cookie')defH+='
    '; + let defH='
    '; + if(tk&&at==='header')defH+='
    '; + if(tk&&at==='cookie')defH+='
    '; l+='
    请求头
    '+defH+'
    '; let qDef=''; - if(tk&&at==='query')qDef='
    '; - qDef+='
    '; + if(tk&&at==='query')qDef='
    '; + qDef+='
    '; l+='
    Query 参数
    '+qDef+'
    '; - l+='
    文件上传
    '; + l+='
    文件上传
    '; let jsonPH=''; if(e.params){ const jp=e.params.filter(p=>p.in==='json'); @@ -269,7 +391,7 @@ function buildLeft(e){ } l+='
    请求体
    '; l+='
    表单 (Form)
    '; - l+='
    '; + l+='
    '; l+='
    JSON
    '; l+='
    '; if(e.params){ @@ -295,18 +417,18 @@ function buildLeft(e){ function addKV(id,k,v){ const row=document.createElement('div');row.className='kv'; - row.innerHTML=''; + row.innerHTML=''; document.getElementById(id).appendChild(row); } function addKVReq(id,k,v,req){ const row=document.createElement('div');row.className='kv'; const star=req?'*':''; - row.innerHTML=star+''; + row.innerHTML=star+''; document.getElementById(id).appendChild(row); } function addFileRow(field){ const row=document.createElement('div');row.className='kv'; - row.innerHTML=''; + row.innerHTML=''; document.getElementById('file-rows').appendChild(row); } function setAcc(t){ @@ -315,23 +437,26 @@ function setAcc(t){ document.getElementById('bt-form').classList.toggle('on',t==='form'); const ctVal=document.getElementById('ct-val'); if(ctVal)ctVal.value=t==='json'?'application/json':'application/x-www-form-urlencoded'; + refreshLiveCurl(); } function togBody(t){ const cur=document.getElementById(t==='json'?'bt-json':'bt-form').classList.contains('on'); setAcc(cur?(t==='json'?'form':'json'):t); } -function autoH(el){el.style.height='36px';el.style.height=Math.min(el.scrollHeight,window.innerHeight*0.4)+'px';} +function autoH(el){el.style.height='36px';el.style.height=Math.min(el.scrollHeight,window.innerHeight*0.4)+'px';refreshLiveCurl();} function syncAuth(){ const tk=document.getElementById('tk').value;const at=document.getElementById('at').value; const hr=document.getElementById('h-rows');const qr=document.getElementById('q-rows'); - if(!hr||!qr)return; + if(!hr||!qr){refreshLiveCurl();return;} hr.querySelectorAll('[data-auth]').forEach(e=>e.remove()); qr.querySelectorAll('[data-auth]').forEach(e=>e.remove()); - if(!tk)return; - function mkAuth(k,v,parent){const d=document.createElement('div');d.className='kv';d.dataset.auth='1';d.innerHTML='';parent.insertBefore(d,parent.querySelector('.kv:last-child'));} - if(at==='header')mkAuth('Authorization',tk,hr); - if(at==='cookie')mkAuth('Cookie','HOTIME='+tk,hr); - if(at==='query')mkAuth('token',tk,qr); + if(tk){ + function mkAuth(k,v,parent){const d=document.createElement('div');d.className='kv';d.dataset.auth='1';d.innerHTML='';parent.insertBefore(d,parent.querySelector('.kv:last-child'));} + if(at==='header')mkAuth('Authorization',tk,hr); + if(at==='cookie')mkAuth('Cookie','HOTIME='+tk,hr); + if(at==='query')mkAuth('token',tk,qr); + } + refreshLiveCurl(); } function buildPfList(e){ @@ -449,6 +574,7 @@ function pfFill(v){ document.getElementById('file-rows').innerHTML=''; if(c.hasFile&&c.fileField)addFileRow(c.fileField); addFileRow();syncAuth(); + refreshLiveCurl(); } function getKV(id){ @@ -457,6 +583,52 @@ function getKV(id){ return Object.keys(obj).length?obj:null; } +function buildLiveCurl(){ + if(!C)return ''; + let url=C.path; + const qp=getKV('q-rows'); + if(qp){const ps=new URLSearchParams();for(const[k,v]of Object.entries(qp))ps.set(k,v);url+='?'+ps.toString();} + const token=document.getElementById('tk')?.value||''; + const authType=document.getElementById('at')?.value||'none'; + const headers={}; + const ch=getKV('h-rows');if(ch)for(const[k,v]of Object.entries(ch)){ + if(k.toLowerCase()==='authorization'&&token&&authType==='header')continue; + if(k.toLowerCase()==='cookie'&&token&&authType==='cookie')continue; + headers[k]=v; + } + if(token&&authType==='header')headers['Authorization']=token; + const fileEls=document.querySelectorAll('#file-rows .kv'); + const files=[];fileEls.forEach(r=>{const fnEl=r.querySelector('input:not([type="file"])');const fi=r.querySelector('input[type="file"]');if(!fnEl||!fi)return;const fn=fnEl.value.trim();if(!fn)return;if(fi.files.length)files.push({field:fn,name:fi.files[0].name});else files.push({field:fn,name:'filename'});}); + const isJson=document.getElementById('bt-json')?.classList.contains('on'); + const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');let curlParts=''; + if(files.length){ + for(const f of files)curlParts+=' -F "'+f.field+'=@'+f.name+'"'; + if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"'; + }else if(isJson){ + const b=bodyEl?.value?.trim(); + if(b&&C.method!=='GET')curlParts=" -d '"+b.replace(/'/g,"'\\''")+"'"; + }else if(formKV){ + for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"'; + } + let curl='curl -X '+C.method; + for(const[k,v]of Object.entries(headers)){if(k&&v)curl+=" -H '"+k+": "+v+"'";} + if(token&&authType==='cookie')curl+=" --cookie 'HOTIME="+token+"'"; + curl+=curlParts+' '+location.origin+url; + return curl; +} +function refreshLiveCurl(){ + const curlBox=document.getElementById('curl-box'); + if(!curlBox||!C)return; + curlBox.textContent=buildLiveCurl(); +} +function bindLiveCurl(){ + const left=document.querySelector('#ct .split-l'); + if(!left||left.dataset.liveCurl==='1')return; + left.dataset.liveCurl='1'; + left.addEventListener('input',refreshLiveCurl); + left.addEventListener('change',refreshLiveCurl); +} + async function send(){ if(!C)return;const btn=document.getElementById('sbtn');btn.disabled=true;btn.textContent='请求中...'; let url=C.path; @@ -476,25 +648,18 @@ async function send(){ const fileEls=document.querySelectorAll('#file-rows .kv'); const files=[];fileEls.forEach(r=>{const fnEl=r.querySelector('input:not([type="file"])');const fi=r.querySelector('input[type="file"]');if(!fnEl||!fi)return;const fn=fnEl.value.trim();if(fn&&fi.files.length)files.push({field:fn,file:fi.files[0]});}); const isJson=document.getElementById('bt-json')?.classList.contains('on'); - const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');let curlParts=''; + const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows'); if(files.length){ const fd=new FormData();for(const f of files)fd.append(f.field,f.file); if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))fd.append(k,v); opts.body=fd;delete opts.headers['Content-Type']; - for(const f of files)curlParts+=' -F "'+f.field+'=@'+f.file.name+'"'; - if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"'; }else if(isJson){ const b=bodyEl?.value?.trim(); - if(b&&C.method!=='GET'){opts.body=b;curlParts=" -d '"+b+"'";} + if(b&&C.method!=='GET')opts.body=b; }else if(formKV){ const fd=new FormData();for(const[k,v]of Object.entries(formKV))fd.append(k,v);opts.body=fd;delete opts.headers['Content-Type']; - for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"'; } - let curl='curl -X '+C.method; - for(const[k,v]of Object.entries(opts.headers)){if(k&&v)curl+=" -H '"+k+": "+v+"'";} - if(token&&authType==='cookie')curl+=" --cookie 'HOTIME="+token+"'"; - curl+=curlParts+' '+location.origin+url; - const curlBox=document.getElementById('curl-box');if(curlBox)curlBox.textContent=curl; + refreshLiveCurl(); rswitch(1); const t0=Date.now(); try{ diff --git a/testing_api.go b/testing_api.go index 52554f3..f916164 100644 --- a/testing_api.go +++ b/testing_api.go @@ -44,30 +44,51 @@ type ApiTestDef struct { // Api 测试 API 入口,由 RunTests 自动创建并注入 type Api struct { - app *TestApp - path string - t *testing.T - session Map - lastResp *ApiResponse + app *TestApp + path string + t *testing.T + session Map + lastResp *ApiResponse + flowName string + flowGroup string + flowDesc string + flowPrep string + flowStep string + flowStepIndex int + flowParent *Flow // 业务流程内执行时回写 lastResp } // WithSession 返回一个携带 session 的新 Api 实例 func (a *Api) WithSession(s Map) *Api { return &Api{ - app: a.app, - path: a.path, - t: a.t, - session: s, + app: a.app, + path: a.path, + t: a.t, + session: s, + flowName: a.flowName, + flowGroup: a.flowGroup, + flowDesc: a.flowDesc, + flowPrep: a.flowPrep, + flowStep: a.flowStep, + flowStepIndex: a.flowStepIndex, + flowParent: a.flowParent, } } // AtPath 返回指向其它 API 路径的 Api(跨接口断言时用) func (a *Api) AtPath(path string) *Api { return &Api{ - app: a.app, - path: path, - t: a.t, - session: a.session, + app: a.app, + path: path, + t: a.t, + session: a.session, + flowName: a.flowName, + flowGroup: a.flowGroup, + flowDesc: a.flowDesc, + flowPrep: a.flowPrep, + flowStep: a.flowStep, + flowStepIndex: a.flowStepIndex, + flowParent: a.flowParent, } } @@ -79,14 +100,29 @@ func (a *Api) RunSub(desc string, fn func(a *Api)) { } a.t.Run(desc, func(t *testing.T) { fn(&Api{ - app: a.app, - path: a.path, - t: t, - session: a.session, + app: a.app, + path: a.path, + t: t, + session: a.session, + flowName: a.flowName, + flowGroup: a.flowGroup, + flowDesc: a.flowDesc, + flowPrep: a.flowPrep, + flowStep: a.flowStep, + flowStepIndex: a.flowStepIndex, + flowParent: a.flowParent, }) }) } +// FromCase 绑定单接口已有验收用例:加载其请求模板;跑通后回写该 case 的响应 +func (a *Api) FromCase(caseName string) *ApiCase { + c := a.newCase() + c.bindCase = caseName + c.applyCaseTemplate(a.app.findCaseTemplate(a.path, caseName)) + return c +} + // JSON 设置 JSON body,返回 ApiCase 构建器 func (a *Api) JSON(body interface{}) *ApiCase { c := a.newCase() @@ -184,6 +220,34 @@ type ApiCase struct { fileContent []byte note string verifyFn func(a *Api) error + bindCase string // 绑定的单接口验收用例名(FromCase) +} + +// FromCase 在已有 ApiCase 上绑定验收用例名(若尚未加载模板则加载) +func (c *ApiCase) FromCase(caseName string) *ApiCase { + c.bindCase = caseName + if c.query == nil && c.jsonBody == nil && c.formBody == nil { + c.applyCaseTemplate(c.api.app.findCaseTemplate(c.api.path, caseName)) + } + return c +} + +func (c *ApiCase) applyCaseTemplate(tmpl *caseTemplate) { + if tmpl == nil { + return + } + if c.query == nil && tmpl.Query != nil { + c.query = tmpl.Query + } + if c.jsonBody == nil && tmpl.Json != nil { + c.jsonBody = tmpl.Json + } + if c.formBody == nil && tmpl.Form != nil { + c.formBody = tmpl.Form + } + if c.note == "" && tmpl.Note != "" { + c.note = tmpl.Note + } } // JSON 叠加 JSON body @@ -294,15 +358,22 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i defer func() { record := TestRecord{ - Path: c.api.path, - Desc: desc, - CaseName: desc, - Passed: passed, - Duration: time.Since(start), - Method: method, - Note: c.note, - VerifyError: verifyError, - FailReason: failReason, + Path: c.api.path, + Desc: desc, + CaseName: desc, + Passed: passed, + Duration: time.Since(start), + Method: method, + Note: c.note, + VerifyError: verifyError, + FailReason: failReason, + FlowName: c.api.flowName, + FlowGroup: c.api.flowGroup, + FlowDesc: c.api.flowDesc, + FlowPrep: c.api.flowPrep, + FlowStep: c.api.flowStep, + FlowStepIndex: c.api.flowStepIndex, + BindCase: c.bindCase, } if c.query != nil { record.Query = c.query @@ -336,6 +407,9 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i } record.ExpectResult = expectResp c.api.app.collector.Add(record) + if c.bindCase != "" && c.api.flowName != "" { + c.api.app.syncBoundCaseToEndpoint(record) + } }() c.api.app.Db.ResetTestSQLErrorCount() @@ -390,6 +464,9 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i } c.api.lastResp = resp + if c.api.flowParent != nil { + c.api.flowParent.SetLastResp(resp) + } if passed && c.verifyFn != nil { if err := c.verifyFn(c.api); err != nil { t.Errorf("Verify 校验失败: %s", err.Error()) diff --git a/testing_flow.go b/testing_flow.go new file mode 100644 index 0000000..d0466d5 --- /dev/null +++ b/testing_flow.go @@ -0,0 +1,96 @@ +package hotime + +import ( + "testing" + + . "code.hoteas.com/golang/hotime/common" + . "code.hoteas.com/golang/hotime/db" +) + +// FlowTest 业务流程测试集合(流程名 → 定义) +type FlowTest map[string]FlowDef + +// FlowDef 单条业务流程定义 +type FlowDef struct { + Group string // 左侧分组,如 "user" / "expect_demo" + Desc string // 流程短标题(右侧标题) + Prep string // 业务说明与前置/数据准备(右侧描述) + Func func(f *Flow) +} + +// Flow 业务流程测试入口,整条流程共用一次测试事务 +type Flow struct { + app *TestApp + t *testing.T + name string + group string + desc string + prep string + session Map + stepIdx int + lastResp *ApiResponse +} + +// WithSession 返回携带 session 的 Flow +func (f *Flow) WithSession(s Map) *Flow { + return &Flow{ + app: f.app, + t: f.t, + name: f.name, + group: f.group, + desc: f.desc, + prep: f.prep, + session: s, + stepIdx: f.stepIdx, + } +} + +// Step 开始流程中的一步,返回指向指定路径的 Api(记录会归入 flows) +func (f *Flow) Step(stepName, path string) *Api { + f.stepIdx++ + return &Api{ + app: f.app, + path: path, + t: f.t, + session: f.session, + flowName: f.name, + flowGroup: f.group, + flowDesc: f.desc, + flowPrep: f.prep, + flowStep: stepName, + flowStepIndex: f.stepIdx, + flowParent: f, + } +} + +// AtPath 在当前流程上下文中切换路径(不增加步骤序号) +func (f *Flow) AtPath(path string) *Api { + return &Api{ + app: f.app, + path: path, + t: f.t, + session: f.session, + flowName: f.name, + flowGroup: f.group, + flowDesc: f.desc, + flowPrep: f.prep, + flowStep: "", + flowStepIndex: f.stepIdx, + flowParent: f, + } +} + +// DB 获取测试库(流程级事务内) +func (f *Flow) DB() *HoTimeDB { + return f.app.DB() +} + +// Resp 最近一次步骤响应 +func (f *Flow) Resp() *ApiResponse { + return f.lastResp +} + +// SetLastResp 由框架在步骤执行后更新 +func (f *Flow) SetLastResp(r *ApiResponse) { + f.lastResp = r +} diff --git a/testing_helper.go b/testing_helper.go index 9025dc8..6ad47ef 100644 --- a/testing_helper.go +++ b/testing_helper.go @@ -7,8 +7,12 @@ import ( "net/http" "net/http/httptest" "os" + "os/signal" + "sort" "strings" "sync" + "sync/atomic" + "syscall" "testing" "time" @@ -18,8 +22,171 @@ import ( // TestApp 测试应用,封装 Application 提供测试能力 type TestApp struct { *Application - projs TestProj - collector *TestCollector + configPath string + listeners []func(*Context) bool + workDir string + projs TestProj + flows FlowTest + collector *TestCollector + + swaggerEnabled bool + swaggerTitle string + swaggerVersion string + swaggerDir string + + stopNewTests atomic.Bool + swaggerMu sync.Mutex + inited bool + initOnce sync.Once +} + +// Test 创建测试应用(链式入口,延迟 Init 以便先 WorkDir) +func Test(configPath string, listeners ...func(*Context) bool) *TestApp { + return &TestApp{ + configPath: configPath, + listeners: listeners, + collector: &TestCollector{}, + } +} + +// WorkDir 设置工作目录(在 Init 前 chdir) +func (that *TestApp) WorkDir(dir string) *TestApp { + that.workDir = dir + return that +} + +// Swagger 启用接口级增量 Swagger 写入。 +// 参数可选:Swagger() / Swagger(title) / Swagger(title, version) / Swagger(title, version, outputDir) +func (that *TestApp) Swagger(args ...string) *TestApp { + that.swaggerEnabled = true + that.swaggerTitle = "API" + that.swaggerVersion = "1.0.0" + if len(args) > 0 && args[0] != "" { + that.swaggerTitle = args[0] + } + if len(args) > 1 && args[1] != "" { + that.swaggerVersion = args[1] + } + if len(args) > 2 && args[2] != "" { + that.swaggerDir = args[2] + } + return that +} + +// Proj 注册路由与单接口测试,并完成 Init +func (that *TestApp) Proj(projects TestProj) *TestApp { + that.projs = projects + that.ensureInit() + return that +} + +// Flows 注册多接口业务流程测试(可选) +func (that *TestApp) Flows(flows FlowTest) *TestApp { + that.flows = flows + return that +} + +// Run 执行测试:注册优雅停测信号 → m.Run → 覆盖率 → Swagger 收尾,返回 exit code +func (that *TestApp) Run(m *testing.M) int { + that.ensureInit() + that.installStopSignals() + + code := m.Run() + + if that.Application != nil { + _ = that.Db.RollbackTestTx() + } + + that.PrintCoverage() + if that.swaggerEnabled { + dir := that.swaggerOutputDir() + if err := that.GenerateSwagger(that.swaggerTitle, that.swaggerVersion, dir); err != nil { + fmt.Printf("Swagger 收尾失败: %v\n", err) + } + } + return code +} + +func (that *TestApp) swaggerOutputDir() string { + if that.swaggerDir != "" { + return that.swaggerDir + } + if that.Application != nil && that.Config != nil { + if d := that.Config.GetString("tpt"); d != "" { + return d + } + } + return "tpt" +} + +func (that *TestApp) installStopSignals() { + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + <-sigCh + fmt.Println("\n收到停测信号,将在当前接口/流程结束后停止并回滚测试事务…") + that.RequestStop() + }() +} + +// RequestStop 模拟优雅停测(等同收到 SIGINT):不再开启新的接口/流程,当前跑完后回滚并收尾 +func (that *TestApp) RequestStop() { + that.stopNewTests.Store(true) +} + +func (that *TestApp) shouldStop() bool { + return that.stopNewTests.Load() +} + +func (that *TestApp) ensureInit() { + that.initOnce.Do(func() { + if that.workDir != "" { + if err := os.Chdir(that.workDir); err != nil { + fmt.Printf("WorkDir(%s) 失败: %v\n", that.workDir, err) + } + } + if that.configPath == "" { + return + } + + origStdout := os.Stdout + origStderr := os.Stderr + devNull, _ := os.Open(os.DevNull) + os.Stdout = devNull + os.Stderr = devNull + + app := Init(that.configPath) + + os.Stdout = origStdout + os.Stderr = origStderr + devNull.Close() + + app.WebConnectLog = nil + app.Log.SetOutput(io.Discard) + + for _, lis := range that.listeners { + app.SetConnectListener(lis) + } + + that.Application = app + if that.collector == nil { + that.collector = &TestCollector{} + } + + if that.projs != nil { + router := Router{} + for projName, projDef := range that.projs { + router[projName] = projDef.Proj + } + app.SetupForTest(router) + } + + if app.HoTimeCache != nil { + app.HoTimeCache.DisableDbCache() + } + app.Log.SetOutput(os.Stderr) + that.inited = true + }) } // TestResponse httptest 的原始响应封装 @@ -59,29 +226,37 @@ type FailedCase struct { // TestRecord 单条测试记录 type TestRecord struct { - Path string - Desc string - CaseName string - Passed bool - Duration time.Duration - Method string - Query map[string]interface{} - JsonBody interface{} - FormBody map[string]interface{} - HasFile bool - FileField string - ResponseBody map[string]interface{} - Note string - ExpectResult interface{} - VerifyError string - FailReason string + Path string + Desc string + CaseName string + Passed bool + Duration time.Duration + Method string + Query map[string]interface{} + JsonBody interface{} + FormBody map[string]interface{} + HasFile bool + FileField string + ResponseBody map[string]interface{} + Note string + ExpectResult interface{} + VerifyError string + FailReason string + FlowName string + FlowGroup string + FlowDesc string + FlowPrep string + FlowStep string + FlowStepIndex int + BindCase string // FromCase 绑定的单接口验收用例名 } // TestCollector 线程安全的测试记录收集器 type TestCollector struct { - mu sync.Mutex - Records []TestRecord - Visited map[string]bool // 被 RunTests 实际调用过的路径(不论是否产生记录) + mu sync.Mutex + Records []TestRecord + Visited map[string]bool + VisitedFlows map[string]bool } func (c *TestCollector) Add(r TestRecord) { @@ -90,7 +265,7 @@ func (c *TestCollector) Add(r TestRecord) { c.Records = append(c.Records, r) } -// MarkVisited 标记路径已被测试框架调用(由 RunTests 在进入方法级 t.Run 时调用) +// MarkVisited 标记路径已被测试框架调用 func (c *TestCollector) MarkVisited(path string) { c.mu.Lock() defer c.mu.Unlock() @@ -100,47 +275,19 @@ func (c *TestCollector) MarkVisited(path string) { c.Visited[path] = true } -// NewTestApp 创建测试应用实例 -// configPath: 配置文件路径(如 "../config/config.json") -// projects: 项目定义(路由 + 测试) -// listeners: 可选的请求拦截器(与 SetConnectListener 相同) +// MarkFlowVisited 标记业务流程已运行 +func (c *TestCollector) MarkFlowVisited(name string) { + c.mu.Lock() + defer c.mu.Unlock() + if c.VisitedFlows == nil { + c.VisitedFlows = map[string]bool{} + } + c.VisitedFlows[name] = true +} + +// NewTestApp 立即 Init 的便捷构造(内部委托 Test().Proj()) func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp { - origStdout := os.Stdout - origStderr := os.Stderr - devNull, _ := os.Open(os.DevNull) - os.Stdout = devNull - os.Stderr = devNull - - app := Init(configPath) - - os.Stdout = origStdout - os.Stderr = origStderr - devNull.Close() - - app.WebConnectLog = nil - app.Log.SetOutput(io.Discard) - - for _, lis := range listeners { - app.SetConnectListener(lis) - } - - router := Router{} - for projName, projDef := range projects { - router[projName] = projDef.Proj - } - app.SetupForTest(router) - - if app.HoTimeCache != nil { - app.HoTimeCache.DisableDbCache() - } - - app.Log.SetOutput(os.Stderr) - - return &TestApp{ - Application: app, - projs: projects, - collector: &TestCollector{}, - } + return Test(configPath, listeners...).Proj(projects) } // SetupForTest 初始化路由(复用 Run 的路由注册逻辑,但不启动 HTTP 服务) @@ -199,34 +346,48 @@ func (that *TestApp) TestRequest(method, path string, body *http.Request) TestRe } } -// RunTests 运行所有注册的测试,每个方法级别使用事务隔离 +// RunTests 运行所有注册的测试;方法级事务隔离;另运行 Flows func (that *TestApp) RunTests(t *testing.T) { - for projName, projDef := range that.projs { - projName := projName - projDef := projDef + that.ensureInit() + + projNames := sortedKeys(that.projs) + for _, projName := range projNames { + projDef := that.projs[projName] + if that.shouldStop() { + t.Skip("收到停测信号,跳过剩余测试") + return + } t.Run(projName, func(t *testing.T) { - for ctrName, ctrTest := range projDef.Tests { - ctrName := ctrName - ctrTest := ctrTest + ctrNames := sortedKeys(projDef.Tests) + for _, ctrName := range ctrNames { + ctrTest := projDef.Tests[ctrName] + if that.shouldStop() { + t.Skip("收到停测信号,跳过剩余测试") + return + } t.Run(ctrName, func(t *testing.T) { - for methodName, apiTest := range ctrTest { - methodName := methodName - apiTest := apiTest - t.Run(methodName, func(t *testing.T) { - path := "/" + projName + "/" + ctrName + "/" + methodName - that.collector.MarkVisited(path) - - err := that.Db.BeginTestTx() - if err != nil { - t.Fatal("开启测试事务失败:", err) + methodNames := sortedKeys(ctrTest) + for _, methodName := range methodNames { + apiTest := ctrTest[methodName] + if that.shouldStop() { + t.Skip("收到停测信号,跳过剩余接口") + return } - defer that.Db.RollbackTestTx() + t.Run(methodName, func(t *testing.T) { + path := "/" + projName + "/" + ctrName + "/" + methodName + that.collector.MarkVisited(path) - methodBuf := &bytes.Buffer{} - that.Db.SetTestLogBuffer(methodBuf) - defer that.Db.SetTestLogBuffer(nil) + err := that.Db.BeginTestTx() + if err != nil { + t.Fatal("开启测试事务失败:", err) + } + defer that.Db.RollbackTestTx() - api := &Api{ + methodBuf := &bytes.Buffer{} + that.Db.SetTestLogBuffer(methodBuf) + defer that.Db.SetTestLogBuffer(nil) + + api := &Api{ app: that, path: path, t: t, @@ -235,12 +396,73 @@ func (that *TestApp) RunTests(t *testing.T) { api.t = t apiTest.Func(api) }) + + if that.swaggerEnabled { + that.flushEndpointSwagger(projName, path) + } }) } }) } }) } + + if len(that.flows) == 0 { + return + } + t.Run("flows", func(t *testing.T) { + flowNames := sortedKeys(that.flows) + for _, flowName := range flowNames { + flowDef := that.flows[flowName] + if that.shouldStop() { + t.Skip("收到停测信号,跳过剩余流程") + return + } + t.Run(flowName, func(t *testing.T) { + that.collector.MarkFlowVisited(flowName) + + err := that.Db.BeginTestTx() + if err != nil { + t.Fatal("开启流程测试事务失败:", err) + } + defer that.Db.RollbackTestTx() + + methodBuf := &bytes.Buffer{} + that.Db.SetTestLogBuffer(methodBuf) + defer that.Db.SetTestLogBuffer(nil) + + flow := &Flow{ + app: that, + t: t, + name: flowName, + group: flowDef.Group, + desc: flowDef.Desc, + prep: flowDef.Prep, + } + desc := flowDef.Desc + if desc == "" { + desc = flowName + } + t.Run(desc, func(t *testing.T) { + flow.t = t + flowDef.Func(flow) + }) + + if that.swaggerEnabled { + that.flushFlowSwagger(flowName) + } + }) + } + }) +} + +func sortedKeys[M ~map[string]V, V any](m M) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys } // PrintCoverage 输出 API 测试覆盖率报告 @@ -250,6 +472,9 @@ func (that *TestApp) PrintCoverage() CoverageReport { pathRecords := map[string][]TestRecord{} that.collector.mu.Lock() for _, r := range that.collector.Records { + if r.FlowName != "" { + continue + } pathRecords[r.Path] = append(pathRecords[r.Path], r) } visited := that.collector.Visited @@ -296,7 +521,6 @@ func (that *TestApp) PrintCoverage() CoverageReport { } } - // 汇总全部用例统计 for _, d := range report.Details { report.TotalCases += d.CaseCount report.PassedCases += d.Passed @@ -306,7 +530,6 @@ func (that *TestApp) PrintCoverage() CoverageReport { ranCount := len(visited) isPartialRun := ranCount > 0 && ranCount < report.Covered - // 分拣本次运行中通过/失败的接口 var passedDetails, failedDetails []MethodCoverage for _, d := range report.Details { if !d.HasTest || d.CaseCount == 0 { @@ -322,7 +545,6 @@ func (that *TestApp) PrintCoverage() CoverageReport { } } - // 计算本次运行的用例统计(部分运行时只统计实际运行的接口) ranInterfaces := len(failedDetails) + len(passedDetails) var ranTotalCases, ranPassedCases, ranFailedCases int if isPartialRun { diff --git a/testing_swagger.go b/testing_swagger.go index 6f7b719..d72fe68 100644 --- a/testing_swagger.go +++ b/testing_swagger.go @@ -234,9 +234,28 @@ func inferParamsFromCases(cases []swaggerTestCase) []paramSpec { } func (that *TestApp) GenerateSwagger(title, version, outputDir string) error { - if outputDir == "" { - outputDir = "tpt" + if title == "" { + title = that.swaggerTitle } + if version == "" { + version = that.swaggerVersion + } + if outputDir == "" { + outputDir = that.swaggerOutputDir() + } + if title == "" { + title = "API" + } + if version == "" { + version = "1.0.0" + } + that.swaggerEnabled = true + that.swaggerTitle = title + that.swaggerVersion = version + if that.swaggerDir == "" { + that.swaggerDir = outputDir + } + swaggerRoot := filepath.Join(outputDir, "swagger") if err := os.MkdirAll(swaggerRoot, os.ModePerm); err != nil { return fmt.Errorf("创建 swagger 目录失败: %w", err) @@ -249,108 +268,81 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error { for k, v := range that.collector.Visited { visited[k] = v } + visitedFlows := map[string]bool{} + for k, v := range that.collector.VisitedFlows { + visitedFlows[k] = v + } that.collector.mu.Unlock() pathRecords := map[string][]TestRecord{} + flowRecords := map[string][]TestRecord{} for _, r := range records { + if r.FlowName != "" { + flowRecords[r.FlowName] = append(flowRecords[r.FlowName], r) + continue + } pathRecords[r.Path] = append(pathRecords[r.Path], r) } + fullComplete := that.isSwaggerFullComplete(visited, visitedFlows) + + activeModules := map[string]bool{} for projName, projDef := range that.projs { + activeModules[projName] = true moduleDir := filepath.Join(swaggerRoot, projName) if err := os.MkdirAll(moduleDir, os.ModePerm); err != nil { return fmt.Errorf("创建模块目录 %s 失败: %w", projName, err) } - // 判断是否为部分运行(-run 过滤):统计本项目下所有端点是否全部被访问 - totalPaths := 0 - visitedPaths := 0 - for ctrName, ctr := range projDef.Proj { - for methodName := range ctr { - totalPaths++ - if visited["/"+projName+"/"+ctrName+"/"+methodName] { - visitedPaths++ - } - } - } - isPartialRun := visitedPaths > 0 && visitedPaths < totalPaths - - // 仅部分运行时才加载已有 spec 用于合并,全量运行时清空重建 - var existingEndpoints map[string]map[string]interface{} - if isPartialRun { - existingEndpoints = loadExistingEndpoints(filepath.Join(moduleDir, "api-spec.json")) - } + specPath := filepath.Join(moduleDir, "api-spec.json") + existingEndpoints := loadExistingEndpoints(specPath) + existingFlows := loadExistingFlows(specPath) endpoints := []map[string]interface{}{} for ctrName, ctr := range projDef.Proj { for methodName := range ctr { apiPath := "/" + projName + "/" + ctrName + "/" + methodName + recs := pathRecords[apiPath] + existingEp := existingEndpoints[apiPath] + + // 无本轮用例记录:一律保留旧 swagger,禁止空 cases 覆盖已测内容 + if len(recs) == 0 && existingEp != nil { + endpoints = append(endpoints, existingEp) + continue + } + ctrTest, hasCtr := projDef.Tests[ctrName] hasTest := false var apiTest ApiTestDef if hasCtr { apiTest, hasTest = ctrTest[methodName] } - summary := methodName if hasTest { summary = apiTest.Desc } + endpoints = append(endpoints, that.buildEndpointMap(projName, ctrName, apiPath, summary, hasTest, recs, existingEp)) + } + } - recs := pathRecords[apiPath] - - // 部分运行且该路径未被访问 → 保留已有 spec 中的数据 - if isPartialRun && !visited[apiPath] { - if existing, ok := existingEndpoints[apiPath]; ok { - endpoints = append(endpoints, existing) - continue - } + // 未全量时:保留旧 spec 中已不在当前 Proj 的 endpoint + if !fullComplete { + projPaths := map[string]bool{} + for ctrName, ctr := range projDef.Proj { + for methodName := range ctr { + projPaths["/"+projName+"/"+ctrName+"/"+methodName] = true } - - httpMethod := "POST" - if len(recs) > 0 && recs[0].Method != "" { - httpMethod = strings.ToUpper(recs[0].Method) - } else if isPartialRun { - if existing, ok := existingEndpoints[apiPath]; ok { - if m, ok := existing["method"].(string); ok && m != "" { - httpMethod = m - } - } + } + have := map[string]bool{} + for _, ep := range endpoints { + if p, ok := ep["path"].(string); ok { + have[p] = true } - - var cases []swaggerTestCase - for _, r := range recs { - tc := swaggerTestCase{ - Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed, - Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError, - FailReason: r.FailReason, - } - if r.Query != nil { - tc.Query = r.Query - } - if r.JsonBody != nil { - tc.Json = r.JsonBody - } - if r.FormBody != nil { - tc.Form = r.FormBody - } - if r.HasFile { - tc.HasFile = true - tc.FileField = r.FileField - } - cases = append(cases, tc) + } + for path, ep := range existingEndpoints { + if !projPaths[path] && !have[path] { + endpoints = append(endpoints, ep) } - - endpoints = append(endpoints, map[string]interface{}{ - "path": apiPath, - "project": projName, - "ctr": ctrName, - "method": httpMethod, - "summary": summary, - "tested": hasTest, - "cases": cases, - "params": inferParamsFromCases(cases), - }) } } @@ -358,33 +350,526 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error { return endpoints[i]["path"].(string) < endpoints[j]["path"].(string) }) - spec := map[string]interface{}{ - "title": title, - "version": version, - "endpoints": endpoints, - } + flows := that.mergeFlows(existingFlows, flowRecords, visitedFlows, fullComplete) - specJSON, err := json.MarshalIndent(spec, "", " ") - if err != nil { - return fmt.Errorf("序列化 JSON 失败: %w", err) - } - if err := os.WriteFile(filepath.Join(moduleDir, "api-spec.json"), specJSON, os.ModePerm); err != nil { - return fmt.Errorf("写入 %s/api-spec.json 失败: %w", projName, err) - } - if err := os.WriteFile(filepath.Join(moduleDir, "index.html"), []byte(apiConsoleHTML()), os.ModePerm); err != nil { - return fmt.Errorf("写入 %s/index.html 失败: %w", projName, err) + if err := that.writeModuleSpec(moduleDir, title, version, endpoints, flows); err != nil { + return err } fmt.Printf("Swagger 文档已生成: %s\n", moduleDir) } + if fullComplete { + if err := that.pruneOrphanSwaggerModules(swaggerRoot, activeModules); err != nil { + return err + } + } + if err := generateSwaggerPortal(swaggerRoot); err != nil { return fmt.Errorf("生成导航页失败: %w", err) } return nil } +func (that *TestApp) isSwaggerFullComplete(visited, visitedFlows map[string]bool) bool { + totalPaths := 0 + visitedPaths := 0 + for projName, projDef := range that.projs { + for ctrName, ctr := range projDef.Proj { + for methodName := range ctr { + totalPaths++ + if visited["/"+projName+"/"+ctrName+"/"+methodName] { + visitedPaths++ + } + } + } + } + if totalPaths == 0 || visitedPaths < totalPaths { + return false + } + for name := range that.flows { + if !visitedFlows[name] { + return false + } + } + return true +} + +func (that *TestApp) buildEndpointMap(projName, ctrName, apiPath, summary string, hasTest bool, recs []TestRecord, existing map[string]interface{}) map[string]interface{} { + httpMethod := "POST" + if len(recs) > 0 && recs[0].Method != "" { + httpMethod = strings.ToUpper(recs[0].Method) + } else if existing != nil { + if m, ok := existing["method"].(string); ok && m != "" { + httpMethod = m + } + } + + cases := make([]swaggerTestCase, 0, len(recs)) + for _, r := range recs { + cases = append(cases, recordToSwaggerCase(r)) + } + + return map[string]interface{}{ + "path": apiPath, + "project": projName, + "ctr": ctrName, + "method": httpMethod, + "summary": summary, + "tested": hasTest, + "cases": cases, + "params": inferParamsFromCases(cases), + } +} + +func recordToSwaggerCase(r TestRecord) swaggerTestCase { + tc := swaggerTestCase{ + Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed, + Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError, + FailReason: r.FailReason, + } + if r.Query != nil { + tc.Query = r.Query + } + if r.JsonBody != nil { + tc.Json = r.JsonBody + } + if r.FormBody != nil { + tc.Form = r.FormBody + } + if r.HasFile { + tc.HasFile = true + tc.FileField = r.FileField + } + return tc +} + +func (that *TestApp) mergeFlows(existing map[string]map[string]interface{}, flowRecords map[string][]TestRecord, visitedFlows map[string]bool, fullComplete bool) []map[string]interface{} { + out := []map[string]interface{}{} + seen := map[string]bool{} + + for name, recs := range flowRecords { + out = append(out, that.buildFlowMap(name, recs)) + seen[name] = true + } + for name, flowDef := range that.flows { + if seen[name] { + continue + } + if visitedFlows[name] { + continue + } + if existingFlow, ok := existing[name]; ok { + out = append(out, existingFlow) + seen[name] = true + continue + } + _ = flowDef + } + if !fullComplete { + for name, ep := range existing { + if !seen[name] { + // 未全量:保留旧 flow(含已从注册删除的) + out = append(out, ep) + seen[name] = true + } + } + } else { + // 全量:只保留仍注册或本轮跑过的 + registered := map[string]bool{} + for name := range that.flows { + registered[name] = true + } + filtered := out[:0] + for _, f := range out { + name, _ := f["name"].(string) + if registered[name] || visitedFlows[name] { + filtered = append(filtered, f) + } + } + out = filtered + } + + sort.Slice(out, func(i, j int) bool { + return out[i]["name"].(string) < out[j]["name"].(string) + }) + return out +} + +func (that *TestApp) buildFlowMap(name string, recs []TestRecord) map[string]interface{} { + desc := name + group := "" + prep := "" + passed := true + steps := []map[string]interface{}{} + sort.Slice(recs, func(i, j int) bool { + if recs[i].FlowStepIndex != recs[j].FlowStepIndex { + return recs[i].FlowStepIndex < recs[j].FlowStepIndex + } + return recs[i].CaseName < recs[j].CaseName + }) + for _, r := range recs { + if r.FlowDesc != "" { + desc = r.FlowDesc + } + if r.FlowGroup != "" { + group = r.FlowGroup + } + if r.FlowPrep != "" { + prep = r.FlowPrep + } + if !r.Passed { + passed = false + } + stepName := r.FlowStep + if stepName == "" { + stepName = r.CaseName + } + tc := recordToSwaggerCase(r) + step := map[string]interface{}{ + "index": r.FlowStepIndex, + "name": stepName, + "path": r.Path, + "method": tc.Method, + "passed": tc.Passed, + "note": tc.Note, + "query": tc.Query, + "json": tc.Json, + "form": tc.Form, + "response": tc.Response, + "expect": tc.Expect, + "failReason": tc.FailReason, + "verifyError": tc.VerifyError, + "caseName": tc.Name, + } + if r.BindCase != "" { + step["bindCase"] = r.BindCase + } + steps = append(steps, step) + } + if group == "" { + if def, ok := that.flows[name]; ok && def.Group != "" { + group = def.Group + } + } + if prep == "" { + if def, ok := that.flows[name]; ok && def.Prep != "" { + prep = def.Prep + } + } + if desc == name { + if def, ok := that.flows[name]; ok && def.Desc != "" { + desc = def.Desc + } + } + m := map[string]interface{}{ + "name": name, + "desc": desc, + "passed": passed, + "steps": steps, + } + if group != "" { + m["group"] = group + } + if prep != "" { + m["prep"] = prep + } + return m +} + +// caseTemplate 单接口验收用例的请求模板(供 FromCase 加载) +type caseTemplate struct { + Query map[string]interface{} + Json interface{} + Form map[string]interface{} + Note string +} + +func (that *TestApp) findCaseTemplate(apiPath, caseName string) *caseTemplate { + that.collector.mu.Lock() + for _, r := range that.collector.Records { + if r.FlowName == "" && r.Path == apiPath && r.CaseName == caseName { + tmpl := &caseTemplate{Note: r.Note} + if r.Query != nil { + tmpl.Query = r.Query + } + if r.JsonBody != nil { + tmpl.Json = r.JsonBody + } + if r.FormBody != nil { + tmpl.Form = r.FormBody + } + that.collector.mu.Unlock() + return tmpl + } + } + that.collector.mu.Unlock() + + dir := that.swaggerOutputDir() + parts := strings.Split(strings.Trim(apiPath, "/"), "/") + if len(parts) == 0 { + return nil + } + specPath := filepath.Join(dir, "swagger", parts[0], "api-spec.json") + existing := loadExistingEndpoints(specPath) + ep, ok := existing[apiPath] + if !ok { + return nil + } + cases, ok := ep["cases"].([]interface{}) + if !ok { + // json 反序列化可能是 []swaggerTestCase 经 map 再读 + raw, _ := json.Marshal(ep["cases"]) + var parsed []swaggerTestCase + if json.Unmarshal(raw, &parsed) != nil { + return nil + } + for _, c := range parsed { + if c.Name == caseName { + return &caseTemplate{Query: c.Query, Json: c.Json, Form: c.Form, Note: c.Note} + } + } + return nil + } + for _, item := range cases { + cm, ok := item.(map[string]interface{}) + if !ok { + continue + } + name, _ := cm["name"].(string) + if name != caseName { + continue + } + tmpl := &caseTemplate{} + if n, ok := cm["note"].(string); ok { + tmpl.Note = n + } + if q, ok := cm["query"].(map[string]interface{}); ok { + tmpl.Query = q + } + if j, ok := cm["json"]; ok { + tmpl.Json = j + } + if f, ok := cm["form"].(map[string]interface{}); ok { + tmpl.Form = f + } + return tmpl + } + return nil +} + +// syncBoundCaseToEndpoint 流程步骤跑通后,把响应回写到绑定的单接口验收用例 +func (that *TestApp) syncBoundCaseToEndpoint(record TestRecord) { + if record.BindCase == "" || record.Path == "" { + return + } + updated := false + that.collector.mu.Lock() + for i := range that.collector.Records { + r := &that.collector.Records[i] + if r.FlowName != "" || r.Path != record.Path || r.CaseName != record.BindCase { + continue + } + r.ResponseBody = record.ResponseBody + r.Passed = record.Passed + r.FailReason = record.FailReason + r.VerifyError = record.VerifyError + r.ExpectResult = record.ExpectResult + if record.Query != nil { + r.Query = record.Query + } + if record.JsonBody != nil { + r.JsonBody = record.JsonBody + } + if record.FormBody != nil { + r.FormBody = record.FormBody + } + updated = true + break + } + that.collector.mu.Unlock() + if !updated || !that.swaggerEnabled { + return + } + parts := strings.Split(strings.Trim(record.Path, "/"), "/") + if len(parts) == 0 { + return + } + that.flushEndpointSwagger(parts[0], record.Path) +} + +func (that *TestApp) writeModuleSpec(moduleDir, title, version string, endpoints, flows []map[string]interface{}) error { + that.swaggerMu.Lock() + defer that.swaggerMu.Unlock() + + if endpoints == nil { + endpoints = []map[string]interface{}{} + } + if flows == nil { + flows = []map[string]interface{}{} + } + spec := map[string]interface{}{ + "title": title, + "version": version, + "endpoints": endpoints, + "flows": flows, + } + specJSON, err := json.MarshalIndent(spec, "", " ") + if err != nil { + return fmt.Errorf("序列化 JSON 失败: %w", err) + } + if err := os.WriteFile(filepath.Join(moduleDir, "api-spec.json"), specJSON, os.ModePerm); err != nil { + return fmt.Errorf("写入 api-spec.json 失败: %w", err) + } + if err := os.WriteFile(filepath.Join(moduleDir, "index.html"), []byte(apiConsoleHTML()), os.ModePerm); err != nil { + return fmt.Errorf("写入 index.html 失败: %w", err) + } + return nil +} + +func (that *TestApp) pruneOrphanSwaggerModules(swaggerRoot string, active map[string]bool) error { + entries, err := os.ReadDir(swaggerRoot) + if err != nil { + return err + } + for _, e := range entries { + if !e.IsDir() { + continue + } + if active[e.Name()] { + continue + } + _ = os.RemoveAll(filepath.Join(swaggerRoot, e.Name())) + } + return nil +} + +// flushEndpointSwagger 单个接口跑完后按 path 替换写入;无用例记录则不写盘 +func (that *TestApp) flushEndpointSwagger(projName, apiPath string) { + that.ensureInit() + + that.collector.mu.Lock() + var recs []TestRecord + for _, r := range that.collector.Records { + if r.FlowName == "" && r.Path == apiPath { + recs = append(recs, r) + } + } + that.collector.mu.Unlock() + if len(recs) == 0 { + return + } + + dir := that.swaggerOutputDir() + moduleDir := filepath.Join(dir, "swagger", projName) + _ = os.MkdirAll(moduleDir, os.ModePerm) + + parts := strings.Split(strings.Trim(apiPath, "/"), "/") + ctrName := "" + if len(parts) >= 2 { + ctrName = parts[1] + } + summary := parts[len(parts)-1] + hasTest := false + if projDef, ok := that.projs[projName]; ok { + if ctrTest, ok := projDef.Tests[ctrName]; ok { + methodName := parts[len(parts)-1] + if apiTest, ok := ctrTest[methodName]; ok { + hasTest = true + summary = apiTest.Desc + } + } + } + + specPath := filepath.Join(moduleDir, "api-spec.json") + existing := loadExistingEndpoints(specPath) + existingFlows := loadExistingFlows(specPath) + ep := that.buildEndpointMap(projName, ctrName, apiPath, summary, hasTest, recs, existing[apiPath]) + existing[apiPath] = ep + + endpoints := make([]map[string]interface{}, 0, len(existing)) + for _, v := range existing { + endpoints = append(endpoints, v) + } + sort.Slice(endpoints, func(i, j int) bool { + return endpoints[i]["path"].(string) < endpoints[j]["path"].(string) + }) + + flows := make([]map[string]interface{}, 0, len(existingFlows)) + for _, f := range existingFlows { + flows = append(flows, f) + } + sort.Slice(flows, func(i, j int) bool { + return flows[i]["name"].(string) < flows[j]["name"].(string) + }) + + title := that.swaggerTitle + version := that.swaggerVersion + if title == "" { + title = "API" + } + if version == "" { + version = "1.0.0" + } + _ = that.writeModuleSpec(moduleDir, title, version, endpoints, flows) + _ = generateSwaggerPortal(filepath.Join(dir, "swagger")) +} + +// flushFlowSwagger 单个流程跑完后替换写入;无步骤记录则不写盘 +func (that *TestApp) flushFlowSwagger(flowName string) { + that.ensureInit() + + that.collector.mu.Lock() + var recs []TestRecord + for _, r := range that.collector.Records { + if r.FlowName == flowName { + recs = append(recs, r) + } + } + that.collector.mu.Unlock() + if len(recs) == 0 { + return + } + + dir := that.swaggerOutputDir() + + flowMap := that.buildFlowMap(flowName, recs) + title := that.swaggerTitle + version := that.swaggerVersion + if title == "" { + title = "API" + } + if version == "" { + version = "1.0.0" + } + + for projName := range that.projs { + moduleDir := filepath.Join(dir, "swagger", projName) + _ = os.MkdirAll(moduleDir, os.ModePerm) + specPath := filepath.Join(moduleDir, "api-spec.json") + existingEndpoints := loadExistingEndpoints(specPath) + existingFlows := loadExistingFlows(specPath) + existingFlows[flowName] = flowMap + + endpoints := make([]map[string]interface{}, 0, len(existingEndpoints)) + for _, v := range existingEndpoints { + endpoints = append(endpoints, v) + } + sort.Slice(endpoints, func(i, j int) bool { + return endpoints[i]["path"].(string) < endpoints[j]["path"].(string) + }) + flows := make([]map[string]interface{}, 0, len(existingFlows)) + for _, f := range existingFlows { + flows = append(flows, f) + } + sort.Slice(flows, func(i, j int) bool { + return flows[i]["name"].(string) < flows[j]["name"].(string) + }) + _ = that.writeModuleSpec(moduleDir, title, version, endpoints, flows) + } + _ = generateSwaggerPortal(filepath.Join(dir, "swagger")) +} + // loadExistingEndpoints 读取已有的 api-spec.json,返回 path → endpoint 映射。 -// 用于 -run 部分运行时保留未运行端点的已有数据,避免覆盖清空。 func loadExistingEndpoints(specPath string) map[string]map[string]interface{} { result := map[string]map[string]interface{}{} data, err := os.ReadFile(specPath) @@ -411,6 +896,32 @@ func loadExistingEndpoints(specPath string) map[string]map[string]interface{} { return result } +func loadExistingFlows(specPath string) map[string]map[string]interface{} { + result := map[string]map[string]interface{}{} + data, err := os.ReadFile(specPath) + if err != nil { + return result + } + var spec map[string]interface{} + if err := json.Unmarshal(data, &spec); err != nil { + return result + } + flows, ok := spec["flows"].([]interface{}) + if !ok { + return result + } + for _, raw := range flows { + f, ok := raw.(map[string]interface{}) + if !ok { + continue + } + if name, ok := f["name"].(string); ok { + result[name] = f + } + } + return result +} + func generateSwaggerPortal(swaggerRoot string) error { entries, err := os.ReadDir(swaggerRoot) if err != nil { @@ -516,11 +1027,24 @@ input,select,textarea,button{font:inherit}pre{margin:0} .cs{border:1px solid var(--border);border-radius:var(--r);margin-bottom:6px;overflow:hidden} .csh{padding:6px 10px;display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;background:var(--bg2)} .csh:hover{background:var(--bg3)}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0} -.dot.ok{background:var(--green)}.dot.er{background:var(--red)}.csh .ml{margin-left:auto;color:#555;font-size:11px} +.dot.ok{background:var(--green)}.dot.er{background:var(--red)}.csh .ml{margin-left:auto;color:#555;font-size:11px;display:flex;align-items:center;gap:6px;flex-shrink:0;max-width:55%} +.path-link{color:var(--accent);cursor:pointer;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px;display:inline-block;vertical-align:bottom} +.path-link:hover{text-decoration:underline} +.bind-tag{font-size:10px;color:#888;padding:1px 6px;border:1px solid var(--border);border-radius:10px;white-space:nowrap;max-width:160px;overflow:hidden;text-overflow:ellipsis} +.flow-prep{margin-top:8px;padding:8px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);color:var(--fg2);font-size:12px;line-height:1.6;white-space:pre-wrap} +.flow-prep .lbl{display:inline-block;color:var(--accent);font-size:10px;margin-bottom:4px;letter-spacing:.5px} +.flow-note{color:#aaa;font-size:12px;line-height:1.5;margin-bottom:8px;white-space:pre-wrap} +.flow-note.empty{color:#555} .csb{display:none;border-top:1px solid var(--border);font-size:12px}.csb.o{display:block} .csr{display:flex}.csr>.csc{flex:1;padding:8px 10px;min-width:0}.csr>.csc+.csc{border-left:1px solid var(--border)} .csc h5{color:var(--accent);margin:0 0 4px;font-size:11px;letter-spacing:.5px;display:flex;align-items:center} .csc h5 .cp-sm{margin-left:auto}.csc h5:not(:first-child){margin-top:8px} +.modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px} +.modal-box{background:var(--bg);border:1px solid var(--border);border-radius:8px;width:min(920px,96vw);max-height:88vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(0,0,0,.45)} +.modal-hd{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0} +.modal-hd .x{margin-left:auto;background:var(--bg3);border:1px solid var(--border);color:var(--fg2);border-radius:var(--r);cursor:pointer;padding:2px 10px;font-size:14px} +.modal-hd .x:hover{color:var(--accent);border-color:var(--accent)} +.modal-bd{overflow:auto;padding:12px;flex:1} pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;line-height:1.4;color:#aaa;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow:auto;font-family:Consolas,Monaco,monospace} .resp-wrap{display:flex;gap:0}.resp-main{flex:1;min-width:0} .resp-main .rsp-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px} @@ -605,7 +1129,7 @@ function epPass(e){if(!e.cases?.length)return false;return e.cases.every(x=>x.pa function epFail(e){if(!e.cases?.length)return e.tested;return e.cases.some(x=>!x.passed);} function render(){ if(!D)return;const q=document.getElementById('q').value.toLowerCase();const T={}; - for(const e of D.endpoints){ + for(const e of D.endpoints||[]){ if(F==='yes'&&!e.tested)continue; if(F==='no'&&e.tested)continue; if(F==='pass'&&!epPass(e))continue; @@ -614,13 +1138,35 @@ function render(){ if(!T[e.project])T[e.project]={};if(!T[e.project][e.ctr])T[e.project][e.ctr]=[];T[e.project][e.ctr].push(e); } let h=''; + const flows=(D.flows||[]).filter(f=>{ + if(q&&!f.name.toLowerCase().includes(q)&&!(f.desc||'').toLowerCase().includes(q))return false; + if(F==='pass'&&!f.passed)return false; + if(F==='fail'&&f.passed)return false; + if(F==='no')return false; + return true; + }); + if(flows.length){ + const byG={}; + for(const f of flows){const g=f.group||'default';if(!byG[g])byG[g]=[];byG[g].push(f);} + let fh=''; + for(const g of Object.keys(byG).sort()){ + let gh=''; + for(const f of byG[g]){ + const sc=f.steps?.length||0; + const tg=f.passed?'通过':'失败'; + gh+='
    FLOW'+esc(f.name)+''+tg+''+sc+'
    '; + } + fh+='
    '+esc(g)+''+byG[g].length+'
    '+gh+'
    '; + } + h+='
    业务流程'+flows.length+'
    '+fh+'
    '; + } for(const p of Object.keys(T).sort()){let ph=''; for(const c of Object.keys(T[p]).sort()){const es=T[p][c];let eh=''; for(const e of es){const m=e.method.toLowerCase(); let tg='';if(!e.tested)tg='未测试'; else if(e.cases?.length){const pc=e.cases.filter(x=>x.passed).length;tg=pc===e.cases.length?''+pc+'/'+e.cases.length+'':''+pc+'/'+e.cases.length+'';} const mn=e.path.split('/').pop();const lbl=e.tested&&e.summary!==mn?mn+' '+e.summary:e.summary; - eh+='
    '+e.method+''+lbl+''+tg+'
    '; + eh+='
    '+e.method+''+lbl+''+tg+'
    '; } ph+='
    '+c+''+es.length+'
    '+eh+'
    '; } @@ -631,7 +1177,92 @@ function render(){ } function sf(f,b){F=f;document.querySelectorAll('.sft button').forEach(x=>x.classList.remove('on'));b.classList.add('on');render();} function tog(e){e.querySelector('.ar').classList.toggle('o');e.nextElementSibling.classList.toggle('o');} -function go(el){C=D.endpoints.find(e=>e.path===el.dataset.p);if(!C)return;document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');show();} +function go(el){C=D.endpoints.find(e=>e.path===el.dataset.p);if(!C)return;C.flowName=undefined;document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');show();} +function goFlow(el){const f=(D.flows||[]).find(x=>x.name===el.dataset.flow);if(!f)return;C={flowName:f.name,flow:f};document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');showFlow(f);} +function stepCurl(s){ + const base=window.location.origin; + let url=base+(s.path||''); + const m=s.method||'POST'; + if(s.query){const qs=Object.keys(s.query).filter(k=>s.query[k]!==undefined&&s.query[k]!=='').map(k=>encodeURIComponent(k)+'='+encodeURIComponent(s.query[k])).join('&');if(qs)url+='?'+qs;} + let cmd="curl -X "+m+" '"+url+"'"; + if(s.json){cmd+="\\\n -H 'Content-Type: application/json'";cmd+="\\\n -d '"+JSON.stringify(s.json).replace(/'/g,"'\\''")+"'";} + else if(s.form){const fd=Object.keys(s.form).map(k=>"-F '"+k+"="+s.form[k]+"'").join("\\\n ");if(fd)cmd+="\\\n "+fd;} + return cmd; +} +function hasReqData(s){return !!(s&&((s.query&&Object.keys(s.query).length)||s.json||(s.form&&Object.keys(s.form).length)));} +function closeEpModal(){const m=document.getElementById('flow-ep-modal');if(m)m.remove();} +function openEpModal(path,bindCase){ + const e=(D.endpoints||[]).find(x=>x.path===path); + closeEpModal(); + const mask=document.createElement('div');mask.id='flow-ep-modal';mask.className='modal-mask'; + mask.onclick=ev=>{if(ev.target===mask)closeEpModal();}; + let hd=''; + let bd=''; + mask.innerHTML=''; + document.body.appendChild(mask); +} +document.addEventListener('keydown',e=>{if(e.key==='Escape')closeEpModal();}); +function showFlow(f){ + let h='
    FLOW'+esc(f.name)+''; + if(f.desc)h+=''+esc(f.desc)+''; + h+=''+(f.passed?'通过':'失败')+''; + h+=''+(f.steps?.length||0)+'个步骤
    '; + if(f.prep)h+='
    业务说明
    '+esc(f.prep)+'
    '; + h+='
    '; + h+='
    '; + if(!f.steps?.length){h+='
    暂无步骤
    ';} + else{for(let i=0;i
    '+esc((s.index||i+1)+'. '+(s.name||s.caseName||''))+''; + h+=''+(s.method||'')+''; + if(s.path)h+=''+esc(s.path)+''; + h+='
    '; + h+='
    '; + h+='
    '; + if(s.bindCase)h+='
    关联验收用例
    '; + h+='
    备注
    '; + if(s.note)h+='
    '+esc(s.note)+'
    '; + else h+='
    无备注
    '; + if(!s.passed&&s.failReason)h+='
    失败原因'+esc(s.failReason)+'
    '; + else if(s.verifyError)h+='
    Verify'+esc(s.verifyError)+'
    '; + h+='
    请求
    '; + h+='
    '+esc(stepCurl(s))+'
    '; + if(hasReqData(s)){ + if(s.query)h+='
    Query
    '+fj(s.query)+'
    '; + if(s.json)h+='
    JSON
    '+fj(s.json)+'
    '; + if(s.form)h+='
    Form
    '+fj(s.form)+'
    '; + }else{h+='
    无请求参数
    ';} + h+='
    '; + h+='
    响应'+(s.passed?'通过':'失败')+'
    '; + if(s.response)h+='
    '+fj(s.response)+'
    '; + else h+='无响应'; + h+='
    '; + }} + h+='
    '; + document.getElementById('ct').innerHTML=h; + const failEl=document.querySelector('#ct .flow-step-fail'); + if(failEl)failEl.scrollIntoView({block:'nearest',behavior:'smooth'}); +} +function flowStepToggle(el){ + const bd=el.nextElementSibling;if(!bd)return; + bd.classList.toggle('o'); + el.querySelector('.ar')?.classList.toggle('o'); +} function show(){ const e=C;if(!e)return;const m=e.method.toLowerCase();const cnt=e.cases?.length||0; @@ -653,6 +1284,8 @@ function show(){ let defIdx=-1; if(cnt>0){for(let i=e.cases.length-1;i>=0;i--){if(e.cases[i].passed){defIdx=i;break;}}if(defIdx<0)defIdx=e.cases.length-1;} pfSelect(defIdx>=0?defIdx:''); + bindLiveCurl(); + refreshLiveCurl(); } function rswitch(i){ document.querySelectorAll('.rtab').forEach((t,j)=>t.classList.toggle('on',j===i)); @@ -694,8 +1327,8 @@ function cases(e){ return h; } function accordion(el){ - const bd=el.nextElementSibling;const wasOpen=bd.classList.contains('o'); - const panel=el.closest('.rpn'); + const bd=el.nextElementSibling;if(!bd)return;const wasOpen=bd.classList.contains('o'); + const panel=el.closest('.modal-bd')||el.closest('.rpn')||el.closest('#ct')||document; panel.querySelectorAll('.csb').forEach(b=>b.classList.remove('o')); panel.querySelectorAll('.csh .ar').forEach(a=>a.classList.remove('o')); if(!wasOpen){bd.classList.add('o');el.querySelector('.ar').classList.add('o');} @@ -705,7 +1338,7 @@ function respPanel(){ let r=''; r+=''; r+='
    cURL
    '; - r+='
    发送请求后生成
    '; + r+='
    根据左侧参数实时生成
    '; r+='
    响应
    '; r+='
    发送请求后显示
    '; return r; @@ -722,15 +1355,15 @@ function buildLeft(e){ l+='
      '; l+=''; l+='
      '; - let defH='
      '; - if(tk&&at==='header')defH+='
      '; - if(tk&&at==='cookie')defH+='
      '; + let defH='
      '; + if(tk&&at==='header')defH+='
      '; + if(tk&&at==='cookie')defH+='
      '; l+='
      请求头
      '+defH+'
      '; let qDef=''; - if(tk&&at==='query')qDef='
      '; - qDef+='
      '; + if(tk&&at==='query')qDef='
      '; + qDef+='
      '; l+='
      Query 参数
      '+qDef+'
      '; - l+='
      文件上传
      '; + l+='
      文件上传
      '; let jsonPH=''; if(e.params){ const jp=e.params.filter(p=>p.in==='json'); @@ -738,7 +1371,7 @@ function buildLeft(e){ } l+='
      请求体
      '; l+='
      表单 (Form)
      '; - l+='
      '; + l+='
      '; l+='
      JSON
      '; l+='
      '; if(e.params){ @@ -764,18 +1397,18 @@ function buildLeft(e){ function addKV(id,k,v){ const row=document.createElement('div');row.className='kv'; - row.innerHTML=''; + row.innerHTML=''; document.getElementById(id).appendChild(row); } function addKVReq(id,k,v,req){ const row=document.createElement('div');row.className='kv'; const star=req?'*':''; - row.innerHTML=star+''; + row.innerHTML=star+''; document.getElementById(id).appendChild(row); } function addFileRow(field){ const row=document.createElement('div');row.className='kv'; - row.innerHTML=''; + row.innerHTML=''; document.getElementById('file-rows').appendChild(row); } function setAcc(t){ @@ -784,23 +1417,26 @@ function setAcc(t){ document.getElementById('bt-form').classList.toggle('on',t==='form'); const ctVal=document.getElementById('ct-val'); if(ctVal)ctVal.value=t==='json'?'application/json':'application/x-www-form-urlencoded'; + refreshLiveCurl(); } function togBody(t){ const cur=document.getElementById(t==='json'?'bt-json':'bt-form').classList.contains('on'); setAcc(cur?(t==='json'?'form':'json'):t); } -function autoH(el){el.style.height='36px';el.style.height=Math.min(el.scrollHeight,window.innerHeight*0.4)+'px';} +function autoH(el){el.style.height='36px';el.style.height=Math.min(el.scrollHeight,window.innerHeight*0.4)+'px';refreshLiveCurl();} function syncAuth(){ const tk=document.getElementById('tk').value;const at=document.getElementById('at').value; const hr=document.getElementById('h-rows');const qr=document.getElementById('q-rows'); - if(!hr||!qr)return; + if(!hr||!qr){refreshLiveCurl();return;} hr.querySelectorAll('[data-auth]').forEach(e=>e.remove()); qr.querySelectorAll('[data-auth]').forEach(e=>e.remove()); - if(!tk)return; - function mkAuth(k,v,parent){const d=document.createElement('div');d.className='kv';d.dataset.auth='1';d.innerHTML='';parent.insertBefore(d,parent.querySelector('.kv:last-child'));} - if(at==='header')mkAuth('Authorization',tk,hr); - if(at==='cookie')mkAuth('Cookie','HOTIME='+tk,hr); - if(at==='query')mkAuth('token',tk,qr); + if(tk){ + function mkAuth(k,v,parent){const d=document.createElement('div');d.className='kv';d.dataset.auth='1';d.innerHTML='';parent.insertBefore(d,parent.querySelector('.kv:last-child'));} + if(at==='header')mkAuth('Authorization',tk,hr); + if(at==='cookie')mkAuth('Cookie','HOTIME='+tk,hr); + if(at==='query')mkAuth('token',tk,qr); + } + refreshLiveCurl(); } function buildPfList(e){ @@ -918,6 +1554,7 @@ function pfFill(v){ document.getElementById('file-rows').innerHTML=''; if(c.hasFile&&c.fileField)addFileRow(c.fileField); addFileRow();syncAuth(); + refreshLiveCurl(); } function getKV(id){ @@ -926,6 +1563,52 @@ function getKV(id){ return Object.keys(obj).length?obj:null; } +function buildLiveCurl(){ + if(!C)return ''; + let url=C.path; + const qp=getKV('q-rows'); + if(qp){const ps=new URLSearchParams();for(const[k,v]of Object.entries(qp))ps.set(k,v);url+='?'+ps.toString();} + const token=document.getElementById('tk')?.value||''; + const authType=document.getElementById('at')?.value||'none'; + const headers={}; + const ch=getKV('h-rows');if(ch)for(const[k,v]of Object.entries(ch)){ + if(k.toLowerCase()==='authorization'&&token&&authType==='header')continue; + if(k.toLowerCase()==='cookie'&&token&&authType==='cookie')continue; + headers[k]=v; + } + if(token&&authType==='header')headers['Authorization']=token; + const fileEls=document.querySelectorAll('#file-rows .kv'); + const files=[];fileEls.forEach(r=>{const fnEl=r.querySelector('input:not([type="file"])');const fi=r.querySelector('input[type="file"]');if(!fnEl||!fi)return;const fn=fnEl.value.trim();if(!fn)return;if(fi.files.length)files.push({field:fn,name:fi.files[0].name});else files.push({field:fn,name:'filename'});}); + const isJson=document.getElementById('bt-json')?.classList.contains('on'); + const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');let curlParts=''; + if(files.length){ + for(const f of files)curlParts+=' -F "'+f.field+'=@'+f.name+'"'; + if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"'; + }else if(isJson){ + const b=bodyEl?.value?.trim(); + if(b&&C.method!=='GET')curlParts=" -d '"+b.replace(/'/g,"'\\''")+"'"; + }else if(formKV){ + for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"'; + } + let curl='curl -X '+C.method; + for(const[k,v]of Object.entries(headers)){if(k&&v)curl+=" -H '"+k+": "+v+"'";} + if(token&&authType==='cookie')curl+=" --cookie 'HOTIME="+token+"'"; + curl+=curlParts+' '+location.origin+url; + return curl; +} +function refreshLiveCurl(){ + const curlBox=document.getElementById('curl-box'); + if(!curlBox||!C)return; + curlBox.textContent=buildLiveCurl(); +} +function bindLiveCurl(){ + const left=document.querySelector('#ct .split-l'); + if(!left||left.dataset.liveCurl==='1')return; + left.dataset.liveCurl='1'; + left.addEventListener('input',refreshLiveCurl); + left.addEventListener('change',refreshLiveCurl); +} + async function send(){ if(!C)return;const btn=document.getElementById('sbtn');btn.disabled=true;btn.textContent='请求中...'; let url=C.path; @@ -945,25 +1628,18 @@ async function send(){ const fileEls=document.querySelectorAll('#file-rows .kv'); const files=[];fileEls.forEach(r=>{const fnEl=r.querySelector('input:not([type="file"])');const fi=r.querySelector('input[type="file"]');if(!fnEl||!fi)return;const fn=fnEl.value.trim();if(fn&&fi.files.length)files.push({field:fn,file:fi.files[0]});}); const isJson=document.getElementById('bt-json')?.classList.contains('on'); - const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');let curlParts=''; + const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows'); if(files.length){ const fd=new FormData();for(const f of files)fd.append(f.field,f.file); if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))fd.append(k,v); opts.body=fd;delete opts.headers['Content-Type']; - for(const f of files)curlParts+=' -F "'+f.field+'=@'+f.file.name+'"'; - if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"'; }else if(isJson){ const b=bodyEl?.value?.trim(); - if(b&&C.method!=='GET'){opts.body=b;curlParts=" -d '"+b+"'";} + if(b&&C.method!=='GET')opts.body=b; }else if(formKV){ const fd=new FormData();for(const[k,v]of Object.entries(formKV))fd.append(k,v);opts.body=fd;delete opts.headers['Content-Type']; - for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"'; } - let curl='curl -X '+C.method; - for(const[k,v]of Object.entries(opts.headers)){if(k&&v)curl+=" -H '"+k+": "+v+"'";} - if(token&&authType==='cookie')curl+=" --cookie 'HOTIME="+token+"'"; - curl+=curlParts+' '+location.origin+url; - const curlBox=document.getElementById('curl-box');if(curlBox)curlBox.textContent=curl; + refreshLiveCurl(); rswitch(1); const t0=Date.now(); try{