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
@@ -8,6 +8,8 @@ import { Icon } from "./components/icons";
|
||||
import { Logo } from "./components/Logo";
|
||||
import { useWorkspace } from "./workspaceStore";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { TestingView } from "./components/TestingView";
|
||||
import { startScheduler, stopScheduler } from "./scheduler";
|
||||
import { TabBar } from "./components/TabBar";
|
||||
import { TopBar } from "./components/TopBar";
|
||||
import { GraphqlEditor } from "./components/editors/GraphqlEditor";
|
||||
@@ -32,6 +34,7 @@ export function OpsxWorkspace() {
|
||||
const activeTabId = useWorkspace((s) => s.activeTabId);
|
||||
const dialog = useWorkspace((s) => s.dialog);
|
||||
const openTab = useWorkspace((s) => s.openTab);
|
||||
const view = useWorkspace((s) => s.view);
|
||||
|
||||
useEffect(() => {
|
||||
init();
|
||||
@@ -45,12 +48,21 @@ export function OpsxWorkspace() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 定时任务调度器(应用运行期间常驻)
|
||||
useEffect(() => {
|
||||
startScheduler();
|
||||
return () => stopScheduler();
|
||||
}, []);
|
||||
|
||||
const activeRef = activeTabId ? findNode(collections, activeTabId) : null;
|
||||
const activeNode = activeRef?.node;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-h-[640px] flex-col overflow-hidden">
|
||||
<TopBar />
|
||||
{view === "testing" ? (
|
||||
<TestingView />
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<Sidebar />
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
@@ -74,6 +86,7 @@ export function OpsxWorkspace() {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{dialog?.type === "new" && <NewItemDialog collectionId={dialog.collectionId} parentId={dialog.parentId} />}
|
||||
{dialog?.type === "import" && <ImportDialog />}
|
||||
{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 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)} />
|
||||
</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} />,
|
||||
onClick: () => setDialog({ type: "settings", tab: "mock" }),
|
||||
},
|
||||
{
|
||||
label: "自动化测试",
|
||||
icon: <Icon name="list-checks" size={14} />,
|
||||
onClick: () => useWorkspace.getState().setView("testing"),
|
||||
},
|
||||
{
|
||||
label: "环境管理…",
|
||||
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 { generateMock, KIND_LABELS, MOCK_CHEATSHEET, walk } from "@opsxapi/core";
|
||||
import { AI_PROVIDER_PRESETS, newAiProvider, testProviderConnection } from "../../ai";
|
||||
import { useState } from "react";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
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 }[] = [
|
||||
{ id: "appearance", label: "外观", desc: "主题与界面", icon: "palette" },
|
||||
{ id: "network", label: "网络", desc: "请求与超时", icon: "network" },
|
||||
{ id: "mock", label: "Mock 数据", desc: "智能匹配规则", icon: "flask-conical" },
|
||||
{ id: "ai", label: "AI 助手", desc: "模型供应商", icon: "bot" },
|
||||
{ id: "data", label: "数据管理", desc: "工作区与备份", icon: "database" },
|
||||
{ id: "about", label: "关于", desc: "版本与链接", icon: "info" },
|
||||
];
|
||||
@@ -430,6 +432,10 @@ export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
|
||||
const addMockRule = useWorkspace((s) => s.addMockRule);
|
||||
const removeMockRule = useWorkspace((s) => s.removeMockRule);
|
||||
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 setDialog = useWorkspace((s) => s.setDialog);
|
||||
|
||||
@@ -719,6 +725,8 @@ export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{active === "ai" && <AiSettingsPane />}
|
||||
|
||||
{active === "data" && (
|
||||
<>
|
||||
<SettingGroup title="工作区数据">
|
||||
@@ -812,3 +820,158 @@ export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
|
||||
</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"
|
||||
| "external-link"
|
||||
| "sparkles"
|
||||
| "circle-check"
|
||||
| "circle-x"
|
||||
| "timer"
|
||||
| "list-checks"
|
||||
| "calendar-clock"
|
||||
| "bot"
|
||||
| "rotate-cw"
|
||||
| "file-down"
|
||||
| "plus"
|
||||
| "download"
|
||||
| "upload"
|
||||
@@ -52,6 +60,14 @@ export type IconName =
|
||||
| "info";
|
||||
|
||||
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\" />",
|
||||
"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\" />",
|
||||
|
||||
@@ -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),实时会话等运行时数据不落盘。
|
||||
*/
|
||||
import type {
|
||||
AiProvider,
|
||||
Collection,
|
||||
CollectionNode,
|
||||
Environment,
|
||||
@@ -12,6 +13,9 @@ import type {
|
||||
MockRule,
|
||||
RequestKind,
|
||||
RequestNode,
|
||||
TestCase,
|
||||
TestRun,
|
||||
TestSchedule,
|
||||
ThemeMode,
|
||||
WorkspaceData,
|
||||
WorkspaceSettings,
|
||||
@@ -27,6 +31,7 @@ import {
|
||||
kv,
|
||||
moveNode,
|
||||
newId,
|
||||
newTestCase,
|
||||
normalizeMockSettings,
|
||||
uniqueName,
|
||||
} from "@opsxapi/core";
|
||||
@@ -68,7 +73,7 @@ export type Dialog =
|
||||
| { type: "import" }
|
||||
| { type: "export"; collectionId: string }
|
||||
| { type: "environments" }
|
||||
| { type: "settings"; tab?: "appearance" | "network" | "mock" | "data" | "about" }
|
||||
| { type: "settings"; tab?: "appearance" | "network" | "mock" | "ai" | "data" | "about" }
|
||||
| { type: "rename"; id: string }
|
||||
| { type: "confirm"; title: string; message: string; action: () => void };
|
||||
|
||||
@@ -88,7 +93,14 @@ export function emptyRuntime(): RequestRuntime {
|
||||
|
||||
type PersistedShape = Pick<
|
||||
WorkspaceData,
|
||||
"collections" | "environments" | "activeEnvironmentId" | "history" | "settings"
|
||||
| "collections"
|
||||
| "environments"
|
||||
| "activeEnvironmentId"
|
||||
| "history"
|
||||
| "settings"
|
||||
| "testCases"
|
||||
| "testRuns"
|
||||
| "schedules"
|
||||
>;
|
||||
|
||||
type WorkspaceStore = PersistedShape & {
|
||||
@@ -98,8 +110,11 @@ type WorkspaceStore = PersistedShape & {
|
||||
expanded: Record<string, boolean>;
|
||||
dialog: Dialog | null;
|
||||
runtime: Record<string, RequestRuntime>;
|
||||
/** 主视图:接口工作台 / 自动化测试 */
|
||||
view: "api" | "testing";
|
||||
|
||||
init(): void;
|
||||
setView(view: "api" | "testing"): void;
|
||||
|
||||
addCollection(name?: string): string;
|
||||
renameNode(id: string, name: string): void;
|
||||
@@ -139,6 +154,20 @@ type WorkspaceStore = PersistedShape & {
|
||||
removeMockRule(ruleId: string): 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;
|
||||
pushLive(id: string, dir: LiveMessage["dir"], text: string, topic?: string): void;
|
||||
clearLive(id: string): void;
|
||||
@@ -178,6 +207,28 @@ function sanitizeCollections(collections: Collection[]): Collection[] {
|
||||
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 {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -194,7 +245,11 @@ function loadPersisted(): WorkspaceData | null {
|
||||
theme: data.settings?.theme === "light" ? "light" : "dark",
|
||||
timeoutMs: data.settings?.timeoutMs ?? 30000,
|
||||
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 {
|
||||
return null;
|
||||
@@ -214,6 +269,9 @@ function schedulePersist(state: WorkspaceStore): void {
|
||||
activeEnvironmentId: state.activeEnvironmentId,
|
||||
history: state.history.slice(0, 100),
|
||||
settings: state.settings,
|
||||
testCases: state.testCases,
|
||||
testRuns: state.testRuns.slice(0, 50),
|
||||
schedules: state.schedules,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch (e) {
|
||||
@@ -235,7 +293,11 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
environments: [],
|
||||
activeEnvironmentId: null,
|
||||
history: [],
|
||||
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null) },
|
||||
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null), ai: { providers: [], activeProviderId: null } },
|
||||
testCases: [],
|
||||
testRuns: [],
|
||||
schedules: [],
|
||||
view: "api",
|
||||
|
||||
openTabIds: [],
|
||||
activeTabId: null,
|
||||
@@ -251,6 +313,9 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
activeEnvironmentId: data.activeEnvironmentId,
|
||||
history: data.history,
|
||||
settings: data.settings,
|
||||
testCases: data.testCases,
|
||||
testRuns: data.testRuns,
|
||||
schedules: data.schedules,
|
||||
});
|
||||
applyThemeToDocument(data.settings.theme);
|
||||
// 展开全部目录
|
||||
@@ -409,6 +474,9 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null,
|
||||
history: data.history ?? [],
|
||||
settings: data.settings,
|
||||
testCases: data.testCases ?? [],
|
||||
testRuns: data.testRuns ?? [],
|
||||
schedules: data.schedules ?? [],
|
||||
openTabIds: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
@@ -416,6 +484,129 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
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() {
|
||||
const seed = createSeedWorkspace();
|
||||
get().loadWorkspaceData(seed);
|
||||
|
||||
Reference in new issue
Block a user