diff --git a/apps/server/internal/docs/content/16-testing.md b/apps/server/internal/docs/content/16-testing.md new file mode 100644 index 0000000..dfa5a08 --- /dev/null +++ b/apps/server/internal/docs/content/16-testing.md @@ -0,0 +1,41 @@ +# 自动化测试 + +把日常联调的接口串成**场景用例**,一条龙验证业务流程。三种触发方式共用同一套用例模型: + +| 方式 | 说明 | 适合 | +| --- | --- | --- | +| 手动运行 | 用例编排好后点「运行」 | 日常自测、回归 | +| AI 生成 | 模型按接口清单自动编排步骤与断言 | 快速起盘、补断言思路 | +| 定时任务 | 应用运行期间按间隔自动执行 | 持续监控、冒烟 | + +## 场景用例 + +从侧栏底部「自动化测试」进入。用例 = 有序步骤列表,每个步骤: + +- **引用一个 HTTP 接口**(从集合中选择,可跨集合、跨目录); +- **断言**:状态码 / 耗时 / Header / Body 四类目标,支持 等于、不等于、大于、小于、包含、存在 与 **JSONPath** 取值比较; +- **变量提取**:`$.data.id` → 环境变量 `petId`,后续步骤 URL / Body 用 `{{petId}}` 引用,实现「创建 → 查询 → 删除」链式传值; +- 可单独禁用、上下移动、删除。 + +用例级选项「**失败后继续**」:默认失败即停(快速定位首个问题);打开后跑完全部步骤再看整体报告。 + +## 运行报告 + +- 统计卡:通过 / 失败步骤数、总耗时、断言数、通过率; +- 步骤明细:方法 + URL + 状态码 + 耗时,逐条断言的期望 / 实际对照(可按 通过 / 失败 过滤); +- 「再次运行」一键回归;「导出报告」生成 Markdown 存档或归档到缺陷单; +- 每个用例保留最近 50 次运行记录,可下拉切换历史报告。 + +## 定时任务 + +用例头部的 ⏱ 按钮或左侧「定时任务」入口: + +- 间隔可选 5 / 15 / 30 / 60 分钟、6 小时、每天; +- 独立 启用 / 暂停 开关,显示下次运行时间与上次结果; +- **应用保持运行时生效**:OpsxAPI 窗口(或 Web 页面)开着才会按时触发并记录报告,关闭后自动暂停 —— 适合工作时段的持续监控,不能替代服务器侧的 CI。 + +> 提示:定时触发的请求同样走当前环境与 Mock 设置;跨环境回归前先在顶栏切换环境。 + +## AI 生成用例 + +见 [AI 助手](/docs/ai)。配置模型供应商后,「AI 生成」会读取集合的 HTTP 接口清单(方法、URL、Body 示例),按业务依赖编排步骤、断言与变量提取,生成前可附加一句要求(如「重点覆盖登录态与分页」),预览确认后保存。 diff --git a/apps/server/internal/docs/content/17-ai.md b/apps/server/internal/docs/content/17-ai.md new file mode 100644 index 0000000..90a152d --- /dev/null +++ b/apps/server/internal/docs/content/17-ai.md @@ -0,0 +1,42 @@ +# AI 助手 + +接入 **OpenAI 兼容协议**的大模型,为接口调试与自动化测试提供 AI 能力。密钥只保存在本机(localStorage),请求直连供应商,不经过任何第三方中转。 + +## 配置模型供应商 + +**设置 → AI 助手 → 模型供应商**,点「添加供应商」,内置常用预设: + +| 供应商 | Base URL | 模型示例 | +| --- | --- | --- | +| DeepSeek | `https://api.deepseek.com/v1` | `deepseek-chat` | +| 智谱 GLM | `https://open.bigmodel.cn/api/paas/v4` | `glm-4-flash` | +| Moonshot Kimi | `https://api.moonshot.cn/v1` | `moonshot-v1-8k` | +| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` | +| Ollama 本地 | `http://127.0.0.1:11434/v1` | `qwen2.5:7b` | + +- **API Key**:Ollama 等本地服务可留空; +- **测试连接**:保存前先发一条 ping 验证地址 / 密钥 / 模型名; +- 多个供应商时可切换「当前使用」的模型。 + +## AI 生成场景用例 + +自动化测试视图 →「**AI 生成**」: + +1. 选择集合(AI 读取其中全部 HTTP 接口:方法、URL、Body 示例); +2. 可选填写一句要求,如「覆盖下单 → 支付 → 查询流程,重点断言金额字段」; +3. 模型按业务依赖编排步骤,输出断言与变量提取(如 `$.data.token → token`,后续步骤用 `{{token}}`); +4. 预览生成的步骤清单(未识别的接口会自动跳过并提示),确认后保存为正式用例。 + +生成结果只是起点 —— 步骤、断言、提取规则都可再手动调整。 + +## 隐私与边界 + +- API Key 仅存本机,随工作区数据一起持久化;导出全量备份时会包含,分享备份文件前请注意; +- 供应商收到的内容为接口清单摘要(方法 / URL / Body 片段)与你的指令,**不含**环境变量值与 API Key; +- 未配置供应商时,Mock 数据、环境变量等离线功能不受影响。 + +## 常见问题 + +**生成失败?** 依次检查:Base URL 是否带 `/v1`、模型名是否属于该供应商、Key 是否有效(用「测试连接」验证)、本机能否直连该域名(内网代理环境注意放行)。 + +**想用公司内网模型?** 添加自定义供应商,Base URL 填内网网关地址即可(需兼容 OpenAI Chat Completions 协议)。 diff --git a/apps/server/internal/docs/content/16-server-api.md b/apps/server/internal/docs/content/18-server-api.md similarity index 100% rename from apps/server/internal/docs/content/16-server-api.md rename to apps/server/internal/docs/content/18-server-api.md diff --git a/apps/server/internal/docs/content/17-faq.md b/apps/server/internal/docs/content/19-faq.md similarity index 100% rename from apps/server/internal/docs/content/17-faq.md rename to apps/server/internal/docs/content/19-faq.md diff --git a/apps/server/internal/docs/content/manifest.json b/apps/server/internal/docs/content/manifest.json index 4e05818..d0e2a2e 100644 --- a/apps/server/internal/docs/content/manifest.json +++ b/apps/server/internal/docs/content/manifest.json @@ -106,17 +106,34 @@ } ] }, + { + "name": "自动化测试与 AI", + "pages": [ + { + "file": "16-testing.md", + "slug": "testing", + "title": "自动化测试", + "updated": "2026-09-17" + }, + { + "file": "17-ai.md", + "slug": "ai", + "title": "AI 助手", + "updated": "2026-09-17" + } + ] + }, { "name": "参考", "pages": [ { - "file": "16-server-api.md", + "file": "18-server-api.md", "slug": "server-api", "title": "服务端 API 参考", "updated": "2026-09-17" }, { - "file": "17-faq.md", + "file": "19-faq.md", "slug": "faq", "title": "常见问题", "updated": "2026-09-17" diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index da27321..a7f9474 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -474,6 +474,51 @@ color: var(--accent-strong); } + /* ---------- 自动化测试 ---------- */ + .test-assert-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 6px; + } + .test-assert-row .ops-input { max-width: 150px; } + .test-assert-locator { font-family: ui-monospace, monospace; } + .test-assert-expected { font-family: ui-monospace, monospace; min-width: 110px; } + + .test-stat-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; } + .test-stat { + @apply rounded-xl px-3 py-2.5 text-center; + background: var(--panel-2); + border: 1px solid var(--border); + } + .test-stat.ok { border-color: color-mix(in srgb, var(--success) 45%, transparent); } + .test-stat.bad { border-color: color-mix(in srgb, var(--danger) 45%, transparent); } + .test-stat-value { font-size: 20px; font-weight: 700; line-height: 1.3; } + .test-stat.ok .test-stat-value { color: var(--success); } + .test-stat.bad .test-stat-value { color: var(--danger); } + .test-stat-label { @apply mt-0.5 text-[11px]; color: var(--text-faint); } + + .test-step-result { + @apply overflow-hidden rounded-xl; + background: var(--panel-2); + border: 1px solid var(--border); + } + + .test-assert-table { width: 100%; border-collapse: collapse; font-size: 11px; } + .test-assert-table th { + @apply px-2 py-1 text-left font-semibold; + color: var(--text-faint); + border-bottom: 1px solid var(--border); + } + .test-assert-table td { + @apply px-2 py-1 align-top; + border-bottom: 1px solid color-mix(in srgb, var(--border) 45%, transparent); + color: var(--text-dim); + max-width: 320px; + overflow-wrap: anywhere; + } + /* ---------- {{变量}} 补全浮层 ---------- */ .var-hint-wrap { position: relative; min-width: 0; flex: 1; display: inline-flex; } .var-hint-panel { diff --git a/packages/core/src/factory.ts b/packages/core/src/factory.ts index faafd10..b922c50 100644 --- a/packages/core/src/factory.ts +++ b/packages/core/src/factory.ts @@ -263,6 +263,9 @@ export function createSeedWorkspace(): WorkspaceData { environments: envs, activeEnvironmentId: envs[0]?.id ?? null, history: [], - settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null) }, + settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null), ai: { providers: [], activeProviderId: null } }, + testCases: [], + testRuns: [], + schedules: [], }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a07201d..bdd99a8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,6 +46,19 @@ export type { MockSettings, MockFieldType, MockMatchMode, + AssertTarget, + AssertOp, + TestAssertion, + VariableExtract, + TestStep, + TestCase, + AssertionResult, + TestStepResult, + TestRun, + TestRunTrigger, + TestSchedule, + AiProvider, + AiSettings, } from "./model"; export { REQUEST_METHODS, @@ -103,6 +116,22 @@ export { MOCK_CHEATSHEET, } from "./mock"; +// 自动化测试 +export { ASSERT_OP_LABELS } from "./model"; +export { + queryJsonPath, + describeAssertion, + evaluateAssertion, + suggestAssertions, + extractVariables, + exportRunMarkdown, + newAssertion, + newExtract, + newStep, + newTestCase, + runStats, +} from "./testing"; + // 导入 export { detectImportFormat, diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index aa0ea3c..75e2368 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -261,6 +261,7 @@ export type WorkspaceSettings = { /** 请求超时(毫秒) */ timeoutMs: number; mock: MockSettings; + ai: AiSettings; }; export type WorkspaceData = { @@ -270,6 +271,143 @@ export type WorkspaceData = { activeEnvironmentId: string | null; history: HistoryEntry[]; settings: WorkspaceSettings; + /** 自动化测试:用例 / 运行报告 / 定时任务 */ + testCases: TestCase[]; + testRuns: TestRun[]; + schedules: TestSchedule[]; +}; + +/* ---------------- 自动化测试 ---------------- */ + +/** 断言目标 */ +export type AssertTarget = "status" | "timeMs" | "header" | "body"; + +/** 断言操作 */ +export type AssertOp = + | "eq" + | "neq" + | "lt" + | "gt" + | "contains" + | "not_contains" + | "exists" + | "not_exists" + | "jsonpath_eq" + | "jsonpath_contains"; + +export const ASSERT_OP_LABELS: Record = { + eq: "等于", + neq: "不等于", + lt: "小于", + gt: "大于", + contains: "包含文本", + not_contains: "不包含文本", + exists: "存在", + not_exists: "不存在", + jsonpath_eq: "JSONPath 等于", + jsonpath_contains: "JSONPath 包含", +}; + +/** 单条断言 */ +export type TestAssertion = { + id: string; + enabled: boolean; + target: AssertTarget; + /** target=header 时为 Header 名;jsonpath_* 时为路径 */ + locator: string; + op: AssertOp; + /** 期望值(exists/not_exists 忽略) */ + expected: string; +}; + +/** 响应 → 环境变量提取 */ +export type VariableExtract = { + id: string; + /** JSONPath,如 $.data.token */ + jsonpath: string; + varName: string; +}; + +/** 测试步骤:引用一个请求节点 + 断言 + 提取 */ +export type TestStep = { + id: string; + enabled: boolean; + /** 引用的请求节点 id(HTTP) */ + nodeId: string; + assertions: TestAssertion[]; + extracts: VariableExtract[]; +}; + +/** 场景用例 */ +export type TestCase = { + id: string; + name: string; + description?: string; + /** 失败后是否继续执行后续步骤 */ + continueOnFailure: boolean; + steps: TestStep[]; + createdAt: number; + updatedAt: number; +}; + +export type AssertionResult = { + label: string; + passed: boolean; + /** 实际值(截断展示) */ + actual: string; + expected: string; +}; + +export type TestStepResult = { + stepId: string; + name: string; + method: string; + url: string; + ok: boolean; + httpStatus: number | null; + durationMs: number | null; + error: string | null; + assertionResults: AssertionResult[]; +}; + +export type TestRunTrigger = "manual" | "ai" | "schedule"; + +/** 一次运行报告 */ +export type TestRun = { + id: string; + caseId: string; + caseName: string; + at: number; + trigger: TestRunTrigger; + totalMs: number; + passed: boolean; + stepResults: TestStepResult[]; +}; + +/** 定时任务(应用运行期间生效) */ +export type TestSchedule = { + caseId: string; + enabled: boolean; + /** 间隔分钟 */ + intervalMinutes: number; + lastRunAt: number | null; +}; + +/* ---------------- AI 助手 ---------------- */ + +/** OpenAI 兼容供应商配置 */ +export type AiProvider = { + id: string; + name: string; + baseUrl: string; + apiKey: string; + model: string; + enabled: boolean; +}; + +export type AiSettings = { + providers: AiProvider[]; + activeProviderId: string | null; }; /** 导入结果:集合 + 建议写入环境的前缀变量(如 baseUrl) */ diff --git a/packages/core/src/testing.test.ts b/packages/core/src/testing.test.ts new file mode 100644 index 0000000..6e2ac26 --- /dev/null +++ b/packages/core/src/testing.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { + describeAssertion, + evaluateAssertion, + exportRunMarkdown, + extractVariables, + newTestCase, + queryJsonPath, + runStats, + suggestAssertions, + type StepHttpResponse, +} from "./testing"; +import type { TestAssertion } from "./model"; + +const res: StepHttpResponse = { + status: 200, + headers: { "Content-Type": "application/json", "X-Trace-Id": "abc123" }, + body: JSON.stringify({ code: 0, data: { id: 42, name: "opsx", tags: ["a", "b"] }, msg: "ok" }), + timeMs: 123, +}; + +const as = (over: Partial): TestAssertion => ({ + id: "a1", + enabled: true, + target: "status", + locator: "", + op: "eq", + expected: "", + ...over, +}); + +describe("queryJsonPath", () => { + it("基本路径", () => { + const obj = JSON.parse(res.body); + expect(queryJsonPath(obj, "$.code")).toBe(0); + expect(queryJsonPath(obj, "$.data.id")).toBe(42); + expect(queryJsonPath(obj, "$.data.tags[1]")).toBe("b"); + expect(queryJsonPath(obj, "$['data']['name']")).toBe("opsx"); + expect(queryJsonPath(obj, "$.data.missing")).toBeUndefined(); + expect(queryJsonPath(obj, "$")).toEqual(obj); + }); +}); + +describe("断言评估", () => { + it("状态码 / 耗时(数字比较)", () => { + expect(evaluateAssertion(res, as({ target: "status", op: "eq", expected: "200" })).passed).toBe(true); + expect(evaluateAssertion(res, as({ target: "status", op: "lt", expected: "300" })).passed).toBe(true); + expect(evaluateAssertion(res, as({ target: "timeMs", op: "gt", expected: "100" })).passed).toBe(true); + expect(evaluateAssertion(res, as({ target: "timeMs", op: "lt", expected: "50" })).passed).toBe(false); + }); + + it("Header 大小写不敏感", () => { + expect(evaluateAssertion(res, as({ target: "header", locator: "x-trace-id", op: "contains", expected: "abc" })).passed).toBe(true); + expect(evaluateAssertion(res, as({ target: "header", locator: "X-Missing", op: "exists" })).passed).toBe(false); + }); + + it("JSONPath 断言", () => { + expect(evaluateAssertion(res, as({ target: "body", locator: "$.data.name", op: "jsonpath_eq", expected: "opsx" })).passed).toBe(true); + expect(evaluateAssertion(res, as({ target: "body", locator: "$.data.id", op: "jsonpath_eq", expected: "43" })).passed).toBe(false); + expect(evaluateAssertion(res, as({ target: "body", locator: "$.msg", op: "jsonpath_contains", expected: "o" })).passed).toBe(true); + expect(evaluateAssertion(res, as({ target: "body", locator: "$.data.name", op: "exists" })).passed).toBe(true); + expect(evaluateAssertion(res, as({ target: "body", locator: "$.none", op: "not_exists" })).passed).toBe(true); + }); + + it("非 JSON 响应的 jsonpath 断言失败但不抛错", () => { + const plain = { ...res, body: "hello world" }; + const r = evaluateAssertion(plain, as({ target: "body", locator: "$.a", op: "jsonpath_eq", expected: "1" })); + expect(r.passed).toBe(false); + expect(r.actual).toContain("非 JSON"); + }); + + it("断言文案", () => { + expect(describeAssertion(as({ target: "status", op: "eq", expected: "200" }))).toContain("状态码"); + expect(describeAssertion(as({ target: "body", locator: "$.a", op: "jsonpath_contains", expected: "x" }))).toContain("$.a"); + }); +}); + +describe("变量提取", () => { + it("提取到平面对象", () => { + const out = extractVariables(res, [ + { id: "e1", jsonpath: "$.data.id", varName: "petId" }, + { id: "e2", jsonpath: "$.data.name", varName: "petName" }, + { id: "e3", jsonpath: "$.none", varName: "missing" }, + { id: "e4", jsonpath: "", varName: "bad" }, + ]); + expect(out).toEqual({ petId: "42", petName: "opsx" }); + }); +}); + +describe("默认断言与统计", () => { + it("suggestAssertions 至少含状态码与耗时", () => { + const list = suggestAssertions({ method: "GET", url: "https://x" } as never); + expect(list.length).toBeGreaterThanOrEqual(2); + expect(list[0].target).toBe("status"); + }); + it("runStats", () => { + expect(runStats({ stepResults: [{ ok: true }, { ok: false }, { ok: true }] })).toEqual({ ok: 2, fail: 1 }); + }); +}); + +describe("报告导出", () => { + it("Markdown 结构", () => { + const md = exportRunMarkdown({ + caseName: "下单流程", + at: Date.now(), + trigger: "manual", + totalMs: 500, + passed: false, + stepResults: [ + { + name: "创建订单", + method: "POST", + url: "https://api.test/orders", + ok: true, + httpStatus: 200, + durationMs: 200, + error: null, + assertionResults: [{ label: "状态码 = 200", passed: true, actual: "200", expected: "200" }], + }, + { + name: "查询订单", + method: "GET", + url: "https://api.test/orders/1", + ok: false, + httpStatus: 404, + durationMs: 300, + error: null, + assertionResults: [{ label: "状态码 = 200", passed: false, actual: "404", expected: "200" }], + }, + ], + }); + expect(md).toContain("# 测试报告 · 下单流程"); + expect(md).toContain("1/2 步骤通过"); + expect(md).toContain("POST https://api.test/orders"); + expect(md).toContain("| 状态码 = 200 | 200 | 404 | ❌ |"); + }); +}); + +describe("用例构造", () => { + it("newTestCase 默认值", () => { + const tc = newTestCase("冒烟"); + expect(tc.steps).toEqual([]); + expect(tc.continueOnFailure).toBe(false); + expect(tc.id).toMatch(/^tc-/); + }); +}); diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts new file mode 100644 index 0000000..71183e6 --- /dev/null +++ b/packages/core/src/testing.ts @@ -0,0 +1,298 @@ +/** + * 自动化测试引擎(纯函数):JSONPath 求值 / 断言评估 / 变量提取 / 报告导出。 + */ +import type { + AssertOp, + AssertionResult, + HttpRequestData, + TestAssertion, + TestCase, + TestStep, + VariableExtract, +} from "./model"; + +export type StepHttpResponse = { + status: number; + headers: Record; + body: string; + timeMs: number; +}; + +/* ---------------- JSONPath($.a.b[0].c / $['k'] 子集) ---------------- */ + +/** 返回命中的第一个值;未命中返回 undefined */ +export function queryJsonPath(data: unknown, path: string): unknown { + const normalized = path.trim().replace(/^\$\.?/, ""); + if (normalized === "" || normalized === "$") return data; + + const tokens: string[] = []; + const re = /([^[\].]+)|\['([^']+)'\]|\["([^"]+)"\]|\[(\d+)\]/g; + let m: RegExpExecArray | null; + while ((m = re.exec(normalized)) !== null) { + if (m[1] !== undefined) tokens.push(m[1]); + else if (m[2] !== undefined) tokens.push(m[2]); + else if (m[3] !== undefined) tokens.push(m[3]); + else if (m[4] !== undefined) tokens.push(m[4]); + } + + let cur: unknown = data; + for (const t of tokens) { + if (cur === null || cur === undefined) return undefined; + if (Array.isArray(cur)) { + const idx = Number(t); + if (Number.isNaN(idx)) return undefined; + cur = cur[idx]; + } else if (typeof cur === "object") { + cur = (cur as Record)[t]; + } else { + return undefined; + } + } + return cur; +} + +function stringifyValue(v: unknown): string { + if (v === undefined) return ""; + if (typeof v === "object") { + try { + return JSON.stringify(v); + } catch { + return String(v); + } + } + return String(v); +} + +/* ---------------- 断言评估 ---------------- */ + +export function describeAssertion(a: TestAssertion): string { + const where = + a.target === "status" + ? "状态码" + : a.target === "timeMs" + ? "耗时(ms)" + : a.target === "header" + ? `Header ${a.locator}` + : a.op.startsWith("jsonpath") + ? `Body ${a.locator}` + : "Body"; + const OPS: Record = { + eq: "=", + neq: "≠", + lt: "<", + gt: ">", + contains: "包含", + not_contains: "不包含", + exists: "存在", + not_exists: "不存在", + jsonpath_eq: "=", + jsonpath_contains: "包含", + }; + const needExpected = !(a.op === "exists" || a.op === "not_exists"); + return `${where} ${OPS[a.op]}${needExpected ? ` ${a.expected}` : ""}`; +} + +export function evaluateAssertion( + res: StepHttpResponse, + a: TestAssertion, +): AssertionResult { + const base = { label: describeAssertion(a), expected: a.op === "exists" || a.op === "not_exists" ? "—" : a.expected }; + + let actual: string; + let num: number | null = null; + switch (a.target) { + case "status": + actual = String(res.status); + num = res.status; + break; + case "timeMs": + actual = String(res.timeMs); + num = res.timeMs; + break; + case "header": { + const key = Object.keys(res.headers).find((h) => h.toLowerCase() === a.locator.trim().toLowerCase()); + actual = key ? res.headers[key] : ""; + break; + } + default: { + // body:jsonpath_* 与 exists(locator 以 $. 开头时)先解析再取值;contains 系直接对原文 + if (a.op.startsWith("jsonpath") || (a.locator.trim().startsWith("$.") && (a.op === "exists" || a.op === "not_exists"))) { + let parsed: unknown; + try { + parsed = JSON.parse(res.body); + } catch { + return { ...base, passed: false, actual: "(非 JSON 响应)" }; + } + actual = stringifyValue(queryJsonPath(parsed, a.locator)); + } else { + actual = res.body; + } + } + } + + const expectedNum = Number(a.expected); + const numeric = num !== null && a.expected.trim() !== "" && !Number.isNaN(expectedNum); + + let passed = false; + switch (a.op) { + case "eq": + passed = numeric ? num === expectedNum : actual.trim() === a.expected.trim(); + break; + case "neq": + passed = numeric ? num !== expectedNum : actual.trim() !== a.expected.trim(); + break; + case "lt": + passed = numeric && num !== null && num < expectedNum; + break; + case "gt": + passed = numeric && num !== null && num > expectedNum; + break; + case "contains": + passed = actual.includes(a.expected); + break; + case "not_contains": + passed = !actual.includes(a.expected); + break; + case "exists": + passed = actual !== ""; + break; + case "not_exists": + passed = actual === ""; + break; + case "jsonpath_eq": + passed = actual.trim() === a.expected.trim(); + break; + case "jsonpath_contains": + passed = actual.includes(a.expected); + break; + } + return { ...base, passed, actual: actual.length > 300 ? `${actual.slice(0, 300)}…` : actual }; +} + +/** 按 HTTP 节点推断默认断言 */ +export function suggestAssertions(data: HttpRequestData): TestAssertion[] { + const mk = (target: TestAssertion["target"], locator: string, op: AssertOp, expected: string): TestAssertion => ({ + id: `as-${Math.random().toString(36).slice(2, 10)}`, + enabled: true, + target, + locator, + op, + expected, + }); + const out = [mk("status", "", "eq", "200"), mk("timeMs", "", "lt", "5000")]; + return out; +} + +/** 执行变量提取:返回 { 变量名: 值 } */ +export function extractVariables(res: StepHttpResponse, extracts: VariableExtract[]): Record { + const out: Record = {}; + let parsed: unknown; + let parsedOnce = false; + for (const e of extracts) { + if (!e.varName.trim() || !e.jsonpath.trim()) continue; + if (!parsedOnce) { + try { + parsed = JSON.parse(res.body); + } catch { + parsed = undefined; + } + parsedOnce = true; + } + const v = queryJsonPath(parsed, e.jsonpath); + if (v !== undefined) out[e.varName.trim()] = stringifyValue(v); + } + return out; +} + +/* ---------------- 报告导出 ---------------- */ + +export function exportRunMarkdown(run: { + caseName: string; + at: number; + trigger: string; + totalMs: number; + passed: boolean; + stepResults: { + name: string; + method: string; + url: string; + ok: boolean; + httpStatus: number | null; + durationMs: number | null; + error: string | null; + assertionResults: AssertionResult[]; + }[]; +}): string { + const dt = new Date(run.at).toLocaleString("zh-CN"); + const total = run.stepResults.length; + const okCount = run.stepResults.filter((s) => s.ok).length; + const lines: string[] = [ + `# 测试报告 · ${run.caseName}`, + "", + `- 时间:${dt}`, + `- 触发:${run.trigger === "schedule" ? "定时任务" : run.trigger === "ai" ? "AI 流程" : "手动运行"}`, + `- 结果:**${run.passed ? "✅ 全部通过" : "❌ 存在失败"}**(${okCount}/${total} 步骤通过)`, + `- 总耗时:${run.totalMs} ms`, + "", + ]; + for (const [i, s] of run.stepResults.entries()) { + lines.push(`## ${i + 1}. ${s.ok ? "✅" : "❌"} ${s.name}`); + lines.push(""); + lines.push(`- 请求:\`${s.method} ${s.url}\``); + if (s.httpStatus !== null) lines.push(`- 状态码:${s.httpStatus}`); + if (s.durationMs !== null) lines.push(`- 耗时:${s.durationMs} ms`); + if (s.error) lines.push(`- 错误:${s.error}`); + if (s.assertionResults.length) { + lines.push("", "| 断言 | 期望 | 实际 | 结果 |", "| --- | --- | --- | --- |"); + for (const a of s.assertionResults) { + lines.push(`| ${a.label} | ${a.expected} | ${a.actual || "—"} | ${a.passed ? "✅" : "❌"} |`); + } + } + lines.push(""); + } + return lines.join("\n"); +} + +/* ---------------- 用例构造 ---------------- */ + +export function newAssertion(): TestAssertion { + return { + id: `as-${Math.random().toString(36).slice(2, 10)}`, + enabled: true, + target: "status", + locator: "", + op: "eq", + expected: "200", + }; +} + +export function newExtract(): VariableExtract { + return { id: `ex-${Math.random().toString(36).slice(2, 10)}`, jsonpath: "$.", varName: "" }; +} + +export function newStep(nodeId: string, data?: HttpRequestData): TestStep { + return { + id: `st-${Math.random().toString(36).slice(2, 10)}`, + enabled: true, + nodeId, + assertions: data ? suggestAssertions(data) : [newAssertion()], + extracts: [], + }; +} + +export function newTestCase(name = "未命名用例"): TestCase { + return { + id: `tc-${Math.random().toString(36).slice(2, 10)}`, + name, + continueOnFailure: false, + steps: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} + +/** 统计:通过/失败步骤数 */ +export function runStats(run: { stepResults: { ok: boolean }[] }): { ok: number; fail: number } { + const ok = run.stepResults.filter((s) => s.ok).length; + return { ok, fail: run.stepResults.length - ok }; +} diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 5dc5292..52add06 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -8,6 +8,8 @@ import { Icon } from "./components/icons"; import { Logo } from "./components/Logo"; import { useWorkspace } from "./workspaceStore"; import { Sidebar } from "./components/Sidebar"; +import { TestingView } from "./components/TestingView"; +import { startScheduler, stopScheduler } from "./scheduler"; import { TabBar } from "./components/TabBar"; import { TopBar } from "./components/TopBar"; import { GraphqlEditor } from "./components/editors/GraphqlEditor"; @@ -32,6 +34,7 @@ export function OpsxWorkspace() { const activeTabId = useWorkspace((s) => s.activeTabId); const dialog = useWorkspace((s) => s.dialog); const openTab = useWorkspace((s) => s.openTab); + const view = useWorkspace((s) => s.view); useEffect(() => { init(); @@ -45,12 +48,21 @@ export function OpsxWorkspace() { }); }, []); + // 定时任务调度器(应用运行期间常驻) + useEffect(() => { + startScheduler(); + return () => stopScheduler(); + }, []); + const activeRef = activeTabId ? findNode(collections, activeTabId) : null; const activeNode = activeRef?.node; return (
+ {view === "testing" ? ( + + ) : (
@@ -74,6 +86,7 @@ export function OpsxWorkspace() {
+ )} {dialog?.type === "new" && } {dialog?.type === "import" && } {dialog?.type === "export" && } diff --git a/packages/ui/src/ai.ts b/packages/ui/src/ai.ts new file mode 100644 index 0000000..6144555 --- /dev/null +++ b/packages/ui/src/ai.ts @@ -0,0 +1,228 @@ +/** + * AI 助手:OpenAI 兼容 Chat Completions 客户端 + 用例生成。 + * 供应商配置保存在工作区设置(本地 localStorage),不经任何第三方中转。 + */ +import type { + AiProvider, + Collection, + HttpRequestData, + RequestNode, + TestAssertion, + TestCase, + TestStep, +} from "@opsxapi/core"; +import { newTestCase, suggestAssertions } from "@opsxapi/core"; + +/** 常见供应商预设(均为 OpenAI 兼容协议) */ +export const AI_PROVIDER_PRESETS: { name: string; baseUrl: string; model: string; hint: string }[] = [ + { name: "DeepSeek", baseUrl: "https://api.deepseek.com/v1", model: "deepseek-chat", hint: "性价比高,推荐" }, + { name: "智谱 GLM", baseUrl: "https://open.bigmodel.cn/api/paas/v4", model: "glm-4-flash", hint: "新用户有免费额度" }, + { name: "Moonshot Kimi", baseUrl: "https://api.moonshot.cn/v1", model: "moonshot-v1-8k", hint: "" }, + { name: "OpenAI", baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini", hint: "" }, + { name: "Ollama 本地", baseUrl: "http://127.0.0.1:11434/v1", model: "qwen2.5:7b", hint: "无需 API Key" }, +]; + +export function newAiProvider(preset?: (typeof AI_PROVIDER_PRESETS)[number]): AiProvider { + return { + id: `ai-${Math.random().toString(36).slice(2, 10)}`, + name: preset?.name ?? "自定义供应商", + baseUrl: preset?.baseUrl ?? "", + apiKey: "", + model: preset?.model ?? "", + enabled: true, + }; +} + +type ChatMessage = { role: "system" | "user" | "assistant"; content: string }; + +/** 调用 Chat Completions(非流式),失败抛 Error */ +export async function chatComplete(provider: AiProvider, messages: ChatMessage[], timeoutMs = 60000): Promise { + if (!provider.baseUrl.trim()) throw new Error("未配置 Base URL"); + const url = `${provider.baseUrl.trim().replace(/\/+$/, "")}/chat/completions`; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(provider.apiKey.trim() ? { Authorization: `Bearer ${provider.apiKey.trim()}` } : {}), + }, + body: JSON.stringify({ + model: provider.model.trim() || "gpt-4o-mini", + messages, + temperature: 0.2, + stream: false, + }), + signal: ctrl.signal, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`HTTP ${res.status}${text ? `:${text.slice(0, 200)}` : ""}`); + } + const data = (await res.json()) as { + choices?: { message?: { content?: string } }[]; + }; + const content = data.choices?.[0]?.message?.content; + if (!content) throw new Error("模型未返回内容"); + return content; + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") throw new Error("请求超时"); + throw e instanceof Error ? e : new Error(String(e)); + } +} + +/** 连接测试:问一句并要求简答 */ +export async function testProviderConnection(provider: AiProvider): Promise { + const reply = await chatComplete( + provider, + [ + { role: "system", content: "你是连通性测试助手,只输出:OK" }, + { role: "user", content: "ping" }, + ], + 20000, + ); + return reply.trim().slice(0, 60) || "OK"; +} + +/* ---------------- AI 生成场景用例 ---------------- */ + +/** 汇总集合中的 HTTP 接口给模型看 */ +function summarizeNodes(nodes: { node: RequestNode; path: string }[]): string { + return nodes + .map(({ node, path }) => { + if (node.kind !== "http") return null; + const d = node.data as HttpRequestData; + const lines = [ + `- id: ${node.id} | ${d.method} ${d.url} | 名称: ${node.name}${path ? ` | 目录: ${path}` : ""}`, + ]; + if (d.bodyMode === "json" && d.bodyRaw.trim()) { + lines.push(` body 示例: ${d.bodyRaw.replace(/\s+/g, " ").slice(0, 160)}`); + } + return lines.join("\n"); + }) + .filter(Boolean) + .join("\n"); +} + +const GEN_SYSTEM_PROMPT = `你是 API 自动化测试专家。根据给定的接口清单设计一个可执行的"场景用例"(按业务顺序编排多个接口步骤)。 +只输出一个 JSON 代码块(\`\`\`json ... \`\`\`),不要输出其他解释文字。JSON 结构: +{ + "name": "用例名称(中文,简短)", + "description": "一句话说明场景", + "steps": [ + { + "nodeId": "必须使用清单中的接口 id", + "reason": "为什么这一步", + "assertions": [ + { "target": "status|timeMs|header|body", "locator": "header名或JSONPath(其余为空)", "op": "eq|neq|lt|gt|contains|not_contains|exists|not_exists|jsonpath_eq|jsonpath_contains", "expected": "期望值" } + ], + "extracts": [ { "jsonpath": "$.data.id", "varName": "petId" } ] + } + ] +} +要求:步骤顺序符合业务依赖(如 先创建 → 再查询 → 再删除);每步至少 1 条断言;跨步骤传值用 extracts 提取变量并在后续 URL/Body 用 {{变量名}}(如果原接口里已有 {{xxx}} 占位则沿用);断言 4-8 条为宜,聚焦关键业务字段。`; + +export type AiCaseDraft = { name: string; description?: string; steps: TestStep[]; notes: string[] }; + +/** 从模型回复中提取 JSON(容错:代码块 / 首尾杂文) */ +export function extractJsonBlock(text: string): unknown { + const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text); + const raw = fenced ? fenced[1] : text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1); + return JSON.parse(raw.trim()); +} + +export async function aiGenerateTestCase( + provider: AiProvider, + collection: Collection, + instruction?: string, +): Promise { + const nodes: { node: RequestNode; path: string }[] = []; + const walk = (children: Collection["children"], path: string): void => { + for (const n of children) { + if (n.type === "folder") walk(n.children, path ? `${path}/${n.name}` : n.name); + else if (n.kind === "http") nodes.push({ node: n, path }); + } + }; + walk(collection.children, ""); + if (nodes.length === 0) throw new Error("集合中没有 HTTP 接口可供编排"); + + const user = [ + `接口清单(共 ${nodes.length} 个):`, + summarizeNodes(nodes), + instruction?.trim() ? `\n用户要求:${instruction.trim()}` : "", + "\n请输出场景用例 JSON。", + ] + .filter(Boolean) + .join("\n"); + + const reply = await chatComplete(provider, [ + { role: "system", content: GEN_SYSTEM_PROMPT }, + { role: "user", content: user }, + ]); + + const parsed = extractJsonBlock(reply) as { + name?: string; + description?: string; + steps?: { + nodeId?: string; + reason?: string; + assertions?: { target?: string; locator?: string; op?: string; expected?: string }[]; + extracts?: { jsonpath?: string; varName?: string }[]; + }[]; + }; + + const notes: string[] = []; + const steps: TestStep[] = []; + const validIds = new Set(nodes.map((n) => n.node.id)); + const nodeById = new Map(nodes.map((n) => [n.node.id, n.node])); + + for (const s of parsed.steps ?? []) { + if (!s.nodeId) continue; + if (!validIds.has(s.nodeId)) { + notes.push(`已跳过未知接口:${s.nodeId}`); + continue; + } + const node = nodeById.get(s.nodeId)!; + const assertions: TestAssertion[] = (s.assertions ?? []).map((a) => ({ + id: `as-${Math.random().toString(36).slice(2, 10)}`, + enabled: true, + target: (["status", "timeMs", "header", "body"].includes(a.target ?? "") ? a.target : "status") as TestAssertion["target"], + locator: a.locator ?? "", + op: ([ + "eq", "neq", "lt", "gt", "contains", "not_contains", "exists", "not_exists", "jsonpath_eq", "jsonpath_contains", + ].includes(a.op ?? "") ? a.op : "eq") as TestAssertion["op"], + expected: a.expected ?? "", + })); + if (assertions.length === 0) { + assertions.push(...suggestAssertions(node.data as HttpRequestData)); + if (s.reason) notes.push(`「${node.name}」未给断言,已补默认断言`); + } + steps.push({ + id: `st-${Math.random().toString(36).slice(2, 10)}`, + enabled: true, + nodeId: s.nodeId, + assertions, + extracts: (s.extracts ?? []) + .filter((e) => e.jsonpath && e.varName) + .map((e) => ({ id: `ex-${Math.random().toString(36).slice(2, 10)}`, jsonpath: e.jsonpath!, varName: e.varName! })), + }); + } + + if (steps.length === 0) throw new Error("模型未生成有效步骤,请重试或补充要求"); + + return { + name: parsed.name?.trim() || `${collection.name} · AI 用例`, + description: parsed.description?.trim(), + steps, + notes, + }; +} + +/** 把 AI 草稿转成正式用例 */ +export function draftToTestCase(draft: AiCaseDraft): TestCase { + const tc = newTestCase(draft.name); + tc.description = draft.description; + tc.steps = draft.steps; + return tc; +} diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index f5abc23..837c7de 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -115,6 +115,17 @@ export function Sidebar() { )}
+ {/* 自动化测试入口 */} +
+ +
+ setMenu(null)} /> ); diff --git a/packages/ui/src/components/TestingView.tsx b/packages/ui/src/components/TestingView.tsx new file mode 100644 index 0000000..254bcc0 --- /dev/null +++ b/packages/ui/src/components/TestingView.tsx @@ -0,0 +1,693 @@ +/** + * 自动化测试视图:场景用例列表 / 步骤编排(断言 + 变量提取)/ 运行报告 / 定时任务。 + * 三种触发方式:手动运行(本视图)、AI 生成(AiCaseDialog)、定时调度(scheduler + 定时设置)。 + */ +import type { + AssertOp, + AssertTarget, + Collection, + HttpRequestData, + RequestNode, + TestCase, + TestRun, +} from "@opsxapi/core"; +import { + ASSERT_OP_LABELS, + exportRunMarkdown, + newAssertion, + newExtract, + newStep, + runStats, +} from "@opsxapi/core"; +import { useEffect, useMemo, useState } from "react"; +import { nextRunAt } from "../scheduler"; +import { runTestCase } from "../testRunner"; +import { useWorkspace } from "../workspaceStore"; +import { cx } from "./common"; +import { Icon } from "./icons"; +import { AiCaseDialog } from "./dialogs/AiCaseDialog"; +import { ScheduleDialog } from "./dialogs/ScheduleDialog"; + +const TARGET_LABELS: Record = { + status: "状态码", + timeMs: "耗时(ms)", + header: "Header", + body: "Body", +}; + +/** 汇总所有集合中的 HTTP 节点为选择器选项 */ +function useHttpNodeOptions(): { id: string; label: string; method: string }[] { + const collections = useWorkspace((s) => s.collections); + return useMemo(() => { + const out: { id: string; label: string; method: string }[] = []; + const walk = (nodes: Collection["children"], prefix: string): void => { + for (const n of nodes) { + if (n.type === "folder") walk(n.children, prefix ? `${prefix}/${n.name}` : n.name); + else if (n.kind === "http") { + const d = (n as RequestNode & { kind: "http" }).data as HttpRequestData; + out.push({ id: n.id, label: `${prefix ? `${prefix}/` : ""}${n.name}`, method: d.method }); + } + } + }; + for (const c of collections) walk(c.children, c.name); + return out; + }, [collections]); +} + +export function TestingView() { + const testCases = useWorkspace((s) => s.testCases); + const testRuns = useWorkspace((s) => s.testRuns); + const schedules = useWorkspace((s) => s.schedules); + const setView = useWorkspace((s) => s.setView); + const addTestCase = useWorkspace((s) => s.addTestCase); + + const [selected, setSelected] = useState(testCases[0]?.id ?? null); + const [showAi, setShowAi] = useState(false); + const [showSchedule, setShowSchedule] = useState(false); + const [runAllBusy, setRunAllBusy] = useState(false); + + const tc = testCases.find((c) => c.id === selected) ?? null; + const lastRunOf = (caseId: string): TestRun | undefined => testRuns.find((r) => r.caseId === caseId); + + const runAll = async () => { + setRunAllBusy(true); + try { + for (const c of testCases) await runTestCase(c.id, "manual"); + } finally { + setRunAllBusy(false); + } + }; + + return ( +
+ {/* 左栏:用例列表 */} + + + {/* 右侧:用例详情 */} + {tc ? ( + + ) : ( +
+
+
+
自动化测试
+
+ 手动编排 · AI 生成 · 定时调度,三种方式共用同一套场景用例 +
+ 左侧新建用例,或从「⋯ 菜单 → 自动化测试」随时返回 +
+
+
+ )} + + {showAi && setShowAi(false)} onSaved={(id) => setSelected(id)} />} + {showSchedule && setShowSchedule(false)} />} +
+ ); +} + +/* ---------------- 用例详情:编辑 + 报告 ---------------- */ + +function CaseDetail({ tc }: { tc: TestCase }) { + const updateTestCase = useWorkspace((s) => s.updateTestCase); + const removeTestCase = useWorkspace((s) => s.removeTestCase); + const allRuns = useWorkspace((s) => s.testRuns); + const runs = useMemo(() => allRuns.filter((r) => r.caseId === tc.id), [allRuns, tc.id]); + const schedules = useWorkspace((s) => s.schedules); + const nodeOptions = useHttpNodeOptions(); + const [tab, setTab] = useState<"edit" | "report">("edit"); + const [busy, setBusy] = useState(false); + const sched = schedules.find((s) => s.caseId === tc.id); + + const patch = (p: Partial) => updateTestCase(tc.id, p); + + const patchStep = (stepId: string, p: Partial) => + patch({ steps: tc.steps.map((st) => (st.id === stepId ? { ...st, ...p } : st)) }); + + const moveStep = (idx: number, dir: -1 | 1) => { + const steps = [...tc.steps]; + const j = idx + dir; + if (j < 0 || j >= steps.length) return; + [steps[idx], steps[j]] = [steps[j], steps[idx]]; + patch({ steps }); + }; + + const run = async () => { + setBusy(true); + setTab("report"); + try { + await runTestCase(tc.id, "manual"); + } finally { + setBusy(false); + } + }; + + return ( +
+ {/* 头部 */} +
+ patch({ name: e.target.value })} + placeholder="用例名称" + /> + + + + +
+ + {/* Tabs */} +
+ + +
+ + {tab === "edit" ? ( +
+