From 21a25874a94f4520bffdad3e4c3667d4d4f4b3f2 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Sat, 8 Aug 2026 17:37:48 +0800 Subject: [PATCH] feat(frontend): implement batch testing views and status components Add components for batch testing, including BatchRunCompletedView and BatchRunRunningView, to display test results and progress. Introduce BatchCaseStatusBadge and BatchPhasePill for visual status representation. Implement mock data handling for batch run snapshots and case statuses, enhancing the user experience for managing batch tests. Co-authored-by: Cursor --- .../batch-test/batch-case-status.tsx | 84 ++ .../batch-test/batch-run-completed.tsx | 297 +++++++ .../batch-test/batch-run-running.tsx | 195 +++++ frontend/src/components/layout/Sidebar.tsx | 1 + .../components/layout/list-page-layout.tsx | 5 +- .../src/components/pages/BatchTestPage.tsx | 782 +++++++++++++++++- .../src/components/pages/TestCasesPage.tsx | 27 + .../components/test-cases/suite-case-list.tsx | 205 +++-- frontend/src/data/batch-run.ts | 115 +++ frontend/src/data/test-suites.ts | 62 ++ 10 files changed, 1679 insertions(+), 94 deletions(-) create mode 100644 frontend/src/components/batch-test/batch-case-status.tsx create mode 100644 frontend/src/components/batch-test/batch-run-completed.tsx create mode 100644 frontend/src/components/batch-test/batch-run-running.tsx create mode 100644 frontend/src/data/batch-run.ts diff --git a/frontend/src/components/batch-test/batch-case-status.tsx b/frontend/src/components/batch-test/batch-case-status.tsx new file mode 100644 index 0000000..b56cbde --- /dev/null +++ b/frontend/src/components/batch-test/batch-case-status.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { CheckCircle2, Circle, Loader2, XCircle } from "lucide-react"; + +import type { BatchCaseStatus } from "@/data/batch-run"; +import { cn } from "@/lib/utils"; + +const STATUS_META: Record< + BatchCaseStatus, + { label: string; className: string; Icon: typeof CheckCircle2 } +> = { + pass: { + label: "通过", + className: "text-success", + Icon: CheckCircle2, + }, + fail: { + label: "失败", + className: "text-destructive", + Icon: XCircle, + }, + running: { + label: "运行中", + className: "text-primary", + Icon: Loader2, + }, + waiting: { + label: "等待", + className: "text-muted-soft", + Icon: Circle, + }, + skipped: { + label: "未执行", + className: "text-muted-soft", + Icon: Circle, + }, +}; + +export function BatchCaseStatusBadge({ + status, + className, +}: { + status: BatchCaseStatus; + className?: string; +}) { + const meta = STATUS_META[status]; + const Icon = meta.Icon; + + return ( + + + {meta.label} + + ); +} + +export function BatchPhasePill({ + phase, +}: { + phase: "running" | "completed"; +}) { + if (phase === "running") { + return ( + + 运行中 + + ); + } + + return ( + + 已完成 + + ); +} diff --git a/frontend/src/components/batch-test/batch-run-completed.tsx b/frontend/src/components/batch-test/batch-run-completed.tsx new file mode 100644 index 0000000..6a9860f --- /dev/null +++ b/frontend/src/components/batch-test/batch-run-completed.tsx @@ -0,0 +1,297 @@ +"use client"; + +/** + * 批量测试 — 已完成结果视图(MVP mock)。 + */ + +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useState } from "react"; + +import { + BatchCaseStatusBadge, + BatchPhasePill, +} from "@/components/batch-test/batch-case-status"; +import { + countByStatus, + formatRunTime, + type BatchRunSnapshot, +} from "@/data/batch-run"; +import { cn } from "@/lib/utils"; + +export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) { + const [expandedIds, setExpandedIds] = useState>(() => { + const firstFail = run.cases.find((item) => item.status === "fail"); + return firstFail ? new Set([firstFail.id]) : new Set(); + }); + + const counts = countByStatus(run.cases); + const judged = counts.pass + counts.fail; + const total = run.cases.length; + const passRate = judged === 0 ? 0 : Math.round((counts.pass / judged) * 100); + const failRate = judged === 0 ? 0 : 100 - passRate; + + function toggleExpanded(id: string) { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + return ( +
+ {/* 结果总览 */} +
+
+

{run.title}

+ +
+ {run.finishedAt && ( +

+ 完成时间:{formatRunTime(run.finishedAt)} + {run.stopped ? " · 已手动停止" : ""} +

+ )} + +
+
+
+ {counts.pass} / {judged || total} 通过 +
+
+ 通过率 {passRate}% +
+
+ + + +
+ + + {counts.skipped > 0 && ( + + )} +
+
+
+ + {/* 用例列表 */} +
+
+

+ 用例列表(共 {total} 个) +

+
+ +
+ + + + + + + + + + + {run.cases.map((item, index) => { + const expanded = expandedIds.has(item.id); + const canExpand = + item.status === "fail" || item.status === "pass"; + + return ( + + + + ); + })} + +
#测试用例状态详情
+ +
+
+
+
+ ); +} + +function PassRateDonut({ pass, fail }: { pass: number; fail: number }) { + const total = pass + fail; + const passRatio = total === 0 ? 0 : pass / total; + const size = 112; + const stroke = 14; + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + const passLength = circumference * passRatio; + const failLength = circumference - passLength; + + return ( +
+ + + {total > 0 && ( + <> + + + + )} + +
+ + {total === 0 ? 0 : Math.round((pass / total) * 100)}% + +
+
+ ); +} + +function LegendRow({ + tone, + label, + count, + percent, +}: { + tone: "success" | "destructive" | "muted"; + label: string; + count: number; + percent: number; +}) { + const dotClass = { + success: "bg-success", + destructive: "bg-destructive", + muted: "bg-muted-soft", + }[tone]; + + return ( +
+ + + {label} {count}({percent}%) + +
+ ); +} + +function DetailField({ label, value }: { label: string; value: string }) { + return ( + + {label} + + {value} + + + ); +} diff --git a/frontend/src/components/batch-test/batch-run-running.tsx b/frontend/src/components/batch-test/batch-run-running.tsx new file mode 100644 index 0000000..3d073bb --- /dev/null +++ b/frontend/src/components/batch-test/batch-run-running.tsx @@ -0,0 +1,195 @@ +"use client"; + +/** + * 批量测试 — 运行中视图(MVP mock)。 + */ + +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useState } from "react"; + +import { + BatchCaseStatusBadge, + BatchPhasePill, +} from "@/components/batch-test/batch-case-status"; +import { + countByStatus, + type BatchRunSnapshot, +} from "@/data/batch-run"; +import { cn } from "@/lib/utils"; + +export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) { + const [expandedIds, setExpandedIds] = useState>(new Set()); + const counts = countByStatus(run.cases); + const done = counts.pass + counts.fail + counts.skipped; + const total = run.cases.length; + const percent = total === 0 ? 0 : Math.round((done / total) * 100); + + function toggleExpanded(id: string) { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + return ( +
+ {/* 进度总览 */} +
+
+
+
+

+ {run.title} +

+ +
+ +
+
+
+
+
+ + {done} / {total} + {" "} + 已完成 + {percent}% +
+
+
+ +
+ + + + +
+
+
+ + {/* 用例列表 */} +
+
+

+ 测试用例(共 {total} 个) +

+
+ +
+ + + + + + + + + + {run.cases.map((item, index) => { + const expanded = expandedIds.has(item.id); + const canExpand = + item.status === "fail" || item.status === "pass"; + + return ( + + + + ); + })} + +
#测试用例状态 +
+ +
+
+
+
+ ); +} + +function Stat({ + label, + value, + tone, +}: { + label: string; + value: number; + tone: "success" | "destructive" | "primary" | "muted"; +}) { + const toneClass = { + success: "text-success", + destructive: "text-destructive", + primary: "text-primary", + muted: "text-muted-foreground", + }[tone]; + + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function DetailBlock({ label, value }: { label: string; value: string }) { + return ( + + {label} + + {value} + + + ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 2ea59f8..aea3315 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -54,6 +54,7 @@ const monitorSubItems: NavItem[] = [ const testSubItems: NavItem[] = [ { href: "/test/cases", label: "测试用例", icon: ClipboardList }, + { href: "/test/batch", label: "批量测试", icon: PlayCircle }, ]; export function Sidebar({ collapsed, onToggle }: SidebarProps) { diff --git a/frontend/src/components/layout/list-page-layout.tsx b/frontend/src/components/layout/list-page-layout.tsx index 8d2afbb..cefb551 100644 --- a/frontend/src/components/layout/list-page-layout.tsx +++ b/frontend/src/components/layout/list-page-layout.tsx @@ -10,12 +10,15 @@ export function ListPageLayout({ title, description, action, + topbarAction, children, className, }: { title: string; description?: ReactNode; action?: ReactNode; + /** 右上角 topbar 操作区(如主 CTA) */ + topbarAction?: ReactNode; children: ReactNode; className?: string; }) { @@ -24,7 +27,7 @@ export function ListPageLayout({ return ( <> - +
(".app-content"); +} + +function buildRunTitle( + selected: TestCase[], + suites: TestSuite[], +): string { + const suiteIds = [...new Set(selected.map((item) => item.suiteId))]; + if (suiteIds.length === 1) { + return ( + suites.find((suite) => suite.id === suiteIds[0])?.name ?? "批量测试" + ); + } + if (suiteIds.length > 1) { + return `${suiteIds.length} 个测试集 · ${selected.length} 个用例`; + } + return "批量测试"; +} export function BatchTestPage() { + const [phase, setPhase] = useState("config"); + const [run, setRun] = useState(null); + + const [assistants, setAssistants] = useState([]); + const [loadingAssistants, setLoadingAssistants] = useState(true); + const [assistantId, setAssistantId] = useState(""); + + const [suites, setSuites] = useState([]); + const [casesBySuite, setCasesBySuite] = useState>( + {}, + ); + const [expandedSuiteIds, setExpandedSuiteIds] = useState>( + new Set(), + ); + const [selectedCaseIds, setSelectedCaseIds] = useState>( + new Set(), + ); + + const [concurrency, setConcurrency] = useState("3"); + const [timeoutSecs, setTimeoutSecs] = useState("30"); + const [failStrategy, setFailStrategy] = useState("continue"); + + const [activeSection, setActiveSection] = + useState("target"); + const sectionRefs = useRef>({ + target: null, + scope: null, + settings: null, + }); + const selectedAnchorRef = useRef(null); + const stopOnFailRef = useRef(failStrategy === "stop_on_fail"); + stopOnFailRef.current = failStrategy === "stop_on_fail"; + + useEffect(() => { + if (phase !== "config") return; + + const scrollContainer = getAppScrollContainer(); + if (!scrollContainer) return; + + let animationFrame = 0; + + function updateActiveSection() { + if (selectedAnchorRef.current) { + setActiveSection(selectedAnchorRef.current); + return; + } + + const containerTop = scrollContainer!.getBoundingClientRect().top; + const activationLine = containerTop + 24; + let nextSection: BatchSectionId = BATCH_SECTIONS[0].id; + + for (const section of BATCH_SECTIONS) { + const element = sectionRefs.current[section.id]; + if (element && element.getBoundingClientRect().top <= activationLine) { + nextSection = section.id; + } + } + + const reachedBottom = + scrollContainer!.scrollHeight > scrollContainer!.clientHeight + 8 && + scrollContainer!.scrollHeight - + scrollContainer!.scrollTop - + scrollContainer!.clientHeight < + 8; + if (reachedBottom) { + nextSection = BATCH_SECTIONS[BATCH_SECTIONS.length - 1].id; + } + + setActiveSection((current) => + current === nextSection ? current : nextSection, + ); + } + + function scheduleUpdate() { + window.cancelAnimationFrame(animationFrame); + animationFrame = window.requestAnimationFrame(updateActiveSection); + } + + function releaseSelectedAnchor() { + selectedAnchorRef.current = null; + scheduleUpdate(); + } + + scheduleUpdate(); + scrollContainer.addEventListener("scroll", scheduleUpdate, { + passive: true, + }); + scrollContainer.addEventListener("wheel", releaseSelectedAnchor, { + passive: true, + }); + scrollContainer.addEventListener("touchstart", releaseSelectedAnchor, { + passive: true, + }); + window.addEventListener("resize", scheduleUpdate); + + return () => { + window.cancelAnimationFrame(animationFrame); + scrollContainer.removeEventListener("scroll", scheduleUpdate); + scrollContainer.removeEventListener("wheel", releaseSelectedAnchor); + scrollContainer.removeEventListener("touchstart", releaseSelectedAnchor); + window.removeEventListener("resize", scheduleUpdate); + }; + }, [phase]); + + function scrollToSection(sectionId: BatchSectionId) { + const scrollContainer = getAppScrollContainer(); + const section = sectionRefs.current[sectionId]; + if (!scrollContainer || !section) return; + + const containerTop = scrollContainer.getBoundingClientRect().top; + const sectionTop = section.getBoundingClientRect().top; + + selectedAnchorRef.current = sectionId; + setActiveSection(sectionId); + scrollContainer.scrollTo({ + top: scrollContainer.scrollTop + (sectionTop - containerTop) - 12, + behavior: "smooth", + }); + } + + useEffect(() => { + const nextSuites = listTestSuites(); + setSuites(nextSuites); + + const map: Record = {}; + const allCaseIds = new Set(); + for (const suite of nextSuites) { + const items = listTestCases(suite.id); + map[suite.id] = items; + for (const item of items) allCaseIds.add(item.id); + } + setCasesBySuite(map); + setSelectedCaseIds(allCaseIds); + + void (async () => { + try { + const list = await assistantsApi.list(); + setAssistants(list); + if (list[0]) setAssistantId(list[0].id); + } catch { + // 无助手时仍可浏览配置页 + } finally { + setLoadingAssistants(false); + } + })(); + }, []); + + // mock 执行引擎:按并发推进用例状态 + useEffect(() => { + if (phase !== "running" || !run) return; + + const limit = Number(concurrency) || 3; + let finished = false; + + function tick(settleRunning: boolean) { + if (finished) return; + + setRun((prev) => { + if (!prev || finished) return prev; + + let cases = prev.cases.map((item) => ({ ...item })); + + // 1) 结算上一拍仍在运行的用例(首拍只启动,不结算) + let sawFail = false; + if (settleRunning) { + for (const item of cases) { + if (item.status !== "running") continue; + item.status = item.failReason ? "fail" : "pass"; + if (item.status === "fail") sawFail = true; + } + } + + // 失败即停:剩余全部标记未执行 + if (sawFail && stopOnFailRef.current) { + cases = cases.map((item) => + item.status === "waiting" || item.status === "running" + ? { ...item, status: "skipped" as const } + : item, + ); + finished = true; + window.setTimeout(() => setPhase("completed"), 0); + return { + ...prev, + cases, + finishedAt: new Date().toISOString(), + stopped: true, + }; + } + + // 2) 按并发补齐运行中 + let running = cases.filter((item) => item.status === "running").length; + for (const item of cases) { + if (running >= limit) break; + if (item.status !== "waiting") continue; + item.status = "running"; + running += 1; + } + + const pending = cases.some( + (item) => item.status === "waiting" || item.status === "running", + ); + if (!pending) { + finished = true; + window.setTimeout(() => setPhase("completed"), 0); + return { + ...prev, + cases, + finishedAt: new Date().toISOString(), + stopped: false, + }; + } + + return { ...prev, cases }; + }); + } + + // 立刻拉起第一批,再按节拍结算/推进 + tick(false); + const timer = window.setInterval(() => tick(true), TICK_MS); + return () => window.clearInterval(timer); + }, [phase, concurrency, run?.startedAt]); + + const allCaseIds = useMemo( + () => + Object.values(casesBySuite) + .flat() + .map((item) => item.id), + [casesBySuite], + ); + + const selectedCases = useMemo(() => { + const ordered: TestCase[] = []; + for (const suite of suites) { + for (const item of casesBySuite[suite.id] ?? []) { + if (selectedCaseIds.has(item.id)) ordered.push(item); + } + } + return ordered; + }, [suites, casesBySuite, selectedCaseIds]); + + const selectedCount = selectedCaseIds.size; + const allSelected = + allCaseIds.length > 0 && allCaseIds.every((id) => selectedCaseIds.has(id)); + const someSelected = selectedCount > 0 && !allSelected; + + const timeoutValue = Number(timeoutSecs); + const timeoutValid = + Number.isFinite(timeoutValue) && timeoutValue > 0 && timeoutValue <= 600; + + const canStart = + Boolean(assistantId) && + selectedCount > 0 && + timeoutValid && + !loadingAssistants && + phase === "config"; + + function toggleSelectAll() { + if (allSelected) { + setSelectedCaseIds(new Set()); + return; + } + setSelectedCaseIds(new Set(allCaseIds)); + } + + function toggleSuite(suiteId: string) { + const suiteCaseIds = (casesBySuite[suiteId] ?? []).map((item) => item.id); + if (suiteCaseIds.length === 0) return; + + const allInSuiteSelected = suiteCaseIds.every((id) => + selectedCaseIds.has(id), + ); + setSelectedCaseIds((prev) => { + const next = new Set(prev); + if (allInSuiteSelected) { + for (const id of suiteCaseIds) next.delete(id); + } else { + for (const id of suiteCaseIds) next.add(id); + } + return next; + }); + } + + function toggleCase(caseId: string) { + setSelectedCaseIds((prev) => { + const next = new Set(prev); + if (next.has(caseId)) next.delete(caseId); + else next.add(caseId); + return next; + }); + } + + function toggleExpanded(suiteId: string) { + setExpandedSuiteIds((prev) => { + const next = new Set(prev); + if (next.has(suiteId)) next.delete(suiteId); + else next.add(suiteId); + return next; + }); + } + + function startRun(cases: TestCase[]) { + const assistantName = + assistants.find((item) => item.id === assistantId)?.name ?? "助手"; + const snapshot = createBatchRunSnapshot({ + title: buildRunTitle(cases, suites), + assistantName, + cases, + }); + setRun(snapshot); + setPhase("running"); + } + + function handleStart() { + if (!canStart) return; + startRun(selectedCases); + } + + function handleStop() { + setRun((prev) => { + if (!prev) return prev; + return { + ...prev, + stopped: true, + finishedAt: new Date().toISOString(), + cases: prev.cases.map((item) => + item.status === "waiting" || item.status === "running" + ? { ...item, status: "skipped" } + : item, + ), + }; + }); + setPhase("completed"); + } + + function handleRerun() { + if (selectedCases.length === 0) { + setPhase("config"); + setRun(null); + return; + } + startRun(selectedCases); + } + + function handleBackToConfig() { + setPhase("config"); + setRun(null); + } + + if (phase === "running" && run) { + return ( + + + 停止运行 + + } + > + + + ); + } + + if (phase === "completed" && run) { + return ( + + + +
+ } + > + + + ); + } + return ( - + description="配置一次批量运行:选择被测助手、测试范围与基础运行参数。" + className="max-w-[880px]" + topbarAction={ + + } + > + + scrollToSection(sectionId as BatchSectionId) + } + /> + +
+
{ + sectionRefs.current.target = element; + }} + className="scroll-mt-3" + > + } + title="运行目标" + description="选择本次批量测试要对齐的助手配置" + > + + +
+ +
{ + sectionRefs.current.scope = element; + }} + className="scroll-mt-3" + > + } + title="选择测试范围" + description="可全选,或展开测试集勾选单个用例" + action={ + + 已选 {selectedCount} 个用例 + + } + > +
+ + + {suites.length === 0 ? ( +

+ 暂无测试集,请先在「测试用例」中创建。 +

+ ) : ( +
    + {suites.map((suite) => { + const suiteCases = casesBySuite[suite.id] ?? []; + const suiteCaseIds = suiteCases.map((item) => item.id); + const selectedInSuite = suiteCaseIds.filter((id) => + selectedCaseIds.has(id), + ).length; + const suiteAllSelected = + suiteCaseIds.length > 0 && + selectedInSuite === suiteCaseIds.length; + const suiteSomeSelected = + selectedInSuite > 0 && !suiteAllSelected; + const expanded = expandedSuiteIds.has(suite.id); + const total = suiteCaseStats(suite.id).total; + + return ( +
  • +
    + + + { + if (!element) return; + element.indeterminate = suiteSomeSelected; + }} + onChange={() => toggleSuite(suite.id)} + aria-label={`选择测试集 ${suite.name}`} + /> + + +
    + + {expanded && ( +
      + {suiteCases.length === 0 ? ( +
    • + 该测试集暂无用例 +
    • + ) : ( + suiteCases.map((item) => ( +
    • + +
    • + )) + )} +
    + )} +
  • + ); + })} +
+ )} +
+
+
+ +
{ + sectionRefs.current.settings = element; + }} + className="scroll-mt-3" + > + } + title="运行设置" + description="MVP 仅保留并发、超时与失败策略" + > +
+ + + + + +
+
+
+
+ ); } diff --git a/frontend/src/components/pages/TestCasesPage.tsx b/frontend/src/components/pages/TestCasesPage.tsx index 9fea23c..56d4d8a 100644 --- a/frontend/src/components/pages/TestCasesPage.tsx +++ b/frontend/src/components/pages/TestCasesPage.tsx @@ -8,6 +8,7 @@ import { ChevronLeft, + Copy, Loader2, MoreHorizontal, Pencil, @@ -56,6 +57,8 @@ import { Textarea } from "@/components/ui/textarea"; import { createTestCase, createTestSuite, + duplicateTestCase, + duplicateTestSuite, formatSuiteResult, getTestSuite, listTestCases, @@ -126,6 +129,12 @@ function SuiteListView() { router.push(`/test/cases/${suite.id}`); } + function duplicateSuite(suite: TestSuite) { + const copied = duplicateTestSuite(suite.id); + if (!copied) return; + setSuites(listTestSuites()); + } + function removeSuite(suite: TestSuite) { if ( !window.confirm( @@ -265,6 +274,13 @@ function SuiteListView() { align="end" className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1" > + duplicateSuite(suite)} + > + + 复制 + setStatusMessage(""), 2000); } + function handleDuplicateCase(item: TestCase) { + if (dirty && !window.confirm("当前用例有未保存修改,复制将丢弃。继续?")) { + return; + } + const copied = duplicateTestCase(item.id); + if (!copied) return; + reload(copied.id); + setStatusMessage(""); + } + function handleDeleteCase(item: TestCase) { if (!window.confirm(`确定删除测试用例“${item.name}”吗?`)) return; removeTestCase(item.id); @@ -696,6 +722,7 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) { onSearchChange={setSearch} onSelectCase={selectCase} onCreateCase={handleCreateCase} + onDuplicateCase={handleDuplicateCase} onDeleteCase={handleDeleteCase} onEnterSelectionMode={handleEnterSelectionMode} onExitSelectionMode={handleExitSelectionMode} diff --git a/frontend/src/components/test-cases/suite-case-list.tsx b/frontend/src/components/test-cases/suite-case-list.tsx index c56e47a..149e952 100644 --- a/frontend/src/components/test-cases/suite-case-list.tsx +++ b/frontend/src/components/test-cases/suite-case-list.tsx @@ -23,7 +23,15 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { CheckSquare, GripVertical, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"; +import { + CheckSquare, + Copy, + GripVertical, + MoreHorizontal, + Pencil, + Plus, + Trash2, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -49,6 +57,7 @@ export function SuiteCaseList({ onSearchChange, onSelectCase, onCreateCase, + onDuplicateCase, onDeleteCase, onEnterSelectionMode, onExitSelectionMode, @@ -66,6 +75,7 @@ export function SuiteCaseList({ onSearchChange: (value: string) => void; onSelectCase: (item: TestCase) => void; onCreateCase: () => void; + onDuplicateCase: (item: TestCase) => void; onDeleteCase: (item: TestCase) => void; onEnterSelectionMode: () => void; onExitSelectionMode: () => void; @@ -109,71 +119,73 @@ export function SuiteCaseList({ return (