/** * 自动化测试视图:场景用例列表 / 步骤编排(断言 + 变量提取)/ 运行报告 / 定时任务。 * 三种触发方式:手动运行(本视图)、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 = { 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(testCases[0]?.id ?? null); const [showAi, setShowAi] = useState(false); const [showSchedule, setShowSchedule] = useState(false); const [runAllBusy, setRunAllBusy] = useState(false); const tc = testCases.find((c) => c.id === selected) ?? null; const lastRunOf = (caseId: string): TestRun | undefined => testRuns.find((r) => r.caseId === caseId); const runAll = async () => { setRunAllBusy(true); try { for (const c of testCases) await runTestCase(c.id, "manual"); } finally { setRunAllBusy(false); } }; return (
{/* 左栏:用例列表 */} {/* 右侧:用例详情 */} {tc ? ( ) : (
自动化测试
手动编排 · AI 生成 · 定时调度,三种方式共用同一套场景用例
左侧新建用例,或从「⋯ 菜单 → 自动化测试」随时返回
)} {showAi && setShowAi(false)} onSaved={(id) => setSelected(id)} />} {showSchedule && setShowSchedule(false)} />}
); } /* ---------------- 用例详情:编辑 + 报告 ---------------- */ function CaseDetail({ tc }: { tc: TestCase }) { const updateTestCase = useWorkspace((s) => s.updateTestCase); const removeTestCase = useWorkspace((s) => s.removeTestCase); const allRuns = useWorkspace((s) => s.testRuns); const runs = useMemo(() => allRuns.filter((r) => r.caseId === tc.id), [allRuns, tc.id]); const schedules = useWorkspace((s) => s.schedules); const nodeOptions = useHttpNodeOptions(); const [tab, setTab] = useState<"edit" | "report">("edit"); const [busy, setBusy] = useState(false); const sched = schedules.find((s) => s.caseId === tc.id); const patch = (p: Partial) => updateTestCase(tc.id, p); const patchStep = (stepId: string, p: Partial) => patch({ steps: tc.steps.map((st) => (st.id === stepId ? { ...st, ...p } : st)) }); const moveStep = (idx: number, dir: -1 | 1) => { const steps = [...tc.steps]; const j = idx + dir; if (j < 0 || j >= steps.length) return; [steps[idx], steps[j]] = [steps[j], steps[idx]]; patch({ steps }); }; const run = async () => { setBusy(true); setTab("report"); try { await runTestCase(tc.id, "manual"); } finally { setBusy(false); } }; return (
{/* 头部 */}
patch({ name: e.target.value })} placeholder="用例名称" />
{/* Tabs */}
{tab === "edit" ? (