AI 大模型接入 + 自动化测试(手动/AI/定时三种触发)
- 自动化测试核心(packages/core/src/testing.ts):
- JSONPath 求值($.a.b[0].c)、断言评估(状态码/耗时/Header/Body × 10 种操作)
- 变量提取(响应 → 当前环境,{{var}} 链式传值)、Markdown 报告导出
- 用例模型:场景用例 = 有序步骤(引用 HTTP 接口 + 断言 + 提取),失败后继续可选
- 执行器 testRunner + 定时调度器 scheduler(应用运行期间按间隔自动执行)
- TestingView:用例列表 / 步骤编排 / 断言与提取编辑器 / 报告(统计卡+断言明细+过滤+再次运行+导出)
- AI 助手:OpenAI 兼容客户端(DeepSeek/智谱/Kimi/OpenAI/Ollama 预设)
- 设置 → AI 助手:供应商 CRUD + 测试连接 + 当前使用切换
- AI 生成场景用例:读取集合接口清单 → 编排步骤/断言/提取 → 预览保存
- 入口:侧栏底部「自动化测试」+ ⋯ 菜单;文档新增 /docs/testing /docs/ai
- 新增 11 个引擎单测(总 48 前端用例全绿);本地 E2E:运行通过+断言通过+petId 提取验证
This commit is contained in:
1 parent
9a019408ab
commit
1bcd7be2ac
23 files changed
+2538
-7
No files matched your search
@@ -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 示例),按业务依赖编排步骤、断言与变量提取,生成前可附加一句要求(如「重点覆盖登录态与分页」),预览确认后保存。
|
||||||
@@ -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 协议)。
|
||||||
File renamed without changes.
File renamed without changes.
@@ -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": "参考",
|
"name": "参考",
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"file": "16-server-api.md",
|
"file": "18-server-api.md",
|
||||||
"slug": "server-api",
|
"slug": "server-api",
|
||||||
"title": "服务端 API 参考",
|
"title": "服务端 API 参考",
|
||||||
"updated": "2026-09-17"
|
"updated": "2026-09-17"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "17-faq.md",
|
"file": "19-faq.md",
|
||||||
"slug": "faq",
|
"slug": "faq",
|
||||||
"title": "常见问题",
|
"title": "常见问题",
|
||||||
"updated": "2026-09-17"
|
"updated": "2026-09-17"
|
||||||
|
|||||||
@@ -474,6 +474,51 @@
|
|||||||
color: var(--accent-strong);
|
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-wrap { position: relative; min-width: 0; flex: 1; display: inline-flex; }
|
||||||
.var-hint-panel {
|
.var-hint-panel {
|
||||||
|
|||||||
@@ -263,6 +263,9 @@ export function createSeedWorkspace(): WorkspaceData {
|
|||||||
environments: envs,
|
environments: envs,
|
||||||
activeEnvironmentId: envs[0]?.id ?? null,
|
activeEnvironmentId: envs[0]?.id ?? null,
|
||||||
history: [],
|
history: [],
|
||||||
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null) },
|
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null), ai: { providers: [], activeProviderId: null } },
|
||||||
|
testCases: [],
|
||||||
|
testRuns: [],
|
||||||
|
schedules: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,19 @@ export type {
|
|||||||
MockSettings,
|
MockSettings,
|
||||||
MockFieldType,
|
MockFieldType,
|
||||||
MockMatchMode,
|
MockMatchMode,
|
||||||
|
AssertTarget,
|
||||||
|
AssertOp,
|
||||||
|
TestAssertion,
|
||||||
|
VariableExtract,
|
||||||
|
TestStep,
|
||||||
|
TestCase,
|
||||||
|
AssertionResult,
|
||||||
|
TestStepResult,
|
||||||
|
TestRun,
|
||||||
|
TestRunTrigger,
|
||||||
|
TestSchedule,
|
||||||
|
AiProvider,
|
||||||
|
AiSettings,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
export {
|
export {
|
||||||
REQUEST_METHODS,
|
REQUEST_METHODS,
|
||||||
@@ -103,6 +116,22 @@ export {
|
|||||||
MOCK_CHEATSHEET,
|
MOCK_CHEATSHEET,
|
||||||
} from "./mock";
|
} from "./mock";
|
||||||
|
|
||||||
|
// 自动化测试
|
||||||
|
export { ASSERT_OP_LABELS } from "./model";
|
||||||
|
export {
|
||||||
|
queryJsonPath,
|
||||||
|
describeAssertion,
|
||||||
|
evaluateAssertion,
|
||||||
|
suggestAssertions,
|
||||||
|
extractVariables,
|
||||||
|
exportRunMarkdown,
|
||||||
|
newAssertion,
|
||||||
|
newExtract,
|
||||||
|
newStep,
|
||||||
|
newTestCase,
|
||||||
|
runStats,
|
||||||
|
} from "./testing";
|
||||||
|
|
||||||
// 导入
|
// 导入
|
||||||
export {
|
export {
|
||||||
detectImportFormat,
|
detectImportFormat,
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ export type WorkspaceSettings = {
|
|||||||
/** 请求超时(毫秒) */
|
/** 请求超时(毫秒) */
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
mock: MockSettings;
|
mock: MockSettings;
|
||||||
|
ai: AiSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceData = {
|
export type WorkspaceData = {
|
||||||
@@ -270,6 +271,143 @@ export type WorkspaceData = {
|
|||||||
activeEnvironmentId: string | null;
|
activeEnvironmentId: string | null;
|
||||||
history: HistoryEntry[];
|
history: HistoryEntry[];
|
||||||
settings: WorkspaceSettings;
|
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<AssertOp, string> = {
|
||||||
|
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) */
|
/** 导入结果:集合 + 建议写入环境的前缀变量(如 baseUrl) */
|
||||||
|
|||||||
@@ -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>): 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-/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* 自动化测试引擎(纯函数):JSONPath 求值 / 断言评估 / 变量提取 / 报告导出。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
AssertOp,
|
||||||
|
AssertionResult,
|
||||||
|
HttpRequestData,
|
||||||
|
TestAssertion,
|
||||||
|
TestCase,
|
||||||
|
TestStep,
|
||||||
|
VariableExtract,
|
||||||
|
} from "./model";
|
||||||
|
|
||||||
|
export type StepHttpResponse = {
|
||||||
|
status: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
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<string, unknown>)[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<AssertOp, string> = {
|
||||||
|
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<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { Icon } from "./components/icons";
|
|||||||
import { Logo } from "./components/Logo";
|
import { Logo } from "./components/Logo";
|
||||||
import { useWorkspace } from "./workspaceStore";
|
import { useWorkspace } from "./workspaceStore";
|
||||||
import { Sidebar } from "./components/Sidebar";
|
import { Sidebar } from "./components/Sidebar";
|
||||||
|
import { TestingView } from "./components/TestingView";
|
||||||
|
import { startScheduler, stopScheduler } from "./scheduler";
|
||||||
import { TabBar } from "./components/TabBar";
|
import { TabBar } from "./components/TabBar";
|
||||||
import { TopBar } from "./components/TopBar";
|
import { TopBar } from "./components/TopBar";
|
||||||
import { GraphqlEditor } from "./components/editors/GraphqlEditor";
|
import { GraphqlEditor } from "./components/editors/GraphqlEditor";
|
||||||
@@ -32,6 +34,7 @@ export function OpsxWorkspace() {
|
|||||||
const activeTabId = useWorkspace((s) => s.activeTabId);
|
const activeTabId = useWorkspace((s) => s.activeTabId);
|
||||||
const dialog = useWorkspace((s) => s.dialog);
|
const dialog = useWorkspace((s) => s.dialog);
|
||||||
const openTab = useWorkspace((s) => s.openTab);
|
const openTab = useWorkspace((s) => s.openTab);
|
||||||
|
const view = useWorkspace((s) => s.view);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
init();
|
init();
|
||||||
@@ -45,12 +48,21 @@ export function OpsxWorkspace() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 定时任务调度器(应用运行期间常驻)
|
||||||
|
useEffect(() => {
|
||||||
|
startScheduler();
|
||||||
|
return () => stopScheduler();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const activeRef = activeTabId ? findNode(collections, activeTabId) : null;
|
const activeRef = activeTabId ? findNode(collections, activeTabId) : null;
|
||||||
const activeNode = activeRef?.node;
|
const activeNode = activeRef?.node;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen min-h-[640px] flex-col overflow-hidden">
|
<div className="flex h-screen min-h-[640px] flex-col overflow-hidden">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
|
{view === "testing" ? (
|
||||||
|
<TestingView />
|
||||||
|
) : (
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
@@ -74,6 +86,7 @@ export function OpsxWorkspace() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
{dialog?.type === "new" && <NewItemDialog collectionId={dialog.collectionId} parentId={dialog.parentId} />}
|
{dialog?.type === "new" && <NewItemDialog collectionId={dialog.collectionId} parentId={dialog.parentId} />}
|
||||||
{dialog?.type === "import" && <ImportDialog />}
|
{dialog?.type === "import" && <ImportDialog />}
|
||||||
{dialog?.type === "export" && <ExportDialog collectionId={dialog.collectionId} />}
|
{dialog?.type === "export" && <ExportDialog collectionId={dialog.collectionId} />}
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<AiCaseDraft> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -115,6 +115,17 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 自动化测试入口 */}
|
||||||
|
<div className="p-2" style={{ borderTop: "1px solid var(--border)" }}>
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-secondary w-full"
|
||||||
|
onClick={() => useWorkspace.getState().setView("testing")}
|
||||||
|
title="场景用例 · AI 生成 · 定时任务"
|
||||||
|
>
|
||||||
|
<Icon name="list-checks" size={13} /> 自动化测试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ContextMenu menu={menu} onClose={() => setMenu(null)} />
|
<ContextMenu menu={menu} onClose={() => setMenu(null)} />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<AssertTarget, string> = {
|
||||||
|
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<string | null>(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 (
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{/* 左栏:用例列表 */}
|
||||||
|
<aside className="ops-panel flex w-64 shrink-0 flex-col" style={{ borderRight: "1px solid var(--border)" }}>
|
||||||
|
<div className="flex items-center gap-1.5 p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<Icon name="list-checks" size={15} className="ops-accent" />
|
||||||
|
<span className="text-[13px] font-semibold">场景用例</span>
|
||||||
|
<span className="ops-faint ml-auto text-[11px]">{testCases.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<button className="ops-btn ops-btn-secondary !px-2" onClick={() => setSelected(addTestCase())}>
|
||||||
|
<Icon name="plus" size={13} /> 新建
|
||||||
|
</button>
|
||||||
|
<button className="ops-btn ops-btn-secondary !px-2" onClick={() => setShowAi(true)}>
|
||||||
|
<Icon name="bot" size={13} /> AI 生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ops-scroll min-h-0 flex-1 p-2">
|
||||||
|
{testCases.length === 0 && (
|
||||||
|
<div className="ops-faint px-2 py-8 text-center text-xs leading-5">
|
||||||
|
还没有用例
|
||||||
|
<br />
|
||||||
|
点「新建」手动编排,或用「AI 生成」
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{testCases.map((c) => {
|
||||||
|
const last = lastRunOf(c.id);
|
||||||
|
const sched = schedules.find((s) => s.caseId === c.id && s.enabled);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
className={cx("ops-tree-row w-full text-left", selected === c.id && "ops-tree-row-active")}
|
||||||
|
onClick={() => setSelected(c.id)}
|
||||||
|
>
|
||||||
|
<span className="truncate text-[12px] font-medium">{c.name}</span>
|
||||||
|
<span className="ops-faint ml-auto shrink-0 text-[10px]">{c.steps.length} 步</span>
|
||||||
|
{sched && <Icon name="calendar-clock" size={11} className="ops-accent" />}
|
||||||
|
{last && (
|
||||||
|
<Icon
|
||||||
|
name={last.passed ? "circle-check" : "circle-x"}
|
||||||
|
size={12}
|
||||||
|
className={last.passed ? "ops-success" : "ops-danger"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 p-2.5" style={{ borderTop: "1px solid var(--border)" }}>
|
||||||
|
<button className="ops-btn ops-btn-secondary w-full" onClick={() => setShowSchedule(true)}>
|
||||||
|
<Icon name="calendar-clock" size={13} /> 定时任务
|
||||||
|
{schedules.filter((s) => s.enabled).length > 0 && (
|
||||||
|
<span className="ops-count">{schedules.filter((s) => s.enabled).length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button className="ops-btn ops-btn-primary w-full" disabled={runAllBusy || testCases.length === 0} onClick={runAll}>
|
||||||
|
<Icon name="play" size={13} /> {runAllBusy ? "运行中…" : "运行全部"}
|
||||||
|
</button>
|
||||||
|
<button className="ops-btn ops-btn-ghost w-full" onClick={() => setView("api")}>
|
||||||
|
<Icon name="arrow-left-right" size={13} /> 返回接口工作台
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 右侧:用例详情 */}
|
||||||
|
{tc ? (
|
||||||
|
<CaseDetail key={tc.id} tc={tc} />
|
||||||
|
) : (
|
||||||
|
<div className="grid min-w-0 flex-1 place-items-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="ops-dim mb-2 flex justify-center"><Icon name="list-checks" size={40} /></div>
|
||||||
|
<div className="ops-dim text-sm font-semibold">自动化测试</div>
|
||||||
|
<div className="ops-faint mt-1 text-xs leading-5">
|
||||||
|
手动编排 · AI 生成 · 定时调度,三种方式共用同一套场景用例
|
||||||
|
<br />
|
||||||
|
左侧新建用例,或从「⋯ 菜单 → 自动化测试」随时返回
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAi && <AiCaseDialog onClose={() => setShowAi(false)} onSaved={(id) => setSelected(id)} />}
|
||||||
|
{showSchedule && <ScheduleDialog onClose={() => setShowSchedule(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 用例详情:编辑 + 报告 ---------------- */
|
||||||
|
|
||||||
|
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<TestCase>) => updateTestCase(tc.id, p);
|
||||||
|
|
||||||
|
const patchStep = (stepId: string, p: Partial<TestCase["steps"][number]>) =>
|
||||||
|
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 (
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 px-3 py-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<input
|
||||||
|
className="ops-input ops-input-sm min-w-40 flex-1 font-semibold"
|
||||||
|
value={tc.name}
|
||||||
|
onChange={(e) => patch({ name: e.target.value })}
|
||||||
|
placeholder="用例名称"
|
||||||
|
/>
|
||||||
|
<label className="ops-faint flex items-center gap-1.5 text-[11px]" title="某步失败后是否继续执行后续步骤">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tc.continueOnFailure}
|
||||||
|
onChange={(e) => patch({ continueOnFailure: e.target.checked })}
|
||||||
|
/>
|
||||||
|
失败后继续
|
||||||
|
</label>
|
||||||
|
<ScheduleInline caseId={tc.id} />
|
||||||
|
<button className="ops-btn ops-btn-primary" disabled={busy || tc.steps.length === 0} onClick={run}>
|
||||||
|
<Icon name="play" size={13} /> {busy ? "运行中…" : "运行"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-ghost ops-danger"
|
||||||
|
title="删除用例"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`删除用例「${tc.name}」及其运行记录?`)) removeTestCase(tc.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex shrink-0 px-2" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<button className={cx("ops-tab", tab === "edit" && "ops-tab-active")} onClick={() => setTab("edit")}>
|
||||||
|
用例编排
|
||||||
|
</button>
|
||||||
|
<button className={cx("ops-tab", tab === "report" && "ops-tab-active")} onClick={() => setTab("report")}>
|
||||||
|
测试报告 {runs.length > 0 && <span className="ops-count">{runs.length}</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "edit" ? (
|
||||||
|
<div className="ops-scroll flex-1 p-3.5">
|
||||||
|
<textarea
|
||||||
|
className="ops-input mb-3 min-h-14 w-full resize-y text-[12px]"
|
||||||
|
placeholder="用例说明(可选):场景目的、前置条件…"
|
||||||
|
value={tc.description ?? ""}
|
||||||
|
onChange={(e) => patch({ description: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tc.steps.length === 0 && (
|
||||||
|
<div className="ops-faint py-8 text-center text-xs leading-5">
|
||||||
|
还没有步骤,点击下方「添加步骤」从集合中选择 HTTP 接口
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{tc.steps.map((step, idx) => (
|
||||||
|
<StepEditor
|
||||||
|
key={step.id}
|
||||||
|
index={idx}
|
||||||
|
step={step}
|
||||||
|
nodeOptions={nodeOptions}
|
||||||
|
onPatch={(p) => patchStep(step.id, p)}
|
||||||
|
onRemove={() => patch({ steps: tc.steps.filter((st) => st.id !== step.id) })}
|
||||||
|
onMove={(dir) => moveStep(idx, dir)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-secondary w-full border-dashed"
|
||||||
|
onClick={() => patch({ steps: [...tc.steps, newStep("")] })}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size={13} /> 添加步骤
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ReportPanel caseId={tc.id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 单步骤编辑 ---------------- */
|
||||||
|
|
||||||
|
function StepEditor({
|
||||||
|
index,
|
||||||
|
step,
|
||||||
|
nodeOptions,
|
||||||
|
onPatch,
|
||||||
|
onRemove,
|
||||||
|
onMove,
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
step: TestCase["steps"][number];
|
||||||
|
nodeOptions: { id: string; label: string; method: string }[];
|
||||||
|
onPatch: (p: Partial<TestCase["steps"][number]>) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onMove: (dir: -1 | 1) => void;
|
||||||
|
}) {
|
||||||
|
const node = nodeOptions.find((o) => o.id === step.nodeId);
|
||||||
|
const [showDetail, setShowDetail] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx("rounded-xl", !step.enabled && "opacity-50")} style={{ border: "1px solid var(--border)" }}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 px-3 py-2" style={{ borderBottom: showDetail ? "1px solid var(--border)" : "none" }}>
|
||||||
|
<span className="ops-faint w-5 text-center text-[11px] font-bold">{index + 1}</span>
|
||||||
|
<select
|
||||||
|
className="ops-input ops-input-sm min-w-52 flex-1"
|
||||||
|
value={step.nodeId}
|
||||||
|
onChange={(e) => onPatch({ nodeId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">选择 HTTP 接口…</option>
|
||||||
|
{nodeOptions.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.method} · {o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{node && (
|
||||||
|
<span className={cx("ops-chip", `ops-method-${node.method}`)} style={{ padding: "1px 8px", fontSize: 10 }}>
|
||||||
|
{node.method}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button className="ops-btn ops-btn-ghost px-1.5" title="上移" onClick={() => onMove(-1)}>
|
||||||
|
<Icon name="chevron-right" size={12} style={{ transform: "rotate(-90deg)" }} />
|
||||||
|
</button>
|
||||||
|
<button className="ops-btn ops-btn-ghost px-1.5" title="下移" onClick={() => onMove(1)}>
|
||||||
|
<Icon name="chevron-down" size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-ghost px-1.5"
|
||||||
|
title={step.enabled ? "禁用步骤" : "启用步骤"}
|
||||||
|
onClick={() => onPatch({ enabled: !step.enabled })}
|
||||||
|
>
|
||||||
|
<Icon name={step.enabled ? "circle-check" : "circle-x"} size={13} className={step.enabled ? "ops-success" : "ops-faint"} />
|
||||||
|
</button>
|
||||||
|
<button className="ops-btn ops-btn-ghost px-1.5" title="删除步骤" onClick={onRemove}>
|
||||||
|
<Icon name="trash" size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDetail && (
|
||||||
|
<div className="space-y-2.5 px-3 py-2.5">
|
||||||
|
{/* 断言 */}
|
||||||
|
<div>
|
||||||
|
<div className="ops-faint mb-1 text-[11px] font-semibold uppercase tracking-wider">断言</div>
|
||||||
|
{step.assertions.map((a) => (
|
||||||
|
<div key={a.id} className="test-assert-row">
|
||||||
|
<select
|
||||||
|
className="ops-input ops-input-sm"
|
||||||
|
value={a.target}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPatch({
|
||||||
|
assertions: step.assertions.map((x) =>
|
||||||
|
x.id === a.id ? { ...x, target: e.target.value as AssertTarget, locator: "", expected: "" } : x,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(Object.keys(TARGET_LABELS) as AssertTarget[]).map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{TARGET_LABELS[t]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(a.target === "header" || a.op.startsWith("jsonpath")) && (
|
||||||
|
<input
|
||||||
|
className="ops-input ops-input-sm test-assert-locator"
|
||||||
|
placeholder={a.target === "header" ? "Header 名" : "$.data.id"}
|
||||||
|
value={a.locator}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, locator: e.target.value } : x)) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
className="ops-input ops-input-sm"
|
||||||
|
value={a.op}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, op: e.target.value as AssertOp } : x)) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(Object.keys(ASSERT_OP_LABELS) as AssertOp[]).map((op) => (
|
||||||
|
<option key={op} value={op}>
|
||||||
|
{ASSERT_OP_LABELS[op]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{a.op !== "exists" && a.op !== "not_exists" && (
|
||||||
|
<input
|
||||||
|
className="ops-input ops-input-sm test-assert-expected"
|
||||||
|
placeholder="期望值"
|
||||||
|
value={a.expected}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, expected: e.target.value } : x)) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-ghost px-1.5"
|
||||||
|
title={a.enabled ? "禁用" : "启用"}
|
||||||
|
onClick={() =>
|
||||||
|
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, enabled: !x.enabled } : x)) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name={a.enabled ? "circle-check" : "circle-x"} size={12} className={a.enabled ? "ops-success" : "ops-faint"} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-ghost ops-danger px-1.5"
|
||||||
|
title="删除断言"
|
||||||
|
onClick={() => onPatch({ assertions: step.assertions.filter((x) => x.id !== a.id) })}
|
||||||
|
>
|
||||||
|
<Icon name="x" size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className="ops-btn ops-btn-ghost mt-1 !py-0.5 text-[11px]" onClick={() => onPatch({ assertions: [...step.assertions, newAssertion()] })}>
|
||||||
|
<Icon name="plus" size={11} /> 添加断言
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 变量提取 */}
|
||||||
|
<div>
|
||||||
|
<div className="ops-faint mb-1 text-[11px] font-semibold uppercase tracking-wider">
|
||||||
|
变量提取 <span className="normal-case">(响应 → 当前环境,后续步骤用 {"{{变量名}}"} 引用)</span>
|
||||||
|
</div>
|
||||||
|
{step.extracts.length === 0 && (
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-ghost !py-0.5 text-[11px]"
|
||||||
|
onClick={() => onPatch({ extracts: [...step.extracts, newExtract()] })}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size={11} /> 添加提取
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step.extracts.map((ex) => (
|
||||||
|
<div key={ex.id} className="test-assert-row">
|
||||||
|
<input
|
||||||
|
className="ops-input ops-input-sm test-assert-locator"
|
||||||
|
placeholder="$.data.token"
|
||||||
|
value={ex.jsonpath}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPatch({ extracts: step.extracts.map((x) => (x.id === ex.id ? { ...x, jsonpath: e.target.value } : x)) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="ops-faint text-[11px]">→</span>
|
||||||
|
<input
|
||||||
|
className="ops-input ops-input-sm test-assert-expected"
|
||||||
|
placeholder="变量名 token"
|
||||||
|
value={ex.varName}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPatch({ extracts: step.extracts.map((x) => (x.id === ex.id ? { ...x, varName: e.target.value } : x)) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-ghost ops-danger px-1.5"
|
||||||
|
title="删除"
|
||||||
|
onClick={() => onPatch({ extracts: step.extracts.filter((x) => x.id !== ex.id) })}
|
||||||
|
>
|
||||||
|
<Icon name="x" size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{step.extracts.length > 0 && (
|
||||||
|
<button className="ops-btn ops-btn-ghost mt-1 !py-0.5 text-[11px]" onClick={() => onPatch({ extracts: [...step.extracts, newExtract()] })}>
|
||||||
|
<Icon name="plus" size={11} /> 添加提取
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 行内定时配置 ---------------- */
|
||||||
|
|
||||||
|
function ScheduleInline({ caseId }: { caseId: string }) {
|
||||||
|
const schedules = useWorkspace((s) => s.schedules);
|
||||||
|
const upsertSchedule = useWorkspace((s) => s.upsertSchedule);
|
||||||
|
const removeSchedule = useWorkspace((s) => s.removeSchedule);
|
||||||
|
const sc = schedules.find((s) => s.caseId === caseId);
|
||||||
|
|
||||||
|
if (!sc) {
|
||||||
|
return (
|
||||||
|
<button className="ops-btn ops-btn-secondary !px-2" title="设置定时运行" onClick={() => upsertSchedule(caseId, {})}>
|
||||||
|
<Icon name="calendar-clock" size={13} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<select
|
||||||
|
className="ops-input ops-input-sm !w-24"
|
||||||
|
value={sc.intervalMinutes}
|
||||||
|
onChange={(e) => upsertSchedule(caseId, { intervalMinutes: Number(e.target.value) })}
|
||||||
|
title={`下次运行:${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt)).toLocaleTimeString("zh-CN")}`}
|
||||||
|
>
|
||||||
|
{[5, 15, 30, 60, 360, 1440].map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m < 60 ? `每 ${m} 分钟` : m < 1440 ? `每 ${m / 60} 小时` : "每天"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-ghost px-1.5"
|
||||||
|
title={sc.enabled ? "暂停定时" : "启用定时"}
|
||||||
|
onClick={() => upsertSchedule(caseId, { enabled: !sc.enabled })}
|
||||||
|
>
|
||||||
|
<Icon name={sc.enabled ? "calendar-clock" : "circle-x"} size={13} className={sc.enabled ? "ops-accent" : "ops-faint"} />
|
||||||
|
</button>
|
||||||
|
<button className="ops-btn ops-btn-ghost ops-danger px-1.5" title="删除定时" onClick={() => removeSchedule(caseId)}>
|
||||||
|
<Icon name="x" size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 运行报告 ---------------- */
|
||||||
|
|
||||||
|
function ReportPanel({ caseId }: { caseId: string }) {
|
||||||
|
const allRuns = useWorkspace((s) => s.testRuns);
|
||||||
|
const runs = useMemo(() => allRuns.filter((r) => r.caseId === caseId), [allRuns, caseId]);
|
||||||
|
const [selectedRun, setSelectedRun] = useState<string | null>(null);
|
||||||
|
const [filter, setFilter] = useState<"all" | "pass" | "fail">("all");
|
||||||
|
const run = runs.find((r) => r.id === selectedRun) ?? runs[0];
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (run && selectedRun && !runs.some((r) => r.id === selectedRun)) setSelectedRun(null);
|
||||||
|
}, [runs, selectedRun, run]);
|
||||||
|
|
||||||
|
if (!run) {
|
||||||
|
return (
|
||||||
|
<div className="ops-faint grid flex-1 place-items-center text-center text-xs leading-5">
|
||||||
|
还没有运行记录
|
||||||
|
<br />
|
||||||
|
点击右上「运行」执行用例后在此查看报告
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = runStats(run);
|
||||||
|
const assertCount = run.stepResults.reduce((acc, s) => acc + s.assertionResults.length, 0);
|
||||||
|
const shown = run.stepResults.filter((s) => (filter === "all" ? true : filter === "pass" ? s.ok : !s.ok));
|
||||||
|
|
||||||
|
const download = () => {
|
||||||
|
const md = exportRunMarkdown(run);
|
||||||
|
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${run.caseName}-报告-${new Date(run.at).toISOString().slice(0, 10)}.md`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ops-scroll flex-1 p-3.5">
|
||||||
|
{/* 运行选择 + 操作 */}
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
className="ops-input ops-input-sm w-64"
|
||||||
|
value={run.id}
|
||||||
|
onChange={(e) => setSelectedRun(e.target.value)}
|
||||||
|
>
|
||||||
|
{runs.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{new Date(r.at).toLocaleString("zh-CN")} · {r.trigger === "schedule" ? "定时" : r.trigger === "ai" ? "AI" : "手动"} ·{" "}
|
||||||
|
{r.passed ? "通过" : "失败"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="ml-auto flex gap-1.5">
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await runTestCase(caseId, "manual");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="rotate-cw" size={13} /> 再次运行
|
||||||
|
</button>
|
||||||
|
<button className="ops-btn ops-btn-secondary" onClick={download}>
|
||||||
|
<Icon name="file-down" size={13} /> 导出报告
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡 */}
|
||||||
|
<div className="test-stat-grid mb-3">
|
||||||
|
<div className={cx("test-stat", run.passed && "ok")}>
|
||||||
|
<div className="test-stat-value">{stats.ok}</div>
|
||||||
|
<div className="test-stat-label">通过步骤</div>
|
||||||
|
</div>
|
||||||
|
<div className={cx("test-stat", stats.fail > 0 && "bad")}>
|
||||||
|
<div className="test-stat-value">{stats.fail}</div>
|
||||||
|
<div className="test-stat-label">失败步骤</div>
|
||||||
|
</div>
|
||||||
|
<div className="test-stat">
|
||||||
|
<div className="test-stat-value">{run.totalMs}</div>
|
||||||
|
<div className="test-stat-label">总耗时 (ms)</div>
|
||||||
|
</div>
|
||||||
|
<div className="test-stat">
|
||||||
|
<div className="test-stat-value">{assertCount}</div>
|
||||||
|
<div className="test-stat-label">断言数</div>
|
||||||
|
</div>
|
||||||
|
<div className="test-stat">
|
||||||
|
<div className="test-stat-value" style={{ fontSize: 16 }}>
|
||||||
|
{run.stepResults.length > 0 ? `${Math.round((stats.ok / run.stepResults.length) * 100)}%` : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="test-stat-label">通过率</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 过滤 */}
|
||||||
|
<div className="mb-2 flex gap-1.5">
|
||||||
|
{([["all", `全部 ${run.stepResults.length}`], ["pass", `通过 ${stats.ok}`], ["fail", `失败 ${stats.fail}`]] as const).map(
|
||||||
|
([k, label]) => (
|
||||||
|
<button key={k} className={cx("ops-chip", filter === k && "ops-chip-active")} onClick={() => setFilter(k as never)}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 步骤明细 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{shown.map((s) => (
|
||||||
|
<div key={s.stepId} className="test-step-result">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
|
||||||
|
<Icon name={s.ok ? "circle-check" : "circle-x"} size={15} className={s.ok ? "ops-success" : "ops-danger"} />
|
||||||
|
<span className="text-[13px] font-medium">{s.name}</span>
|
||||||
|
{s.method && s.url !== "—" && (
|
||||||
|
<span className="ops-faint truncate font-mono text-[11px]">
|
||||||
|
{s.method} {s.url}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.httpStatus !== null && (
|
||||||
|
<span className={cx("text-[11px] font-bold", s.httpStatus < 400 ? "ops-success" : "ops-danger")}>{s.httpStatus}</span>
|
||||||
|
)}
|
||||||
|
{s.durationMs !== null && (
|
||||||
|
<span className="ops-faint ml-auto flex items-center gap-1 text-[11px]">
|
||||||
|
<Icon name="timer" size={11} /> {s.durationMs} ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{s.error && <div className="ops-danger px-3 pb-2 text-[11px]">{s.error}</div>}
|
||||||
|
{s.assertionResults.length > 0 && (
|
||||||
|
<div className="px-3 pb-2">
|
||||||
|
<table className="test-assert-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>断言</th>
|
||||||
|
<th>期望</th>
|
||||||
|
<th>实际</th>
|
||||||
|
<th>结果</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{s.assertionResults.map((a, j) => (
|
||||||
|
<tr key={j}>
|
||||||
|
<td>{a.label}</td>
|
||||||
|
<td className="font-mono">{a.expected || "—"}</td>
|
||||||
|
<td className="font-mono">{a.actual || "—"}</td>
|
||||||
|
<td className={a.passed ? "ops-success" : "ops-danger"}>{a.passed ? "通过" : "失败"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -100,6 +100,11 @@ export function TopBar() {
|
|||||||
icon: <Icon name="flask-conical" size={14} />,
|
icon: <Icon name="flask-conical" size={14} />,
|
||||||
onClick: () => setDialog({ type: "settings", tab: "mock" }),
|
onClick: () => setDialog({ type: "settings", tab: "mock" }),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "自动化测试",
|
||||||
|
icon: <Icon name="list-checks" size={14} />,
|
||||||
|
onClick: () => useWorkspace.getState().setView("testing"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "环境管理…",
|
label: "环境管理…",
|
||||||
icon: <Icon name="arrow-left-right" size={14} />,
|
icon: <Icon name="arrow-left-right" size={14} />,
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* AI 生成场景用例:选集合 + 要求 → 模型输出步骤编排 → 预览确认保存。
|
||||||
|
* 需先在「设置 → AI 助手」配置模型供应商。
|
||||||
|
*/
|
||||||
|
import type { AiCaseDraft } from "../../ai";
|
||||||
|
import { aiGenerateTestCase } from "../../ai";
|
||||||
|
import { useWorkspace } from "../../workspaceStore";
|
||||||
|
import { Modal } from "../common";
|
||||||
|
import { Icon } from "../icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function AiCaseDialog({ onClose, onSaved }: { onClose: () => void; onSaved: (caseId: string) => void }) {
|
||||||
|
const collections = useWorkspace((s) => s.collections);
|
||||||
|
const ai = useWorkspace((s) => s.settings.ai);
|
||||||
|
const setDialog = useWorkspace((s) => s.setDialog);
|
||||||
|
const addTestCase = useWorkspace((s) => s.addTestCase);
|
||||||
|
const updateTestCase = useWorkspace((s) => s.updateTestCase);
|
||||||
|
|
||||||
|
const [collectionId, setCollectionId] = useState(collections[0]?.id ?? "");
|
||||||
|
const [instruction, setInstruction] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [draft, setDraft] = useState<AiCaseDraft | null>(null);
|
||||||
|
|
||||||
|
const provider = ai.providers.find((p) => p.id === ai.activeProviderId && p.enabled) ?? null;
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
const collection = collections.find((c) => c.id === collectionId);
|
||||||
|
if (!provider || !collection) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setDraft(null);
|
||||||
|
try {
|
||||||
|
setDraft(await aiGenerateTestCase(provider, collection, instruction));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!draft) return;
|
||||||
|
const id = addTestCase();
|
||||||
|
updateTestCase(id, { name: draft.name, description: draft.description, steps: draft.steps });
|
||||||
|
onSaved(id);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="AI 生成场景用例"
|
||||||
|
onClose={onClose}
|
||||||
|
width="680px"
|
||||||
|
footer={
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<span className="ops-faint text-[11px]">生成后可再手动调整步骤与断言</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="ops-btn ops-btn-secondary" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
{draft ? (
|
||||||
|
<button className="ops-btn ops-btn-primary" onClick={save}>
|
||||||
|
<Icon name="circle-check" size={13} /> 保存用例
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-primary"
|
||||||
|
disabled={!provider || !collectionId || busy}
|
||||||
|
onClick={generate}
|
||||||
|
>
|
||||||
|
<Icon name="bot" size={13} /> {busy ? "生成中…" : "生成"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!provider ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="ops-hint">
|
||||||
|
<Icon name="info" size={14} />
|
||||||
|
<span>尚未配置 AI 供应商。请先在「设置 → AI 助手」添加 OpenAI 兼容的模型供应商。</span>
|
||||||
|
</div>
|
||||||
|
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog({ type: "settings", tab: "ai" })}>
|
||||||
|
<Icon name="settings" size={13} /> 打开设置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3.5">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="ops-label">集合</label>
|
||||||
|
<select className="ops-input" value={collectionId} onChange={(e) => setCollectionId(e.target.value)}>
|
||||||
|
{collections.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}({countHttp(c.children)} 个 HTTP 接口)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="ops-label">模型</label>
|
||||||
|
<div className="ops-faint flex h-[34px] items-center gap-1.5 text-[12px]">
|
||||||
|
<Icon name="bot" size={13} className="ops-accent" />
|
||||||
|
{provider.name} · {provider.model || "默认模型"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="ops-label">要求(可选)</label>
|
||||||
|
<textarea
|
||||||
|
className="ops-input min-h-16 w-full resize-y text-[12px]"
|
||||||
|
placeholder="例:覆盖用户登录 → 查询个人信息 → 修改昵称的完整流程,重点断言返回码与业务字段"
|
||||||
|
value={instruction}
|
||||||
|
onChange={(e) => setInstruction(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="ops-danger text-[12px]">生成失败:{error}</div>}
|
||||||
|
|
||||||
|
{draft && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon name="circle-check" size={14} className="ops-success" />
|
||||||
|
<span className="text-[13px] font-semibold">{draft.name}</span>
|
||||||
|
<span className="ops-faint text-[11px]">{draft.steps.length} 个步骤</span>
|
||||||
|
</div>
|
||||||
|
{draft.description && <div className="ops-faint text-[11px]">{draft.description}</div>}
|
||||||
|
{draft.notes.map((n, i) => (
|
||||||
|
<div key={i} className="ops-faint text-[11px]">
|
||||||
|
· {n}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="max-h-64 space-y-1.5 overflow-y-auto">
|
||||||
|
{draft.steps.map((s, i) => (
|
||||||
|
<div key={s.id} className="flex items-start gap-2 rounded-lg px-2.5 py-1.5" style={{ border: "1px solid var(--border)" }}>
|
||||||
|
<span className="ops-faint mt-0.5 text-[11px] font-bold">{i + 1}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[12px] font-medium">{stepName(s.nodeId)}</div>
|
||||||
|
<div className="ops-faint text-[10px]">
|
||||||
|
{s.assertions.length} 条断言
|
||||||
|
{s.extracts.length > 0 && ` · 提取 ${s.extracts.map((e) => e.varName).join(", ")}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
|
function stepName(nodeId: string): string {
|
||||||
|
const { collections: cols } = useWorkspace.getState();
|
||||||
|
let name = nodeId;
|
||||||
|
const walk = (nodes: { type: string; name?: string; id: string; children?: unknown[] }[]): void => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.id === nodeId && n.type === "request") name = n.name ?? name;
|
||||||
|
if (Array.isArray(n.children)) walk(n.children as never[]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const c of cols) walk(c.children as never[]);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countHttp(nodes: { type: string; kind?: string; children?: unknown[] }[]): number {
|
||||||
|
let n = 0;
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === "request" && node.kind === "http") n++;
|
||||||
|
if (Array.isArray(node.children)) n += countHttp(node.children as never[]);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* 定时任务管理:为每个用例配置启用状态与运行间隔(应用运行期间生效)。
|
||||||
|
*/
|
||||||
|
import { nextRunAt } from "../../scheduler";
|
||||||
|
import { useWorkspace } from "../../workspaceStore";
|
||||||
|
import { Modal, cx } from "../common";
|
||||||
|
import { Icon } from "../icons";
|
||||||
|
|
||||||
|
const INTERVALS = [5, 15, 30, 60, 360, 1440];
|
||||||
|
|
||||||
|
function intervalLabel(m: number): string {
|
||||||
|
if (m < 60) return `每 ${m} 分钟`;
|
||||||
|
if (m < 1440) return `每 ${m / 60} 小时`;
|
||||||
|
return "每天";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleDialog({ onClose }: { onClose: () => void }) {
|
||||||
|
const testCases = useWorkspace((s) => s.testCases);
|
||||||
|
const schedules = useWorkspace((s) => s.schedules);
|
||||||
|
const testRuns = useWorkspace((s) => s.testRuns);
|
||||||
|
const upsertSchedule = useWorkspace((s) => s.upsertSchedule);
|
||||||
|
const removeSchedule = useWorkspace((s) => s.removeSchedule);
|
||||||
|
|
||||||
|
const lastRunOf = (caseId: string) => testRuns.find((r) => r.caseId === caseId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="定时任务"
|
||||||
|
onClose={onClose}
|
||||||
|
width="640px"
|
||||||
|
footer={
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<span className="ops-faint text-[11px]">应用保持运行时按间隔自动执行并记录报告;关闭应用后暂停</span>
|
||||||
|
<button className="ops-btn ops-btn-primary" onClick={onClose}>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{testCases.length === 0 ? (
|
||||||
|
<div className="ops-faint py-8 text-center text-xs">还没有场景用例,先在左侧新建或用 AI 生成</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[52vh] space-y-2 overflow-y-auto">
|
||||||
|
{testCases.map((tc) => {
|
||||||
|
const sc = schedules.find((s) => s.caseId === tc.id);
|
||||||
|
const last = lastRunOf(tc.id);
|
||||||
|
return (
|
||||||
|
<div key={tc.id} className="flex flex-wrap items-center gap-2 rounded-xl px-3 py-2.5" style={{ border: "1px solid var(--border)" }}>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[13px] font-medium">{tc.name}</div>
|
||||||
|
<div className="ops-faint text-[11px]">
|
||||||
|
{sc?.enabled
|
||||||
|
? `下次约 ${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt)).toLocaleTimeString("zh-CN")} 运行`
|
||||||
|
: "未启用定时"}
|
||||||
|
{sc?.lastRunAt ? ` · 上次 ${new Date(sc.lastRunAt).toLocaleString("zh-CN")}` : ""}
|
||||||
|
{last ? ` · ${last.passed ? "通过" : "失败"}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="ops-input ops-input-sm !w-28"
|
||||||
|
value={sc?.intervalMinutes ?? 30}
|
||||||
|
disabled={!sc}
|
||||||
|
onChange={(e) => upsertSchedule(tc.id, { intervalMinutes: Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
{INTERVALS.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{intervalLabel(m)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{sc ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={cx("ops-btn", sc.enabled ? "ops-btn-secondary" : "ops-btn-primary", "!px-2")}
|
||||||
|
title={sc.enabled ? "暂停" : "启用"}
|
||||||
|
onClick={() => upsertSchedule(tc.id, { enabled: !sc.enabled })}
|
||||||
|
>
|
||||||
|
<Icon name={sc.enabled ? "circle-x" : "play"} size={13} />
|
||||||
|
</button>
|
||||||
|
<button className="ops-btn ops-btn-ghost ops-danger px-1.5" title="删除定时" onClick={() => removeSchedule(tc.id)}>
|
||||||
|
<Icon name="trash" size={13} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="ops-btn ops-btn-secondary !px-2" title="添加定时" onClick={() => upsertSchedule(tc.id, {})}>
|
||||||
|
<Icon name="plus" size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { RequestKind } from "@opsxapi/core";
|
import type { RequestKind } from "@opsxapi/core";
|
||||||
import { generateMock, KIND_LABELS, MOCK_CHEATSHEET, walk } from "@opsxapi/core";
|
import { generateMock, KIND_LABELS, MOCK_CHEATSHEET, walk } from "@opsxapi/core";
|
||||||
|
import { AI_PROVIDER_PRESETS, newAiProvider, testProviderConnection } from "../../ai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useWorkspace } from "../../workspaceStore";
|
import { useWorkspace } from "../../workspaceStore";
|
||||||
import { KVEditor, Modal, cx } from "../common";
|
import { KVEditor, Modal, cx } from "../common";
|
||||||
@@ -367,12 +368,13 @@ export function EnvironmentsDialog() {
|
|||||||
|
|
||||||
/* ---------------- 设置弹窗(左侧导航 + 分区) ---------------- */
|
/* ---------------- 设置弹窗(左侧导航 + 分区) ---------------- */
|
||||||
|
|
||||||
type SettingsTab = "appearance" | "network" | "mock" | "data" | "about";
|
type SettingsTab = "appearance" | "network" | "mock" | "ai" | "data" | "about";
|
||||||
|
|
||||||
const SETTINGS_TABS: { id: SettingsTab; label: string; desc: string; icon: IconName }[] = [
|
const SETTINGS_TABS: { id: SettingsTab; label: string; desc: string; icon: IconName }[] = [
|
||||||
{ id: "appearance", label: "外观", desc: "主题与界面", icon: "palette" },
|
{ id: "appearance", label: "外观", desc: "主题与界面", icon: "palette" },
|
||||||
{ id: "network", label: "网络", desc: "请求与超时", icon: "network" },
|
{ id: "network", label: "网络", desc: "请求与超时", icon: "network" },
|
||||||
{ id: "mock", label: "Mock 数据", desc: "智能匹配规则", icon: "flask-conical" },
|
{ id: "mock", label: "Mock 数据", desc: "智能匹配规则", icon: "flask-conical" },
|
||||||
|
{ id: "ai", label: "AI 助手", desc: "模型供应商", icon: "bot" },
|
||||||
{ id: "data", label: "数据管理", desc: "工作区与备份", icon: "database" },
|
{ id: "data", label: "数据管理", desc: "工作区与备份", icon: "database" },
|
||||||
{ id: "about", label: "关于", desc: "版本与链接", icon: "info" },
|
{ id: "about", label: "关于", desc: "版本与链接", icon: "info" },
|
||||||
];
|
];
|
||||||
@@ -430,6 +432,10 @@ export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
|
|||||||
const addMockRule = useWorkspace((s) => s.addMockRule);
|
const addMockRule = useWorkspace((s) => s.addMockRule);
|
||||||
const removeMockRule = useWorkspace((s) => s.removeMockRule);
|
const removeMockRule = useWorkspace((s) => s.removeMockRule);
|
||||||
const resetMockRules = useWorkspace((s) => s.resetMockRules);
|
const resetMockRules = useWorkspace((s) => s.resetMockRules);
|
||||||
|
const addAiProvider = useWorkspace((s) => s.addAiProvider);
|
||||||
|
const updateAiProvider = useWorkspace((s) => s.updateAiProvider);
|
||||||
|
const removeAiProvider = useWorkspace((s) => s.removeAiProvider);
|
||||||
|
const setActiveAiProvider = useWorkspace((s) => s.setActiveAiProvider);
|
||||||
const resetWorkspace = useWorkspace((s) => s.resetWorkspace);
|
const resetWorkspace = useWorkspace((s) => s.resetWorkspace);
|
||||||
const setDialog = useWorkspace((s) => s.setDialog);
|
const setDialog = useWorkspace((s) => s.setDialog);
|
||||||
|
|
||||||
@@ -719,6 +725,8 @@ export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{active === "ai" && <AiSettingsPane />}
|
||||||
|
|
||||||
{active === "data" && (
|
{active === "data" && (
|
||||||
<>
|
<>
|
||||||
<SettingGroup title="工作区数据">
|
<SettingGroup title="工作区数据">
|
||||||
@@ -812,3 +820,158 @@ export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- AI 助手设置 ---------------- */
|
||||||
|
|
||||||
|
function AiSettingsPane() {
|
||||||
|
const ai = useWorkspace((s) => s.settings.ai);
|
||||||
|
const addAiProvider = useWorkspace((s) => s.addAiProvider);
|
||||||
|
const updateAiProvider = useWorkspace((s) => s.updateAiProvider);
|
||||||
|
const removeAiProvider = useWorkspace((s) => s.removeAiProvider);
|
||||||
|
const setActiveAiProvider = useWorkspace((s) => s.setActiveAiProvider);
|
||||||
|
const [preset, setPreset] = useState(0);
|
||||||
|
const [testState, setTestState] = useState<Record<string, "idle" | "busy" | "ok" | "err">>({});
|
||||||
|
const [testMsg, setTestMsg] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const runTest = async (id: string) => {
|
||||||
|
const provider = ai.providers.find((pr) => pr.id === id);
|
||||||
|
if (!provider) return;
|
||||||
|
setTestState((m) => ({ ...m, [id]: "busy" }));
|
||||||
|
setTestMsg((m) => ({ ...m, [id]: "" }));
|
||||||
|
try {
|
||||||
|
const reply = await testProviderConnection(provider);
|
||||||
|
setTestState((m) => ({ ...m, [id]: "ok" }));
|
||||||
|
setTestMsg((m) => ({ ...m, [id]: reply }));
|
||||||
|
} catch (e) {
|
||||||
|
setTestState((m) => ({ ...m, [id]: "err" }));
|
||||||
|
setTestMsg((m) => ({ ...m, [id]: e instanceof Error ? e.message : String(e) }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingGroup title="模型供应商">
|
||||||
|
<div className="ops-hint">
|
||||||
|
<Icon name="info" size={14} />
|
||||||
|
<span>
|
||||||
|
采用 <b>OpenAI 兼容协议</b>(DeepSeek / 智谱 / Kimi / Ollama 本地…),密钥仅保存在本机。
|
||||||
|
<a href="/docs/ai" target="_blank" rel="noopener noreferrer" className="ops-link ml-1">
|
||||||
|
配置说明 →
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 px-4 pb-3">
|
||||||
|
<select
|
||||||
|
className="ops-input ops-input-sm w-52"
|
||||||
|
value={preset}
|
||||||
|
onChange={(e) => setPreset(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{AI_PROVIDER_PRESETS.map((p, i) => (
|
||||||
|
<option key={p.name} value={i}>
|
||||||
|
{p.name}
|
||||||
|
{p.hint ? `(${p.hint})` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-secondary"
|
||||||
|
onClick={() => addAiProvider(newAiProvider(AI_PROVIDER_PRESETS[preset]))}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size={13} /> 添加供应商
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ai.providers.length === 0 && (
|
||||||
|
<div className="ops-faint px-4 pb-4 text-center text-xs leading-5">
|
||||||
|
请先添加模型供应商,添加后即可在「自动化测试」中使用 AI 生成场景用例
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3 px-4 pb-4">
|
||||||
|
{ai.providers.map((pr) => {
|
||||||
|
const st = testState[pr.id] ?? "idle";
|
||||||
|
const isActive = ai.activeProviderId === pr.id;
|
||||||
|
return (
|
||||||
|
<div key={pr.id} className="rounded-xl p-3" style={{ border: "1px solid var(--border)" }}>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="ops-input ops-input-sm w-40 font-semibold"
|
||||||
|
value={pr.name}
|
||||||
|
onChange={(e) => updateAiProvider(pr.id, { name: e.target.value })}
|
||||||
|
placeholder="名称"
|
||||||
|
/>
|
||||||
|
<span className={cx("ops-chip", isActive && "ops-chip-active")} style={{ padding: "1px 8px", fontSize: 10 }}>
|
||||||
|
{isActive ? "当前使用" : "未选用"}
|
||||||
|
</span>
|
||||||
|
{!isActive && (
|
||||||
|
<button className="ops-btn ops-btn-ghost !py-0.5 text-[11px]" onClick={() => setActiveAiProvider(pr.id)}>
|
||||||
|
设为当前
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-ghost !py-0.5 text-[11px]"
|
||||||
|
disabled={st === "busy"}
|
||||||
|
onClick={() => runTest(pr.id)}
|
||||||
|
>
|
||||||
|
{st === "busy" ? "测试中…" : "测试连接"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-ghost ops-danger ml-auto px-1.5"
|
||||||
|
title="删除供应商"
|
||||||
|
onClick={() => removeAiProvider(pr.id)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="ops-faint text-[10px]">Base URL</label>
|
||||||
|
<input
|
||||||
|
className="ops-input ops-input-sm w-full font-mono"
|
||||||
|
value={pr.baseUrl}
|
||||||
|
onChange={(e) => updateAiProvider(pr.id, { baseUrl: e.target.value })}
|
||||||
|
placeholder="https://api.deepseek.com/v1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="ops-faint text-[10px]">模型</label>
|
||||||
|
<input
|
||||||
|
className="ops-input ops-input-sm w-full font-mono"
|
||||||
|
value={pr.model}
|
||||||
|
onChange={(e) => updateAiProvider(pr.id, { model: e.target.value })}
|
||||||
|
placeholder="deepseek-chat"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="ops-faint text-[10px]">API Key(本机保存,Ollama 可留空)</label>
|
||||||
|
<input
|
||||||
|
className="ops-input ops-input-sm w-full font-mono"
|
||||||
|
type="password"
|
||||||
|
value={pr.apiKey}
|
||||||
|
onChange={(e) => updateAiProvider(pr.id, { apiKey: e.target.value })}
|
||||||
|
placeholder="sk-…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{st === "ok" && <div className="ops-success mt-2 text-[11px]">连接成功:{testMsg[pr.id]}</div>}
|
||||||
|
{st === "err" && <div className="ops-danger mt-2 break-all text-[11px]">连接失败:{testMsg[pr.id]}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SettingGroup>
|
||||||
|
|
||||||
|
<SettingGroup title="AI 能力">
|
||||||
|
<SettingRow label="AI 生成场景用例" desc="自动化测试 → AI 生成:按集合接口清单编排步骤、断言与变量提取">
|
||||||
|
<span className={cx("ops-chip", ai.providers.length > 0 && "ops-chip-active")} style={{ padding: "2px 10px" }}>
|
||||||
|
{ai.providers.length > 0 ? "已就绪" : "待配置"}
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="AI 批量生成测试数据" desc="Body 智能填充已内置离线规则;接入模型后可描述需求生成整段 JSON 测试数据(规划中)">
|
||||||
|
<span className="ops-faint text-[11px]">规划中</span>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,14 @@ export type IconName =
|
|||||||
| "monitor"
|
| "monitor"
|
||||||
| "external-link"
|
| "external-link"
|
||||||
| "sparkles"
|
| "sparkles"
|
||||||
|
| "circle-check"
|
||||||
|
| "circle-x"
|
||||||
|
| "timer"
|
||||||
|
| "list-checks"
|
||||||
|
| "calendar-clock"
|
||||||
|
| "bot"
|
||||||
|
| "rotate-cw"
|
||||||
|
| "file-down"
|
||||||
| "plus"
|
| "plus"
|
||||||
| "download"
|
| "download"
|
||||||
| "upload"
|
| "upload"
|
||||||
@@ -52,6 +60,14 @@ export type IconName =
|
|||||||
| "info";
|
| "info";
|
||||||
|
|
||||||
const ICONS: Record<IconName, string> = {
|
const ICONS: Record<IconName, string> = {
|
||||||
|
"circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m16 9-5.5 5.5L8 12\" />",
|
||||||
|
"circle-x": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m15 9-6 6\" /> <path d=\"m9 9 6 6\" />",
|
||||||
|
"timer": "<line x1=\"10\" x2=\"14\" y1=\"2\" y2=\"2\" /> <line x1=\"12\" x2=\"15\" y1=\"14\" y2=\"11\" /> <circle cx=\"12\" cy=\"14\" r=\"8\" />",
|
||||||
|
"list-checks": "<path d=\"M13 5h8\" /> <path d=\"M13 12h8\" /> <path d=\"M13 19h8\" /> <path d=\"m3 17 2 2 4-4\" /> <path d=\"m3 7 2 2 4-4\" />",
|
||||||
|
"calendar-clock": "<path d=\"M16 14v2.2l1.6 1\" /> <path d=\"M16 2v3\" /> <path d=\"M21 7.338V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2h2.338\" /> <path d=\"M3 9h5.859\" /> <path d=\"M8 2v3\" /> <circle cx=\"16\" cy=\"16\" r=\"6\" />",
|
||||||
|
"bot": "<path d=\"M12 8V4H8\" /> <rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\" /> <path d=\"M2 14h2\" /> <path d=\"M20 14h2\" /> <path d=\"M15 13v2\" /> <path d=\"M9 13v2\" />",
|
||||||
|
"rotate-cw": "<path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\" /> <path d=\"M21 3v5h-5\" />",
|
||||||
|
"file-down": "<path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\" /> <path d=\"M14 2v5a1 1 0 0 0 1 1h5\" /> <path d=\"M12 18v-6\" /> <path d=\"m9 15 3 3 3-3\" />",
|
||||||
"palette": "<path d=\"M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z\" /> <circle cx=\"13.5\" cy=\"6.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"17.5\" cy=\"10.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"6.5\" cy=\"12.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"8.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\" />",
|
"palette": "<path d=\"M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z\" /> <circle cx=\"13.5\" cy=\"6.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"17.5\" cy=\"10.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"6.5\" cy=\"12.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"8.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\" />",
|
||||||
"network": "<rect x=\"16\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\" /> <rect x=\"2\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\" /> <rect x=\"9\" y=\"2\" width=\"6\" height=\"6\" rx=\"1\" /> <path d=\"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3\" /> <path d=\"M12 12V8\" />",
|
"network": "<rect x=\"16\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\" /> <rect x=\"2\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\" /> <rect x=\"9\" y=\"2\" width=\"6\" height=\"6\" rx=\"1\" /> <path d=\"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3\" /> <path d=\"M12 12V8\" />",
|
||||||
"flask-conical": "<path d=\"M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2\" /> <path d=\"M6.453 15h11.094\" /> <path d=\"M8.5 2h7\" />",
|
"flask-conical": "<path d=\"M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2\" /> <path d=\"M6.453 15h11.094\" /> <path d=\"M8.5 2h7\" />",
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 定时任务调度器:应用运行期间每 30s 巡检一次,
|
||||||
|
* 对启用且到达间隔的用例自动执行并记录报告(trigger = schedule)。
|
||||||
|
*/
|
||||||
|
import { runTestCase } from "./testRunner";
|
||||||
|
import { useWorkspace } from "./workspaceStore";
|
||||||
|
|
||||||
|
const TICK_MS = 30_000;
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let running = new Set<string>();
|
||||||
|
|
||||||
|
function isDue(intervalMinutes: number, lastRunAt: number | null): boolean {
|
||||||
|
const intervalMs = Math.max(1, intervalMinutes) * 60_000;
|
||||||
|
return lastRunAt === null || Date.now() - lastRunAt >= intervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(): void {
|
||||||
|
const { schedules } = useWorkspace.getState();
|
||||||
|
for (const s of schedules) {
|
||||||
|
if (!s.enabled) continue;
|
||||||
|
if (!isDue(s.intervalMinutes, s.lastRunAt)) continue;
|
||||||
|
if (running.has(s.caseId)) continue;
|
||||||
|
running = new Set([...running, s.caseId]);
|
||||||
|
void runTestCase(s.caseId, "schedule")
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
running = new Set([...running].filter((id) => id !== s.caseId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startScheduler(): void {
|
||||||
|
if (timer) return;
|
||||||
|
timer = setInterval(tick, TICK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopScheduler(): void {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供 UI 显示:下次预计运行时间(ms 时间戳) */
|
||||||
|
export function nextRunAt(intervalMinutes: number, lastRunAt: number | null): number {
|
||||||
|
const intervalMs = Math.max(1, intervalMinutes) * 60_000;
|
||||||
|
return (lastRunAt ?? Date.now() - intervalMs) + intervalMs;
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* 用例执行器:串行执行步骤 → 断言 → 变量提取 → 生成报告。
|
||||||
|
* 与手动发送共用 buildHttpSpec(含环境变量与 Mock)。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
CollectionNode,
|
||||||
|
HttpSpec,
|
||||||
|
RequestNode,
|
||||||
|
TestRun,
|
||||||
|
TestRunTrigger,
|
||||||
|
TestStep,
|
||||||
|
TestStepResult,
|
||||||
|
} from "@opsxapi/core";
|
||||||
|
import { evaluateAssertion, extractVariables } from "@opsxapi/core";
|
||||||
|
import { buildHttpSpec } from "./send";
|
||||||
|
import { activeEnvMap, useWorkspace } from "./workspaceStore";
|
||||||
|
|
||||||
|
async function getTransportFn() {
|
||||||
|
const { detectTransport } = await import("@opsxapi/transport");
|
||||||
|
const { transport } = detectTransport({ ginBaseUrl: "" });
|
||||||
|
return transport.http.bind(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepLabel(step: TestStep, node: RequestNode | undefined): string {
|
||||||
|
return node?.name ?? "(接口已被删除)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行单个用例并写入运行记录;返回本次报告 */
|
||||||
|
export async function runTestCase(caseId: string, trigger: TestRunTrigger): Promise<TestRun | null> {
|
||||||
|
const tc = useWorkspace.getState().testCases.find((c) => c.id === caseId);
|
||||||
|
if (!tc) return null;
|
||||||
|
|
||||||
|
const http = await getTransportFn();
|
||||||
|
const started = performance.now();
|
||||||
|
const stepResults: TestStepResult[] = [];
|
||||||
|
|
||||||
|
for (const step of tc.steps) {
|
||||||
|
if (!step.enabled) continue;
|
||||||
|
const node = findRequestNode(step.nodeId);
|
||||||
|
if (!node || node.kind !== "http") {
|
||||||
|
stepResults.push({
|
||||||
|
stepId: step.id,
|
||||||
|
name: stepLabel(step, node),
|
||||||
|
method: "—",
|
||||||
|
url: "—",
|
||||||
|
ok: false,
|
||||||
|
httpStatus: null,
|
||||||
|
durationMs: null,
|
||||||
|
error: node ? "仅支持 HTTP 步骤" : "接口不存在或已被删除",
|
||||||
|
assertionResults: [],
|
||||||
|
});
|
||||||
|
if (!tc.continueOnFailure) break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = activeEnvMap();
|
||||||
|
const { timeoutMs, mock } = useWorkspace.getState().settings;
|
||||||
|
const spec: HttpSpec = buildHttpSpec(node.data, env, timeoutMs, mock);
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
const res = await http(spec);
|
||||||
|
const durationMs = Math.round(performance.now() - t0);
|
||||||
|
const stepRes: TestStepResult = {
|
||||||
|
stepId: step.id,
|
||||||
|
name: node.name,
|
||||||
|
method: spec.method,
|
||||||
|
url: spec.url,
|
||||||
|
ok: true,
|
||||||
|
httpStatus: res.status,
|
||||||
|
durationMs,
|
||||||
|
error: null,
|
||||||
|
assertionResults: step.assertions
|
||||||
|
.filter((a) => a.enabled)
|
||||||
|
.map((a) => evaluateAssertion(res, a)),
|
||||||
|
};
|
||||||
|
stepRes.ok = stepRes.assertionResults.every((a) => a.passed);
|
||||||
|
|
||||||
|
// 变量提取 → 写入当前环境
|
||||||
|
const extracted = extractVariables(res, step.extracts);
|
||||||
|
if (Object.keys(extracted).length) {
|
||||||
|
useWorkspace.getState().setEnvValues(extracted);
|
||||||
|
}
|
||||||
|
stepResults.push(stepRes);
|
||||||
|
if (!stepRes.ok && !tc.continueOnFailure) break;
|
||||||
|
} catch (e) {
|
||||||
|
stepResults.push({
|
||||||
|
stepId: step.id,
|
||||||
|
name: node.name,
|
||||||
|
method: spec.method,
|
||||||
|
url: spec.url,
|
||||||
|
ok: false,
|
||||||
|
httpStatus: null,
|
||||||
|
durationMs: Math.round(performance.now() - t0),
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
assertionResults: [],
|
||||||
|
});
|
||||||
|
if (!tc.continueOnFailure) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const run: TestRun = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
caseId: tc.id,
|
||||||
|
caseName: tc.name,
|
||||||
|
at: Date.now(),
|
||||||
|
trigger,
|
||||||
|
totalMs: Math.round(performance.now() - started),
|
||||||
|
passed: stepResults.length > 0 && stepResults.every((s) => s.ok),
|
||||||
|
stepResults,
|
||||||
|
};
|
||||||
|
useWorkspace.getState().addTestRun(run);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRequestNode(id: string): RequestNode | undefined {
|
||||||
|
const { collections } = useWorkspace.getState();
|
||||||
|
const walk = (nodes: CollectionNode[]): RequestNode | undefined => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.type === "request" && n.id === id) return n;
|
||||||
|
if (n.type === "folder") {
|
||||||
|
const hit = walk(n.children);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
for (const c of collections) {
|
||||||
|
const hit = walk(c.children);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
* 持久化:localStorage(debounce),实时会话等运行时数据不落盘。
|
* 持久化:localStorage(debounce),实时会话等运行时数据不落盘。
|
||||||
*/
|
*/
|
||||||
import type {
|
import type {
|
||||||
|
AiProvider,
|
||||||
Collection,
|
Collection,
|
||||||
CollectionNode,
|
CollectionNode,
|
||||||
Environment,
|
Environment,
|
||||||
@@ -12,6 +13,9 @@ import type {
|
|||||||
MockRule,
|
MockRule,
|
||||||
RequestKind,
|
RequestKind,
|
||||||
RequestNode,
|
RequestNode,
|
||||||
|
TestCase,
|
||||||
|
TestRun,
|
||||||
|
TestSchedule,
|
||||||
ThemeMode,
|
ThemeMode,
|
||||||
WorkspaceData,
|
WorkspaceData,
|
||||||
WorkspaceSettings,
|
WorkspaceSettings,
|
||||||
@@ -27,6 +31,7 @@ import {
|
|||||||
kv,
|
kv,
|
||||||
moveNode,
|
moveNode,
|
||||||
newId,
|
newId,
|
||||||
|
newTestCase,
|
||||||
normalizeMockSettings,
|
normalizeMockSettings,
|
||||||
uniqueName,
|
uniqueName,
|
||||||
} from "@opsxapi/core";
|
} from "@opsxapi/core";
|
||||||
@@ -68,7 +73,7 @@ export type Dialog =
|
|||||||
| { type: "import" }
|
| { type: "import" }
|
||||||
| { type: "export"; collectionId: string }
|
| { type: "export"; collectionId: string }
|
||||||
| { type: "environments" }
|
| { type: "environments" }
|
||||||
| { type: "settings"; tab?: "appearance" | "network" | "mock" | "data" | "about" }
|
| { type: "settings"; tab?: "appearance" | "network" | "mock" | "ai" | "data" | "about" }
|
||||||
| { type: "rename"; id: string }
|
| { type: "rename"; id: string }
|
||||||
| { type: "confirm"; title: string; message: string; action: () => void };
|
| { type: "confirm"; title: string; message: string; action: () => void };
|
||||||
|
|
||||||
@@ -88,7 +93,14 @@ export function emptyRuntime(): RequestRuntime {
|
|||||||
|
|
||||||
type PersistedShape = Pick<
|
type PersistedShape = Pick<
|
||||||
WorkspaceData,
|
WorkspaceData,
|
||||||
"collections" | "environments" | "activeEnvironmentId" | "history" | "settings"
|
| "collections"
|
||||||
|
| "environments"
|
||||||
|
| "activeEnvironmentId"
|
||||||
|
| "history"
|
||||||
|
| "settings"
|
||||||
|
| "testCases"
|
||||||
|
| "testRuns"
|
||||||
|
| "schedules"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type WorkspaceStore = PersistedShape & {
|
type WorkspaceStore = PersistedShape & {
|
||||||
@@ -98,8 +110,11 @@ type WorkspaceStore = PersistedShape & {
|
|||||||
expanded: Record<string, boolean>;
|
expanded: Record<string, boolean>;
|
||||||
dialog: Dialog | null;
|
dialog: Dialog | null;
|
||||||
runtime: Record<string, RequestRuntime>;
|
runtime: Record<string, RequestRuntime>;
|
||||||
|
/** 主视图:接口工作台 / 自动化测试 */
|
||||||
|
view: "api" | "testing";
|
||||||
|
|
||||||
init(): void;
|
init(): void;
|
||||||
|
setView(view: "api" | "testing"): void;
|
||||||
|
|
||||||
addCollection(name?: string): string;
|
addCollection(name?: string): string;
|
||||||
renameNode(id: string, name: string): void;
|
renameNode(id: string, name: string): void;
|
||||||
@@ -139,6 +154,20 @@ type WorkspaceStore = PersistedShape & {
|
|||||||
removeMockRule(ruleId: string): void;
|
removeMockRule(ruleId: string): void;
|
||||||
resetMockRules(): void;
|
resetMockRules(): void;
|
||||||
|
|
||||||
|
setEnvValues(values: Record<string, string>): void;
|
||||||
|
addAiProvider(provider: AiProvider): void;
|
||||||
|
updateAiProvider(id: string, patch: Partial<AiProvider>): void;
|
||||||
|
removeAiProvider(id: string): void;
|
||||||
|
setActiveAiProvider(id: string | null): void;
|
||||||
|
|
||||||
|
addTestCase(): string;
|
||||||
|
updateTestCase(id: string, patch: Partial<Omit<TestCase, "id">>): void;
|
||||||
|
removeTestCase(id: string): void;
|
||||||
|
addTestRun(run: TestRun): void;
|
||||||
|
clearTestRuns(): void;
|
||||||
|
upsertSchedule(caseId: string, patch: Partial<Omit<TestSchedule, "caseId">>): void;
|
||||||
|
removeSchedule(caseId: string): void;
|
||||||
|
|
||||||
setRuntime(id: string, patch: Partial<RequestRuntime>): void;
|
setRuntime(id: string, patch: Partial<RequestRuntime>): void;
|
||||||
pushLive(id: string, dir: LiveMessage["dir"], text: string, topic?: string): void;
|
pushLive(id: string, dir: LiveMessage["dir"], text: string, topic?: string): void;
|
||||||
clearLive(id: string): void;
|
clearLive(id: string): void;
|
||||||
@@ -178,6 +207,28 @@ function sanitizeCollections(collections: Collection[]): Collection[] {
|
|||||||
return collections.map((c) => ({ ...c, children: fix(c.children) }));
|
return collections.map((c) => ({ ...c, children: fix(c.children) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AI 设置归一化:坏字段修复 + 默认空列表 */
|
||||||
|
function normalizeAiSettings(raw: unknown): { providers: AiProvider[]; activeProviderId: string | null } {
|
||||||
|
const source = raw && typeof raw === "object" ? (raw as { providers?: unknown; activeProviderId?: unknown }) : {};
|
||||||
|
const providers = Array.isArray(source.providers)
|
||||||
|
? source.providers
|
||||||
|
.filter((pr): pr is AiProvider => !!pr && typeof pr === "object")
|
||||||
|
.map((pr, i) => ({
|
||||||
|
id: typeof pr.id === "string" && pr.id ? pr.id : `ai-${i}`,
|
||||||
|
name: typeof pr.name === "string" ? pr.name : "未命名供应商",
|
||||||
|
baseUrl: typeof pr.baseUrl === "string" ? pr.baseUrl : "",
|
||||||
|
apiKey: typeof pr.apiKey === "string" ? pr.apiKey : "",
|
||||||
|
model: typeof pr.model === "string" ? pr.model : "",
|
||||||
|
enabled: pr.enabled !== false,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const activeProviderId =
|
||||||
|
typeof source.activeProviderId === "string" && providers.some((pr) => pr.id === source.activeProviderId)
|
||||||
|
? source.activeProviderId
|
||||||
|
: (providers[0]?.id ?? null);
|
||||||
|
return { providers, activeProviderId };
|
||||||
|
}
|
||||||
|
|
||||||
function loadPersisted(): WorkspaceData | null {
|
function loadPersisted(): WorkspaceData | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -194,7 +245,11 @@ function loadPersisted(): WorkspaceData | null {
|
|||||||
theme: data.settings?.theme === "light" ? "light" : "dark",
|
theme: data.settings?.theme === "light" ? "light" : "dark",
|
||||||
timeoutMs: data.settings?.timeoutMs ?? 30000,
|
timeoutMs: data.settings?.timeoutMs ?? 30000,
|
||||||
mock: normalizeMockSettings(data.settings?.mock),
|
mock: normalizeMockSettings(data.settings?.mock),
|
||||||
|
ai: normalizeAiSettings(data.settings?.ai),
|
||||||
},
|
},
|
||||||
|
testCases: Array.isArray(data.testCases) ? data.testCases : [],
|
||||||
|
testRuns: Array.isArray(data.testRuns) ? data.testRuns.slice(0, 50) : [],
|
||||||
|
schedules: Array.isArray(data.schedules) ? data.schedules : [],
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -214,6 +269,9 @@ function schedulePersist(state: WorkspaceStore): void {
|
|||||||
activeEnvironmentId: state.activeEnvironmentId,
|
activeEnvironmentId: state.activeEnvironmentId,
|
||||||
history: state.history.slice(0, 100),
|
history: state.history.slice(0, 100),
|
||||||
settings: state.settings,
|
settings: state.settings,
|
||||||
|
testCases: state.testCases,
|
||||||
|
testRuns: state.testRuns.slice(0, 50),
|
||||||
|
schedules: state.schedules,
|
||||||
};
|
};
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -235,7 +293,11 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
|||||||
environments: [],
|
environments: [],
|
||||||
activeEnvironmentId: null,
|
activeEnvironmentId: null,
|
||||||
history: [],
|
history: [],
|
||||||
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null) },
|
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null), ai: { providers: [], activeProviderId: null } },
|
||||||
|
testCases: [],
|
||||||
|
testRuns: [],
|
||||||
|
schedules: [],
|
||||||
|
view: "api",
|
||||||
|
|
||||||
openTabIds: [],
|
openTabIds: [],
|
||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
@@ -251,6 +313,9 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
|||||||
activeEnvironmentId: data.activeEnvironmentId,
|
activeEnvironmentId: data.activeEnvironmentId,
|
||||||
history: data.history,
|
history: data.history,
|
||||||
settings: data.settings,
|
settings: data.settings,
|
||||||
|
testCases: data.testCases,
|
||||||
|
testRuns: data.testRuns,
|
||||||
|
schedules: data.schedules,
|
||||||
});
|
});
|
||||||
applyThemeToDocument(data.settings.theme);
|
applyThemeToDocument(data.settings.theme);
|
||||||
// 展开全部目录
|
// 展开全部目录
|
||||||
@@ -409,6 +474,9 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
|||||||
activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null,
|
activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null,
|
||||||
history: data.history ?? [],
|
history: data.history ?? [],
|
||||||
settings: data.settings,
|
settings: data.settings,
|
||||||
|
testCases: data.testCases ?? [],
|
||||||
|
testRuns: data.testRuns ?? [],
|
||||||
|
schedules: data.schedules ?? [],
|
||||||
openTabIds: [],
|
openTabIds: [],
|
||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
});
|
});
|
||||||
@@ -416,6 +484,129 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setView(view) {
|
||||||
|
set({ view });
|
||||||
|
},
|
||||||
|
|
||||||
|
setEnvValues(values) {
|
||||||
|
const envId = get().activeEnvironmentId;
|
||||||
|
if (!envId) return;
|
||||||
|
set({
|
||||||
|
environments: get().environments.map((e) => {
|
||||||
|
if (e.id !== envId) return e;
|
||||||
|
const vars = [...e.variables];
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
const existing = vars.find((v) => v.key === key);
|
||||||
|
if (existing) existing.value = value;
|
||||||
|
else vars.push(kv(key, value));
|
||||||
|
}
|
||||||
|
return { ...e, variables: vars };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
addAiProvider(provider) {
|
||||||
|
const ai = get().settings.ai;
|
||||||
|
set({
|
||||||
|
settings: {
|
||||||
|
...get().settings,
|
||||||
|
ai: {
|
||||||
|
providers: [...ai.providers, provider],
|
||||||
|
activeProviderId: ai.activeProviderId ?? provider.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAiProvider(id, patch) {
|
||||||
|
set({
|
||||||
|
settings: {
|
||||||
|
...get().settings,
|
||||||
|
ai: {
|
||||||
|
...get().settings.ai,
|
||||||
|
providers: get().settings.ai.providers.map((pr) => (pr.id === id ? { ...pr, ...patch } : pr)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAiProvider(id) {
|
||||||
|
const ai = get().settings.ai;
|
||||||
|
const providers = ai.providers.filter((pr) => pr.id !== id);
|
||||||
|
set({
|
||||||
|
settings: {
|
||||||
|
...get().settings,
|
||||||
|
ai: { providers, activeProviderId: ai.activeProviderId === id ? (providers[0]?.id ?? null) : ai.activeProviderId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveAiProvider(id) {
|
||||||
|
set({ settings: { ...get().settings, ai: { ...get().settings.ai, activeProviderId: id } } });
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
addTestCase() {
|
||||||
|
const tc = newTestCase();
|
||||||
|
set({ testCases: [...get().testCases, tc] });
|
||||||
|
persist();
|
||||||
|
return tc.id;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTestCase(id, patch) {
|
||||||
|
set({
|
||||||
|
testCases: get().testCases.map((tc) =>
|
||||||
|
tc.id === id ? { ...tc, ...patch, updatedAt: Date.now() } : tc,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTestCase(id) {
|
||||||
|
set({
|
||||||
|
testCases: get().testCases.filter((tc) => tc.id !== id),
|
||||||
|
testRuns: get().testRuns.filter((r) => r.caseId !== id),
|
||||||
|
schedules: get().schedules.filter((sc) => sc.caseId !== id),
|
||||||
|
});
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
addTestRun(run) {
|
||||||
|
set({ testRuns: [run, ...get().testRuns].slice(0, 50) });
|
||||||
|
set({
|
||||||
|
schedules: get().schedules.map((sc) =>
|
||||||
|
sc.caseId === run.caseId ? { ...sc, lastRunAt: run.at } : sc,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
clearTestRuns() {
|
||||||
|
set({ testRuns: [] });
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
upsertSchedule(caseId, patch) {
|
||||||
|
const existing = get().schedules.find((sc) => sc.caseId === caseId);
|
||||||
|
if (existing) {
|
||||||
|
set({
|
||||||
|
schedules: get().schedules.map((sc) => (sc.caseId === caseId ? { ...sc, ...patch } : sc)),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({ schedules: [...get().schedules, { caseId, enabled: true, intervalMinutes: 30, lastRunAt: null, ...patch }] });
|
||||||
|
}
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSchedule(caseId) {
|
||||||
|
set({ schedules: get().schedules.filter((sc) => sc.caseId !== caseId) });
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
resetWorkspace() {
|
resetWorkspace() {
|
||||||
const seed = createSeedWorkspace();
|
const seed = createSeedWorkspace();
|
||||||
get().loadWorkspaceData(seed);
|
get().loadWorkspaceData(seed);
|
||||||
|
|||||||
Reference in new issue
Block a user