- TestSchedule 新增可选 5 段 cron(分 时 日 月 周,本地时区),优先于 intervalMinutes;runLog 调度日志 - cron.ts 解析与匹配引擎 + 单测;scheduler 按最近下次触发时间调度 - ScheduleDialog / TestingView 支持 cron 输入与下次运行提示;AiCaseDialog 微调 - 服务端 internal/echo 调试回显端点;修复 model.ts 注释内 */ 导致的语法错误 - 全量 53 前端用例绿
757 lines
31 KiB
TypeScript
757 lines
31 KiB
TypeScript
/**
|
|
* 自动化测试视图:场景用例列表 / 步骤编排(断言 + 变量提取)/ 运行报告 / 定时任务。
|
|
* 三种触发方式:手动运行(本视图)、AI 生成(AiCaseDialog)、定时调度(scheduler + 定时设置)。
|
|
*/
|
|
import type {
|
|
AssertOp,
|
|
AssertTarget,
|
|
Collection,
|
|
HttpRequestData,
|
|
RequestNode,
|
|
TestCase,
|
|
TestRun,
|
|
} from "@opsxapi/core";
|
|
import {
|
|
ASSERT_OP_LABELS,
|
|
createSeedWorkspace,
|
|
exportRunJson,
|
|
exportRunMarkdown,
|
|
isValidCron,
|
|
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]);
|
|
}
|
|
|
|
|
|
const REGRESSION_CASE_ID = "tc-opsx-http8-regression";
|
|
|
|
/** 装载 / 刷新本地 Gin echo 八组合回归用例(不整库清空) */
|
|
function installHttp8Regression(): string {
|
|
const seed = createSeedWorkspace();
|
|
const state = useWorkspace.getState();
|
|
const seedCase = seed.testCases.find((c) => c.id === REGRESSION_CASE_ID) ?? seed.testCases[0]!;
|
|
const localEnv = seed.environments.find((e) => e.name === "本地") ?? seed.environments[0];
|
|
const hasCase = state.testCases.some((c) => c.id === REGRESSION_CASE_ID);
|
|
const hasCollection = state.collections.some((c) => c.name === "示例集合");
|
|
const localExisting = state.environments.find((e) => e.name === "本地");
|
|
state.loadWorkspaceData({
|
|
version: 2,
|
|
collections: hasCollection
|
|
? state.collections.map((c) => (c.name === "示例集合" ? seed.collections[0]! : c))
|
|
: [...state.collections, ...seed.collections],
|
|
environments: localExisting
|
|
? state.environments
|
|
: [...state.environments, ...seed.environments.filter((e) => e.name === "本地")],
|
|
activeEnvironmentId: localExisting?.id ?? localEnv?.id ?? state.activeEnvironmentId,
|
|
history: state.history,
|
|
settings: state.settings,
|
|
testCases: hasCase
|
|
? state.testCases.map((c) => (c.id === REGRESSION_CASE_ID ? seedCase : c))
|
|
: [...state.testCases, seedCase],
|
|
testRuns: state.testRuns,
|
|
schedules: state.schedules,
|
|
});
|
|
return seedCase.id;
|
|
}
|
|
|
|
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>
|
|
<button
|
|
className="ops-btn ops-btn-secondary mt-1.5 w-full !px-2"
|
|
title="装载本地 Gin /v1/echo 八组合回归用例"
|
|
onClick={() => setSelected(installHttp8Regression())}
|
|
>
|
|
<Icon name="play" size={13} /> 装载回归示例
|
|
</button>
|
|
</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>
|
|
);
|
|
}
|
|
const cron = sc.cron ?? "";
|
|
const cronOk = !cron.trim() || isValidCron(cron.trim());
|
|
return (
|
|
<span className="flex flex-wrap items-center gap-1">
|
|
<input
|
|
className="ops-input ops-input-sm !w-36 font-mono text-[11px]"
|
|
placeholder="Cron 可选 */5 * * * *"
|
|
value={cron}
|
|
title={cronOk ? "5 段 Cron(分 时 日 月 周);留空则用间隔" : "Cron 格式无效"}
|
|
onChange={(e) => upsertSchedule(caseId, { cron: e.target.value.trim() || null })}
|
|
style={!cronOk ? { borderColor: "var(--danger)" } : undefined}
|
|
/>
|
|
<select
|
|
className="ops-input ops-input-sm !w-24"
|
|
value={sc.intervalMinutes}
|
|
disabled={Boolean(cron.trim())}
|
|
onChange={(e) => upsertSchedule(caseId, { intervalMinutes: Number(e.target.value), cron: null })}
|
|
title={
|
|
cron.trim()
|
|
? "已启用 Cron,间隔停用"
|
|
: `下次运行:${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt, sc.cron)).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 = (format: "md" | "json") => {
|
|
const body = format === "json" ? exportRunJson(run) : exportRunMarkdown(run);
|
|
const blob = new Blob([body], {
|
|
type: format === "json" ? "application/json;charset=utf-8" : "text/markdown;charset=utf-8",
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
const day = new Date(run.at).toISOString().slice(0, 10);
|
|
a.download = `${run.caseName}-报告-${day}.${format === "json" ? "json" : "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("md")}>
|
|
<Icon name="file-down" size={13} /> 导出 MD
|
|
</button>
|
|
<button className="ops-btn ops-btn-secondary" onClick={() => download("json")}>
|
|
<Icon name="file-down" size={13} /> 导出 JSON
|
|
</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>
|
|
);
|
|
}
|