diff --git a/apps/portal/src/components/routes/RouteWorkbenchTabs.tsx b/apps/portal/src/components/routes/RouteWorkbenchTabs.tsx
new file mode 100644
index 00000000..bc303571
--- /dev/null
+++ b/apps/portal/src/components/routes/RouteWorkbenchTabs.tsx
@@ -0,0 +1,60 @@
+import { Link } from "@tanstack/react-router";
+import { cn } from "@fluxify/components";
+import { TbFlask, TbTopologyStar3 } from "react-icons/tb";
+
+const TABS = [
+ { label: "Canvas", icon: TbTopologyStar3, to: "/$projectId/canvas/$routeId" },
+ { label: "Tests", icon: TbFlask, to: "/$projectId/canvas/$routeId/test-suites" },
+] as const;
+
+/**
+ * Segmented switcher between a route's workbench views. Links rather than tab
+ * panels: each view is its own route, so the browser keeps the history entry.
+ */
+export function RouteWorkbenchTabs({
+ projectId,
+ routeId,
+}: {
+ projectId: string;
+ routeId: string;
+}) {
+ return (
+
+ {TABS.map(({ label, icon: Icon, to }) => (
+
+
+
+ {label}
+
+
+ ))}
+
+ );
+}
+
+/** The shared topbar shell — the canvas grows its own, this is for the sibling views. */
+export function RouteWorkbenchHeader({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/portal/src/components/testSuites/AssertionsEditor.tsx b/apps/portal/src/components/testSuites/AssertionsEditor.tsx
new file mode 100644
index 00000000..e09acf6f
--- /dev/null
+++ b/apps/portal/src/components/testSuites/AssertionsEditor.tsx
@@ -0,0 +1,201 @@
+import {
+ Button,
+ DeleteIconButton,
+ JavaScriptTextArea,
+ Label,
+ ListBox,
+ Select,
+ cn,
+} from "@fluxify/components";
+import { TbPlus } from "react-icons/tb";
+import {
+ ASSERTION_TARGETS,
+ OPERATOR_LABELS,
+ TARGET_LABELS,
+ type Assertion,
+ type AssertionOperator,
+ type AssertionTarget,
+ allowsPropertyPath,
+ needsExpectedValue,
+ normalizeAssertion,
+ operatorsFor,
+ validateAssertions,
+} from "./assertions";
+
+const inputClass =
+ "w-full rounded-md border border-border bg-background-secondary px-2 py-1.5 text-sm text-foreground outline-none placeholder:text-muted focus:border-accent";
+
+function AssertionRow({
+ assertion,
+ error,
+ onChange,
+ onRemove,
+}: {
+ assertion: Assertion;
+ error?: string;
+ onChange: (next: Assertion) => void;
+ onRemove: () => void;
+}) {
+ const { target, operator } = assertion;
+ // Switching a target drops the fields it forbids, so the payload can never
+ // carry a stale property path the server would reject.
+ const setTarget = (next: AssertionTarget) =>
+ onChange(normalizeAssertion({ ...assertion, target: next }));
+ const setOperator = (next: AssertionOperator) =>
+ onChange(normalizeAssertion({ ...assertion, operator: next }));
+
+ return (
+
+ {/* items-start only for customJs, whose editor is several rows tall;
+ every other row is a single line and should sit on one baseline */}
+
+
key && setTarget(key as AssertionTarget)}
+ className="w-40 shrink-0"
+ >
+
+ {TARGET_LABELS[target]}
+
+
+
+
+ {ASSERTION_TARGETS.map((item) => (
+
+ {TARGET_LABELS[item]}
+
+
+ ))}
+
+
+
+
+ {target === "customJs" ? (
+
+ onChange({ ...assertion, customJs })}
+ />
+
+ Receives body, headers, status and{" "}
+ request. A truthy result passes.
+
+
+ ) : (
+ // one line: [path/header] [operator] [expected value] — the pieces read
+ // as a sentence, so stacking them is what broke the alignment
+
+ {(target === "header" || allowsPropertyPath(target)) && (
+ onChange({ ...assertion, propertyPath: e.target.value })}
+ />
+ )}
+
+ key && setOperator(key as AssertionOperator)}
+ className="w-36 shrink-0"
+ >
+
+
+ {operator ? OPERATOR_LABELS[operator] : "Pick one"}
+
+
+
+
+
+ {/* only the operators this target accepts — the invalid pairs
+ the server rejects are never offered */}
+ {operatorsFor(target).map((item) => (
+
+ {OPERATOR_LABELS[item]}
+
+
+ ))}
+
+
+
+
+ {needsExpectedValue(target, operator) && (
+ onChange({ ...assertion, expectedValue: e.target.value })}
+ />
+ )}
+
+ )}
+
+
+
+
+ {error &&
{error}
}
+
+ );
+}
+
+export function AssertionsEditor({
+ assertions,
+ onChange,
+}: {
+ assertions: Assertion[];
+ onChange: (next: Assertion[]) => void;
+}) {
+ const errors = validateAssertions(assertions);
+
+ function replace(index: number, next: Assertion) {
+ onChange(assertions.map((item, i) => (i === index ? next : item)));
+ }
+
+ return (
+
+
+ Assertions
+
+ onChange([
+ ...assertions,
+ { target: "status", operator: "eq", expectedValue: "200" },
+ ])
+ }
+ >
+ Add assertion
+
+
+
+ {assertions.length === 0 ? (
+
+ No assertions yet. A suite without one only checks that the route runs.
+
+ ) : (
+ assertions.map((assertion, index) => (
+
replace(index, next)}
+ onRemove={() => onChange(assertions.filter((_, i) => i !== index))}
+ />
+ ))
+ )}
+
+ );
+}
diff --git a/apps/portal/src/components/testSuites/OverridesEditor.tsx b/apps/portal/src/components/testSuites/OverridesEditor.tsx
new file mode 100644
index 00000000..8fe87343
--- /dev/null
+++ b/apps/portal/src/components/testSuites/OverridesEditor.tsx
@@ -0,0 +1,202 @@
+import { Button, DeleteIconButton, Label, ListBox, Select } from "@fluxify/components";
+import { TbArrowRight, TbPlus } from "react-icons/tb";
+import { appConfigQuery } from "@/query/appConfigQuery";
+import { integrationsQuery } from "@/query/integrationsQuery";
+import type { SuiteDraft } from "./types";
+
+const inputClass =
+ "w-full rounded-md border border-border bg-background-secondary px-2 py-1.5 text-sm text-foreground outline-none placeholder:text-muted focus:border-accent";
+
+/**
+ * Per-suite overrides so a test can run without touching production data. App
+ * config is applied first on the server, then integrations resolve against the
+ * overridden values.
+ */
+export function OverridesEditor({
+ projectId,
+ draft,
+ onChange,
+}: {
+ projectId: string;
+ draft: SuiteDraft;
+ onChange: (patch: Partial) => void;
+}) {
+ const keys = appConfigQuery.getKeysList.useQuery(projectId, "");
+ const integrations = integrationsQuery.getBasicList.useQuery(projectId);
+ const integrationList = integrations.data ?? [];
+
+ const { appConfigOverrides, integrationOverrides } = draft;
+
+ function patchConfig(index: number, patch: Partial<{ key: string; value: string }>) {
+ onChange({
+ appConfigOverrides: appConfigOverrides.map((item, i) =>
+ i === index ? { ...item, ...patch } : item,
+ ),
+ });
+ }
+
+ function patchIntegration(
+ index: number,
+ patch: Partial<{ existingId: string; newId: string }>,
+ ) {
+ onChange({
+ integrationOverrides: integrationOverrides.map((item, i) =>
+ i === index ? { ...item, ...patch } : item,
+ ),
+ });
+ }
+
+ function nameOf(id: string) {
+ return integrationList.find((item) => item.id === id)?.name ?? "Pick one";
+ }
+
+ return (
+
+
+
+
+
App config overrides
+
+ Test-only values for existing config keys. Applied before integrations resolve.
+
+
+
+ onChange({ appConfigOverrides: [...appConfigOverrides, { key: "", value: "" }] })
+ }
+ >
+ Add
+
+
+
+ {appConfigOverrides.map((override, index) => (
+ // eslint-disable-next-line react/no-array-index-key -- overrides are an ordered list with no id
+
+ key && patchConfig(index, { key: key as string })}
+ className="w-64 shrink-0"
+ >
+
+
+ {override.key || "Pick a key"}
+
+
+
+
+
+ {(keys.data ?? []).map((key) => (
+
+ {key}
+
+
+ ))}
+
+
+
+ {/* these are credentials in practice — never render them in plain text */}
+ patchConfig(index, { value: e.target.value })}
+ />
+
+ onChange({
+ appConfigOverrides: appConfigOverrides.filter((_, i) => i !== index),
+ })
+ }
+ />
+
+ ))}
+
+
+
+
+
+
Integration overrides
+
+ Swap an integration for another one, for this suite only.
+
+
+
+ onChange({
+ integrationOverrides: [
+ ...integrationOverrides,
+ { existingId: "", newId: "" },
+ ],
+ })
+ }
+ >
+ Add
+
+
+
+ {integrationOverrides.map((override, index) => (
+ // eslint-disable-next-line react/no-array-index-key -- overrides are an ordered list with no id
+
+ {(["existingId", "newId"] as const).map((side, position) => (
+
+ {position === 1 && }
+
+ key && patchIntegration(index, { [side]: key as string })
+ }
+ className="min-w-0 flex-1"
+ >
+
+ {nameOf(override[side])}
+
+
+
+
+ {integrationList
+ // the same integration on both sides is a no-op the server rejects
+ .filter(
+ (item) =>
+ item.id !==
+ override[side === "existingId" ? "newId" : "existingId"],
+ )
+ .map((item) => (
+
+
+ {item.name}
+ {item.variant}
+
+
+
+ ))}
+
+
+
+
+ ))}
+
+ onChange({
+ integrationOverrides: integrationOverrides.filter((_, i) => i !== index),
+ })
+ }
+ />
+
+ ))}
+
+
+ );
+}
diff --git a/apps/portal/src/components/testSuites/RequestEditor.tsx b/apps/portal/src/components/testSuites/RequestEditor.tsx
new file mode 100644
index 00000000..bd8bd65f
--- /dev/null
+++ b/apps/portal/src/components/testSuites/RequestEditor.tsx
@@ -0,0 +1,87 @@
+import { FieldMapEditor, JsonEditor, Label, TextArea } from "@fluxify/components";
+import type { JsonContainer } from "@fluxify/components";
+import { methodTakesBody, pathParamsOf } from "./assertions";
+import type { SuiteDraft } from "./types";
+
+/**
+ * The mock request a suite sends: description, path params, query, headers and
+ * body. Path params come from the route's own `:segments` rather than being
+ * typed blind.
+ */
+export function RequestEditor({
+ draft,
+ routePath,
+ method,
+ onChange,
+}: {
+ draft: SuiteDraft;
+ routePath: string | undefined;
+ method: string | undefined;
+ onChange: (patch: Partial) => void;
+}) {
+ const pathParams = pathParamsOf(routePath);
+
+ return (
+
+
+ Description
+
+
+ {pathParams.length > 0 && (
+
+
Path parameters
+
{routePath}
+
+ {pathParams.map((param) => (
+
+
+ :{param}
+
+
+ onChange({
+ routeParams: { ...draft.routeParams, [param]: e.target.value },
+ })
+ }
+ />
+
+ ))}
+
+
+ )}
+
+
onChange({ queryParams })}
+ />
+
+ onChange({ headers })}
+ />
+
+ {methodTakesBody(method) && (
+ onChange({ body: body as Record })}
+ showPreview
+ />
+ )}
+
+ );
+}
diff --git a/apps/portal/src/components/testSuites/ResponseViewer.tsx b/apps/portal/src/components/testSuites/ResponseViewer.tsx
new file mode 100644
index 00000000..7f95c4c9
--- /dev/null
+++ b/apps/portal/src/components/testSuites/ResponseViewer.tsx
@@ -0,0 +1,164 @@
+import { useMemo, useState } from "react";
+import { Button, CodeViewer, cn } from "@fluxify/components";
+import { TbDownload } from "react-icons/tb";
+
+type Headers = Record | undefined;
+
+function contentTypeOf(headers: Headers) {
+ if (!headers) return "";
+ const entry = Object.entries(headers).find(
+ ([key]) => key.toLowerCase() === "content-type",
+ );
+ return entry?.[1]?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
+}
+
+/** Same mapping the API playground uses, kept local to avoid a cross-package import. */
+function languageFor(mime: string) {
+ if (mime.includes("json") || mime.endsWith("+json")) return "json";
+ if (mime.includes("xml")) return "xml";
+ if (mime.includes("html")) return "html";
+ if (mime.includes("javascript")) return "javascript";
+ return "plaintext";
+}
+
+/**
+ * How the payload should be shown. Anything the browser can render natively
+ * (image, video, audio) gets its own element; anything else that is not text
+ * becomes a download, since dumping bytes into an editor helps nobody.
+ */
+function presentationFor(mime: string, data: unknown) {
+ if (mime.startsWith("image/")) return "image" as const;
+ if (mime.startsWith("video/")) return "video" as const;
+ if (mime.startsWith("audio/")) return "audio" as const;
+ const textual =
+ !mime ||
+ mime.startsWith("text/") ||
+ mime.includes("json") ||
+ mime.includes("xml") ||
+ mime.includes("javascript") ||
+ mime.includes("html") ||
+ typeof data === "object";
+ return textual ? ("text" as const) : ("binary" as const);
+}
+
+/**
+ * A run's payload is stored as jsonb, so binary bodies arrive as a base64 or
+ * data-URI string. Both become a usable src; anything else has no meaningful
+ * media representation and falls back to the text view.
+ */
+function toDataUri(mime: string, data: unknown): string | null {
+ if (typeof data !== "string") return null;
+ if (data.startsWith("data:")) return data;
+ if (/^[A-Za-z0-9+/=\s]+$/.test(data) && data.length > 16) {
+ return `data:${mime || "application/octet-stream"};base64,${data.replace(/\s/g, "")}`;
+ }
+ return null;
+}
+
+function asText(data: unknown) {
+ if (data === undefined || data === null) return "";
+ return typeof data === "string" ? data : JSON.stringify(data, null, 2);
+}
+
+function HeadersTable({ headers }: { headers: Headers }) {
+ const entries = Object.entries(headers ?? {});
+ if (entries.length === 0) {
+ return No headers recorded.
;
+ }
+ return (
+
+ {entries.map(([key, value]) => (
+
+ {key}
+ {value}
+
+ ))}
+
+ );
+}
+
+/** Response body + headers for one suite run, as secondary tabs. */
+export function ResponseViewer({
+ data,
+ headers,
+ suiteName,
+}: {
+ data: unknown;
+ headers: Headers;
+ suiteName: string;
+}) {
+ const [tab, setTab] = useState<"body" | "headers">("body");
+ const mime = contentTypeOf(headers);
+ const presentation = presentationFor(mime, data);
+ const src = useMemo(
+ () => (presentation === "text" ? null : toDataUri(mime, data)),
+ [presentation, mime, data],
+ );
+ const headerCount = Object.keys(headers ?? {}).length;
+
+ return (
+
+
+ {(["body", "headers"] as const).map((name) => (
+ setTab(name)}
+ className={cn(
+ "border-b-2 px-2 py-1 text-xs font-medium capitalize transition-colors",
+ tab === name
+ ? "border-accent text-accent"
+ : "border-transparent text-muted hover:text-foreground",
+ )}
+ >
+ {name}
+ {name === "headers" && headerCount > 0 && (
+ {headerCount}
+ )}
+
+ ))}
+ {mime && {mime} }
+
+
+ {tab === "headers" ? (
+
+ ) : presentation === "image" && src ? (
+
+ ) : presentation === "video" && src ? (
+ // eslint-disable-next-line jsx-a11y/media-has-caption -- a response body has no caption track
+
+ ) : presentation === "audio" && src ? (
+ // eslint-disable-next-line jsx-a11y/media-has-caption -- a response body has no caption track
+
+ ) : presentation === "binary" ? (
+
+
+ Binary response ({mime || "unknown type"}) — nothing useful to render.
+
+ {src ? (
+
+ Download
+
+ ) : (
+
+ Not downloadable
+
+ )}
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/portal/src/components/testSuites/RunResults.tsx b/apps/portal/src/components/testSuites/RunResults.tsx
new file mode 100644
index 00000000..3fbb3af5
--- /dev/null
+++ b/apps/portal/src/components/testSuites/RunResults.tsx
@@ -0,0 +1,339 @@
+import { useState } from "react";
+import { Button, DeleteButton, Spinner, cn } from "@fluxify/components";
+import type { SuiteRunResult } from "@fluxify/server/src/db/schema";
+import {
+ TbAlertTriangle,
+ TbCheck,
+ TbChevronDown,
+ TbChevronRight,
+ TbClock,
+ TbHistory,
+ TbX,
+} from "react-icons/tb";
+import { ConfirmDialog } from "@/components/common/ConfirmDialog";
+import { showErrorNotification } from "@/lib/errorNotifier";
+import { testSuitesQuery } from "@/query/testSuitesQuery";
+import type { TestRunStatus } from "@/services/testSuites";
+import { ResponseViewer } from "./ResponseViewer";
+
+const TERMINAL_TONE: Record = {
+ passed: "text-success",
+ failed: "text-danger",
+ timeout: "text-warning",
+ error: "text-danger",
+ running: "text-warning",
+ queued: "text-muted",
+};
+
+function StatusIcon({ status }: { status: TestRunStatus }) {
+ const className = cn("shrink-0", TERMINAL_TONE[status]);
+ if (status === "passed") return ;
+ if (status === "failed" || status === "error")
+ return ;
+ if (status === "timeout")
+ return ;
+ return ;
+}
+
+function formatDuration(ms: number | null | undefined) {
+ if (ms == null) return "—";
+ return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
+}
+
+function SuiteRunRow({
+ name,
+ status,
+ durationMs,
+ result,
+}: {
+ name: string;
+ status: TestRunStatus;
+ durationMs: number | null;
+ result: SuiteRunResult | null;
+}) {
+ const [open, setOpen] = useState(false);
+ const assertions = result?.result ?? [];
+
+ return (
+
+
setOpen((v) => !v)}
+ onKeyDown={(e) => e.key === "Enter" && setOpen((v) => !v)}
+ className="flex cursor-pointer items-center gap-2 p-3"
+ >
+
+ {name}
+ {result?.statusCode != null && (
+ {result.statusCode}
+ )}
+ {formatDuration(durationMs)}
+ {open ? (
+
+ ) : (
+
+ )}
+
+
+ {open && (
+
+ {/* a killed process has no partial results — say so rather than
+ rendering an empty assertion list */}
+ {status === "timeout" && assertions.length === 0 && (
+
+ Timed out after {formatDuration(durationMs)}. A suite killed at its time
+ budget reports no assertion detail.
+
+ )}
+ {result?.error &&
{result.error}
}
+
+ {assertions.length > 0 && (
+
+ {assertions.map((assertion, index) => (
+
+ {assertion.success ? (
+
+ ) : (
+
+ )}
+
+ {assertion.message}
+
+
+ ))}
+
+ )}
+
+ {(result?.actualData !== undefined || result?.headers) && (
+
+ )}
+
+ )}
+
+ );
+}
+
+/** Absolute local time, plus a relative hint for anything recent. */
+function formatWhen(value: unknown) {
+ if (!value) return "—";
+ const date = new Date(value as string);
+ if (Number.isNaN(date.getTime())) return "—";
+ const elapsed = Date.now() - date.getTime();
+ const minutes = Math.round(elapsed / 60_000);
+ if (minutes < 1) return "just now";
+ if (minutes < 60) return `${minutes}m ago`;
+ if (minutes < 60 * 24) return `${Math.round(minutes / 60)}h ago`;
+ return date.toLocaleString();
+}
+
+function RunHistory({
+ projectId,
+ routeId,
+ onSelect,
+}: {
+ projectId: string;
+ routeId: string;
+ onSelect: (runId: string) => void;
+}) {
+ const [page, setPage] = useState(1);
+ const runs = testSuitesQuery.getRuns.useQuery(projectId, routeId, { page, perPage: 10 });
+ const pagination = runs.data?.pagination;
+
+ if (runs.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const items = runs.data?.data ?? [];
+ if (items.length === 0) {
+ return No runs yet.
;
+ }
+
+ return (
+
+ {items.map((run) => (
+
onSelect(run.id)}
+ onKeyDown={(e) => e.key === "Enter" && onSelect(run.id)}
+ className="flex cursor-pointer flex-col gap-1 rounded-lg border border-border bg-background-secondary p-3 hover:border-accent/40"
+ >
+
+
+
+ {run.passedCount}/{run.totalSuites} passed
+
+ {formatDuration(run.durationMs)}
+
+
+ {formatWhen(run.startedAt ?? run.createdAt)}
+
+
+ ))}
+
+
+ {pagination && (
+
+ setPage((p) => p - 1)}
+ >
+ Previous
+
+ Page {page}
+ setPage((p) => p + 1)}
+ >
+ Next
+
+
+ )}
+
+ );
+}
+
+/**
+ * The run panel. Suite rows settle independently on the server, so this renders
+ * whatever has landed so far rather than one spinner until the parent finishes.
+ */
+export function RunResults({
+ projectId,
+ routeId,
+ runId,
+ suiteNames,
+ onSelectRun,
+}: {
+ projectId: string;
+ routeId: string;
+ runId: string | null;
+ suiteNames: Record;
+ onSelectRun: (runId: string) => void;
+}) {
+ const [showHistory, setShowHistory] = useState(false);
+ const [confirmClear, setConfirmClear] = useState(false);
+ const run = testSuitesQuery.getRun.useQuery(projectId, routeId, runId);
+ const clear = testSuitesQuery.clearRuns.mutation(projectId, routeId);
+ const data = run.data;
+ const summary = data?.result as { error?: string } | null | undefined;
+
+ return (
+
+
+
+ {showHistory ? "Run history" : "Latest run"}
+
+ {!showHistory && data && (
+ <>
+
+ {data.passedCount} passed · {data.failedCount} failed
+
+
+ {formatDuration(data.durationMs)} ·{" "}
+ {formatWhen(data.startedAt ?? data.createdAt)}
+
+ >
+ )}
+ setShowHistory((v) => !v)}
+ >
+
+
+ {showHistory && (
+ setConfirmClear(true)}
+ >
+ Clear
+
+ )}
+
+
+
+ clear.mutate(undefined, {
+ onSuccess: () => setConfirmClear(false),
+ onError: (error) => showErrorNotification(error as Error),
+ })
+ }
+ >
+ Every recorded run for this route will be deleted. The suites themselves are kept.
+
+
+
+ {showHistory ? (
+
{
+ onSelectRun(id);
+ setShowHistory(false);
+ }}
+ />
+ ) : !runId ? (
+
+ Run a suite to see its result here.
+
+ ) : run.isLoading ? (
+
+
+
+ ) : (
+
+ {/* a run can fail outright — compilation error, or a restart
+ mid-run — with no suite detail at all */}
+ {data?.status === "error" && (
+
+ {summary?.error ?? "The run failed before any suite reported."}
+
+ )}
+ {data?.suiteRuns.map((suiteRun) => (
+
+ ))}
+ {data?.suiteRuns.length === 0 && data.status !== "error" && (
+
+ Waiting for the first suite to settle…
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/apps/portal/src/components/testSuites/TestSuitesWorkbench.tsx b/apps/portal/src/components/testSuites/TestSuitesWorkbench.tsx
new file mode 100644
index 00000000..82229c74
--- /dev/null
+++ b/apps/portal/src/components/testSuites/TestSuitesWorkbench.tsx
@@ -0,0 +1,373 @@
+import { useEffect, useMemo, useState } from "react";
+import { Button, DeleteIconButton, Spinner, cn, toast } from "@fluxify/components";
+import { TbPlayerPlay, TbPlus, TbSearch } from "react-icons/tb";
+import { ConfirmDialog } from "@/components/common/ConfirmDialog";
+import { RouteSwitcher } from "@/components/routes/RouteSwitcher";
+import {
+ RouteWorkbenchHeader,
+ RouteWorkbenchTabs,
+} from "@/components/routes/RouteWorkbenchTabs";
+import { showErrorNotification } from "@/lib/errorNotifier";
+import { routesQuery } from "@/query/routesQuery";
+import { testSuitesQuery } from "@/query/testSuitesQuery";
+import { IN_FLIGHT_STATUSES } from "@/services/testSuites";
+import { AssertionsEditor } from "./AssertionsEditor";
+import { OverridesEditor } from "./OverridesEditor";
+import { RequestEditor } from "./RequestEditor";
+import { RunResults } from "./RunResults";
+import { validateAssertions } from "./assertions";
+import { toDraft, type SuiteDraft } from "./types";
+
+const EDITOR_TABS = ["Request", "Assertions", "Overrides"] as const;
+
+type EditorTab = (typeof EDITOR_TABS)[number];
+
+function SuiteList({
+ suites,
+ isLoading,
+ selectedId,
+ onSelect,
+ onCreate,
+ onDelete,
+ isCreating,
+}: {
+ suites: { id: string; name: string }[];
+ isLoading: boolean;
+ selectedId: string | null;
+ onSelect: (id: string) => void;
+ onCreate: () => void;
+ onDelete: (suite: { id: string; name: string }) => void;
+ isCreating: boolean;
+}) {
+ const [filter, setFilter] = useState("");
+ const shown = suites.filter((suite) =>
+ suite.name.toLowerCase().includes(filter.toLowerCase()),
+ );
+
+ return (
+
+
+
+
+ setFilter(e.target.value)}
+ />
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : shown.length === 0 ? (
+
+ {suites.length === 0 ? "No suites for this route yet." : "Nothing matches."}
+
+ ) : (
+ shown.map((suite) => (
+
onSelect(suite.id)}
+ onKeyDown={(e) => e.key === "Enter" && onSelect(suite.id)}
+ className={cn(
+ "group flex cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-xs transition-colors",
+ suite.id === selectedId
+ ? "bg-accent/10 text-accent"
+ : "text-muted hover:bg-surface-secondary hover:text-foreground",
+ )}
+ >
+ {suite.name || "Untitled suite"}
+ onDelete(suite)}
+ />
+
+ ))
+ )}
+
+
+ );
+}
+
+export function TestSuitesWorkbench({
+ projectId,
+ routeId,
+}: {
+ projectId: string;
+ routeId: string;
+}) {
+ const [selectedId, setSelectedId] = useState(null);
+ const [tab, setTab] = useState("Request");
+ const [draft, setDraft] = useState(() => toDraft(undefined));
+ const [isDirty, setDirty] = useState(false);
+ const [runId, setRunId] = useState(null);
+ const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null);
+
+ const route = routesQuery.byId.useQuery(routeId);
+ const suites = testSuitesQuery.getAll.useQuery(routeId);
+ const detail = testSuitesQuery.getById.useQuery(routeId, selectedId);
+ const create = testSuitesQuery.create.mutation(routeId);
+ const update = testSuitesQuery.update.mutation(routeId, selectedId ?? "");
+ const remove = testSuitesQuery.remove.mutation(routeId);
+ const startRun = testSuitesQuery.startRun.mutation(projectId, routeId);
+ // Same query key the results panel polls, so react-query serves both from one
+ // request — this is only here to know when the run is still moving.
+ const activeRun = testSuitesQuery.getRun.useQuery(projectId, routeId, runId);
+ const isRunning =
+ startRun.isPending ||
+ (!!runId && (!activeRun.data || IN_FLIGHT_STATUSES.includes(activeRun.data.status)));
+
+ const list = useMemo(
+ () =>
+ (suites.data ?? []).map((suite) => ({
+ id: suite.id ?? "",
+ name: suite.name ?? "",
+ })),
+ [suites.data],
+ );
+
+ const suiteNames = useMemo(
+ () => Object.fromEntries(list.map((suite) => [suite.id, suite.name])),
+ [list],
+ );
+
+ // Open the first suite once the list arrives, so the editor is never blank
+ // when there is something to show.
+ useEffect(() => {
+ if (!selectedId && list.length > 0) setSelectedId(list[0].id);
+ }, [list, selectedId]);
+
+ // Seed the form from the loaded suite. Keyed on the suite id, not the query
+ // data, so a background refetch cannot wipe unsaved edits.
+ useEffect(() => {
+ if (detail.data) {
+ setDraft(toDraft(detail.data));
+ setDirty(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- reseed per suite, not per refetch
+ }, [selectedId, detail.data?.id]);
+
+ const assertionErrors = validateAssertions(draft.assertions);
+ const canSave = isDirty && assertionErrors.size === 0 && draft.name.trim().length > 0;
+
+ function patch(next: Partial) {
+ setDraft((current) => ({ ...current, ...next }));
+ setDirty(true);
+ }
+
+ async function onSave() {
+ if (!selectedId || !canSave) return;
+ try {
+ await update.mutateAsync({
+ name: draft.name,
+ description: draft.description,
+ routeId,
+ projectId,
+ headers: draft.headers,
+ queryParams: draft.queryParams,
+ routeParams: draft.routeParams,
+ params: {},
+ body: draft.body ?? null,
+ assertions: draft.assertions,
+ appConfigOverrides: draft.appConfigOverrides,
+ integrationOverrides: draft.integrationOverrides,
+ });
+ setDirty(false);
+ toast.success("Suite saved");
+ } catch (error) {
+ // The server also rejects an integration from another project — its
+ // message is the useful one, so surface it rather than a generic failure.
+ showErrorNotification(error as Error);
+ }
+ }
+
+ async function onCreate() {
+ try {
+ const created = await create.mutateAsync({
+ name: "New suite",
+ description: "",
+ });
+ setSelectedId(created.id);
+ } catch (error) {
+ showErrorNotification(error as Error);
+ }
+ }
+
+ async function onRun(suiteIds?: string[]) {
+ try {
+ const { runId: id } = await startRun.mutateAsync(suiteIds);
+ setRunId(id);
+ } catch (error) {
+ showErrorNotification(error as Error);
+ }
+ }
+
+ async function onConfirmDelete() {
+ if (!pendingDelete) return;
+ try {
+ await remove.mutateAsync(pendingDelete.id);
+ if (pendingDelete.id === selectedId) setSelectedId(null);
+ setPendingDelete(null);
+ } catch (error) {
+ showErrorNotification(error as Error);
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {list.length} suite{list.length === 1 ? "" : "s"}
+
+ void onRun()}
+ >
+ {isRunning ? "Running…" : "Run all"}
+
+ void onCreate()}>
+ New suite
+
+
+
+
+
+
void onCreate()}
+ onDelete={setPendingDelete}
+ />
+
+
+ {!selectedId ? (
+
+ Select a suite, or create one.
+
+ ) : detail.isLoading ? (
+
+
+
+ ) : (
+ <>
+
+ patch({ name: e.target.value })}
+ />
+ void onRun([selectedId])}
+ >
+ {isRunning ? "Running…" : "Run suite"}
+
+ void onSave()}
+ >
+ Save
+
+
+
+
+ {EDITOR_TABS.map((name) => (
+ setTab(name)}
+ className={cn(
+ "border-b-2 px-3 py-2 text-xs font-medium transition-colors",
+ tab === name
+ ? "border-accent text-accent"
+ : "border-transparent text-muted hover:text-foreground",
+ )}
+ >
+ {name}
+ {name === "Assertions" && assertionErrors.size > 0 && (
+ •
+ )}
+
+ ))}
+
+
+
+ {tab === "Request" && (
+
+ )}
+ {tab === "Assertions" && (
+
patch({ assertions })}
+ />
+ )}
+ {tab === "Overrides" && (
+
+ )}
+
+ >
+ )}
+
+
+
+
+
+ !open && setPendingDelete(null)}
+ title="Delete test suite"
+ confirmText="Delete"
+ danger
+ pending={remove.isPending}
+ onConfirm={() => void onConfirmDelete()}
+ >
+ {pendingDelete?.name || "This suite"} and its assertions will be removed. Past run
+ results are kept.
+
+ >
+ );
+}
diff --git a/apps/portal/src/components/testSuites/assertions.test.ts b/apps/portal/src/components/testSuites/assertions.test.ts
new file mode 100644
index 00000000..97b19a83
--- /dev/null
+++ b/apps/portal/src/components/testSuites/assertions.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, test } from "bun:test";
+import {
+ methodTakesBody,
+ normalizeAssertion,
+ pathParamsOf,
+ validateAssertion,
+} from "./assertions";
+
+describe("assertion validation", () => {
+ test("accepts a valid pair", () => {
+ expect(
+ validateAssertion({ target: "status", operator: "eq", expectedValue: "200" }),
+ ).toBeNull();
+ });
+
+ test("rejects an operator the target does not allow", () => {
+ expect(
+ validateAssertion({ target: "status", operator: "contains", expectedValue: "2" }),
+ ).not.toBeNull();
+ });
+
+ test("rejects a property path outside the body", () => {
+ expect(
+ validateAssertion({
+ target: "header",
+ operator: "eq",
+ expectedValue: "a",
+ propertyPath: "data.id",
+ }),
+ ).not.toBeNull();
+ });
+
+ test("expected value is required only for value operators", () => {
+ expect(validateAssertion({ target: "body", operator: "exists" })).toBeNull();
+ expect(validateAssertion({ target: "body", operator: "eq" })).not.toBeNull();
+ });
+
+ test("numeric targets reject a non-number", () => {
+ expect(
+ validateAssertion({ target: "time", operator: "lt", expectedValue: "fast" }),
+ ).not.toBeNull();
+ });
+
+ test("customJs needs an expression, not an operator", () => {
+ expect(validateAssertion({ target: "customJs", customJs: "status === 200" })).toBeNull();
+ expect(validateAssertion({ target: "customJs", customJs: " " })).not.toBeNull();
+ });
+});
+
+describe("normalizeAssertion", () => {
+ test("drops a stale property path when the target changes", () => {
+ const next = normalizeAssertion({
+ target: "status",
+ operator: "eq",
+ expectedValue: "200",
+ propertyPath: "data.id",
+ });
+ expect(next.propertyPath).toBeNull();
+ });
+
+ test("repairs an operator the new target forbids", () => {
+ const next = normalizeAssertion({ target: "status", operator: "contains" });
+ expect(next.operator).toBe("eq");
+ });
+
+ test("clears the expected value for a valueless operator", () => {
+ const next = normalizeAssertion({
+ target: "body",
+ operator: "exists",
+ expectedValue: "leftover",
+ });
+ expect(next.expectedValue).toBeNull();
+ });
+});
+
+test("pathParamsOf reads the route's own segments", () => {
+ expect(pathParamsOf("/users/:id/posts/:postId")).toEqual(["id", "postId"]);
+ expect(pathParamsOf("/users")).toEqual([]);
+ expect(pathParamsOf(undefined)).toEqual([]);
+});
+
+test("methodTakesBody follows the HTTP methods that carry one", () => {
+ expect(methodTakesBody("POST")).toBe(true);
+ expect(methodTakesBody("get")).toBe(false);
+});
diff --git a/apps/portal/src/components/testSuites/assertions.ts b/apps/portal/src/components/testSuites/assertions.ts
new file mode 100644
index 00000000..ad9143f3
--- /dev/null
+++ b/apps/portal/src/components/testSuites/assertions.ts
@@ -0,0 +1,167 @@
+/**
+ * Assertion rules, mirrored from the server's `superRefine`
+ * (`apps/server/src/api/v1/test-suites/schema.ts`). Kept in one place so the
+ * editor can disable an invalid pair instead of letting the user discover it
+ * from a 400.
+ */
+
+export const ASSERTION_TARGETS = [
+ "status",
+ "body",
+ "time",
+ "header",
+ "customJs",
+] as const;
+
+export type AssertionTarget = (typeof ASSERTION_TARGETS)[number];
+
+export const ASSERTION_OPERATORS = [
+ "eq",
+ "neq",
+ "lt",
+ "gt",
+ "contains",
+ "true",
+ "false",
+ "exists",
+ "not_exists",
+] as const;
+
+export type AssertionOperator = (typeof ASSERTION_OPERATORS)[number];
+
+export type Assertion = {
+ target: AssertionTarget;
+ propertyPath?: string | null;
+ operator?: AssertionOperator | null;
+ expectedValue?: string | null;
+ customJs?: string | null;
+};
+
+/** `customJs` has none: the expression itself is the assertion. */
+const OPERATORS_BY_TARGET: Record = {
+ status: ["eq", "neq", "lt", "gt"],
+ time: ["eq", "neq", "lt", "gt"],
+ body: ["eq", "neq", "contains", "true", "false", "exists", "not_exists"],
+ header: ["eq", "neq", "contains", "true", "false", "exists", "not_exists"],
+ customJs: [],
+};
+
+/** Operators that assert on their own — an expected value would mean nothing. */
+const VALUELESS_OPERATORS: AssertionOperator[] = [
+ "true",
+ "false",
+ "exists",
+ "not_exists",
+];
+
+export const OPERATOR_LABELS: Record = {
+ eq: "equals",
+ neq: "not equals",
+ lt: "less than",
+ gt: "greater than",
+ contains: "contains",
+ true: "is true",
+ false: "is false",
+ exists: "exists",
+ not_exists: "does not exist",
+};
+
+export const TARGET_LABELS: Record = {
+ status: "Status code",
+ body: "Response body",
+ time: "Duration (ms)",
+ header: "Header",
+ customJs: "Custom JS",
+};
+
+export function operatorsFor(target: AssertionTarget): AssertionOperator[] {
+ return OPERATORS_BY_TARGET[target];
+}
+
+/** Only `body` addresses into a structure, so only it may carry a path. */
+export function allowsPropertyPath(target: AssertionTarget): boolean {
+ return target === "body";
+}
+
+export function needsExpectedValue(
+ target: AssertionTarget,
+ operator?: AssertionOperator | null,
+): boolean {
+ if (target === "customJs") return false;
+ return !operator || !VALUELESS_OPERATORS.includes(operator);
+}
+
+/** Returns a message per invalid assertion, keyed by its index. */
+export function validateAssertions(
+ assertions: Assertion[],
+): Map {
+ const errors = new Map();
+ assertions.forEach((assertion, index) => {
+ const error = validateAssertion(assertion);
+ if (error) errors.set(index, error);
+ });
+ return errors;
+}
+
+export function validateAssertion(assertion: Assertion): string | null {
+ const { target, operator, expectedValue, propertyPath, customJs } = assertion;
+
+ if (target === "customJs") {
+ return customJs?.trim() ? null : "Write the expression to evaluate";
+ }
+
+ if (propertyPath && !allowsPropertyPath(target)) {
+ return "A property path only applies to the response body";
+ }
+ if (!operator) return "Pick an operator";
+ if (!operatorsFor(target).includes(operator)) {
+ return `'${OPERATOR_LABELS[operator]}' does not apply to ${TARGET_LABELS[target].toLowerCase()}`;
+ }
+ if (needsExpectedValue(target, operator) && !expectedValue?.trim()) {
+ return "Expected value is required";
+ }
+ if (
+ (target === "status" || target === "time") &&
+ expectedValue &&
+ Number.isNaN(Number(expectedValue))
+ ) {
+ return "Expected value must be a number";
+ }
+ return null;
+}
+
+/**
+ * Drops the fields the server rejects for the chosen target, so switching a
+ * target in the UI cannot smuggle a stale property path into the payload.
+ */
+export function normalizeAssertion(assertion: Assertion): Assertion {
+ if (assertion.target === "customJs") {
+ return { target: "customJs", customJs: assertion.customJs ?? "" };
+ }
+ const operator = operatorsFor(assertion.target).includes(
+ assertion.operator as AssertionOperator,
+ )
+ ? assertion.operator
+ : operatorsFor(assertion.target)[0];
+ return {
+ target: assertion.target,
+ operator,
+ propertyPath: allowsPropertyPath(assertion.target)
+ ? (assertion.propertyPath ?? null)
+ : null,
+ expectedValue: needsExpectedValue(assertion.target, operator)
+ ? (assertion.expectedValue ?? "")
+ : null,
+ };
+}
+
+/** `/users/:id/posts/:postId` -> `["id", "postId"]` */
+export function pathParamsOf(routePath: string | undefined): string[] {
+ if (!routePath) return [];
+ return [...routePath.matchAll(/:([A-Za-z0-9_]+)/g)].map((match) => match[1]);
+}
+
+/** Methods with no request body — the editor hides the body tab for these. */
+export function methodTakesBody(method: string | undefined): boolean {
+ return !!method && !["GET", "HEAD", "DELETE", "OPTIONS"].includes(method.toUpperCase());
+}
diff --git a/apps/portal/src/components/testSuites/types.ts b/apps/portal/src/components/testSuites/types.ts
new file mode 100644
index 00000000..a112616d
--- /dev/null
+++ b/apps/portal/src/components/testSuites/types.ts
@@ -0,0 +1,33 @@
+import type { TestSuiteDetail } from "@/services/testSuites";
+import type { Assertion } from "./assertions";
+
+/**
+ * What the editor holds while a suite is open. The server DTO is fully partial
+ * (every field optional), which is unusable as form state — this is the same
+ * suite with the containers guaranteed present.
+ */
+export type SuiteDraft = {
+ name: string;
+ description: string;
+ headers: Record;
+ queryParams: Record;
+ routeParams: Record;
+ body: Record | null;
+ assertions: Assertion[];
+ appConfigOverrides: { key: string; value: string }[];
+ integrationOverrides: { existingId: string; newId: string }[];
+};
+
+export function toDraft(suite: TestSuiteDetail | undefined): SuiteDraft {
+ return {
+ name: suite?.name ?? "",
+ description: suite?.description ?? "",
+ headers: (suite?.headers as Record) ?? {},
+ queryParams: (suite?.queryParams as Record) ?? {},
+ routeParams: (suite?.routeParams as Record) ?? {},
+ body: (suite?.body as Record | undefined) ?? null,
+ assertions: (suite?.assertions as Assertion[]) ?? [],
+ appConfigOverrides: suite?.appConfigOverrides ?? [],
+ integrationOverrides: suite?.integrationOverrides ?? [],
+ };
+}
diff --git a/apps/portal/src/query/testSuitesQuery.ts b/apps/portal/src/query/testSuitesQuery.ts
index c1c190aa..40d1f703 100644
--- a/apps/portal/src/query/testSuitesQuery.ts
+++ b/apps/portal/src/query/testSuitesQuery.ts
@@ -13,8 +13,12 @@ const runKey = (projectId: string, routeId: string) => [
routeId,
];
-/** how often an unfinished run is re-read */
-const RUN_POLL_MS = 1_500;
+/**
+ * How often an unfinished run is re-read. Suite rows land one at a time, so a
+ * short interval is what makes them appear to fill in rather than arrive in
+ * batches; the request is a single indexed read.
+ */
+const RUN_POLL_MS = 500;
export const testSuitesQuery = {
getAll: {
@@ -92,6 +96,16 @@ export const testSuitesQuery = {
});
},
},
+ clearRuns: {
+ mutation(projectId: string, routeId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () => testSuitesService.clearRuns(projectId, routeId),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: runKey(projectId, routeId) }),
+ });
+ },
+ },
getRun: {
useQuery(
projectId: string,
diff --git a/apps/portal/src/routes/_authed/$projectId_.canvas.$routeId.tsx b/apps/portal/src/routes/_authed/$projectId_.canvas.$routeId.tsx
index f1dfd810..cd2dd670 100644
--- a/apps/portal/src/routes/_authed/$projectId_.canvas.$routeId.tsx
+++ b/apps/portal/src/routes/_authed/$projectId_.canvas.$routeId.tsx
@@ -8,6 +8,7 @@ import { CanvasWorkbench } from "@/components/canvas";
import { RouteApiPlayground } from "@/components/RouteApiPlayground";
import { RouteSettingsModal } from "@/components/routes/RouteSettingsModal";
import { RouteSwitcher } from "@/components/routes/RouteSwitcher";
+import { RouteWorkbenchTabs } from "@/components/routes/RouteWorkbenchTabs";
import { createRouteHead } from "@/lib/seo";
export const Route = createFileRoute("/_authed/$projectId_/canvas/$routeId")({
@@ -30,7 +31,12 @@ function RouteCanvasPage() {
playgroundContent={ }
reload={() => routesService.getCanvasItems(routeId)}
save={(payload) => save.mutateAsync(payload)}
- headerLeft={ }
+ headerLeft={
+ <>
+
+
+ >
+ }
headerActions={
setSettingsOpen(true)}>
Settings
diff --git a/apps/portal/src/routes/_authed/$projectId_.canvas.$routeId_.test-suites.tsx b/apps/portal/src/routes/_authed/$projectId_.canvas.$routeId_.test-suites.tsx
new file mode 100644
index 00000000..270cce23
--- /dev/null
+++ b/apps/portal/src/routes/_authed/$projectId_.canvas.$routeId_.test-suites.tsx
@@ -0,0 +1,20 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { TestSuitesWorkbench } from "@/components/testSuites/TestSuitesWorkbench";
+import { createRouteHead } from "@/lib/seo";
+
+export const Route = createFileRoute("/_authed/$projectId_/canvas/$routeId_/test-suites")({
+ head: createRouteHead("Route Tests", "Create, edit and run test suites for an API route."),
+ component: TestSuitesPage,
+});
+
+function TestSuitesPage() {
+ const { projectId, routeId } = Route.useParams();
+
+ // The workbench owns the topbar: the run controls and the suite count live
+ // there alongside the route switcher and the canvas/tests tabs.
+ return (
+
+
+
+ );
+}
diff --git a/apps/portal/src/services/testSuites.ts b/apps/portal/src/services/testSuites.ts
index 89b9f5ab..4739b676 100644
--- a/apps/portal/src/services/testSuites.ts
+++ b/apps/portal/src/services/testSuites.ts
@@ -87,6 +87,11 @@ export const testSuitesService = {
);
return result.data;
},
+ /** Clears every recorded run for the route. */
+ async clearRuns(projectId: string, routeId: string): Promise<{ deleted: number }> {
+ const result = await httpClient.delete(runsUrl(projectId, routeId));
+ return result.data;
+ },
async getRun(
projectId: string,
routeId: string,
diff --git a/apps/server/src/api/v1/test-suites/delete-runs/dto.ts b/apps/server/src/api/v1/test-suites/delete-runs/dto.ts
new file mode 100644
index 00000000..1d122982
--- /dev/null
+++ b/apps/server/src/api/v1/test-suites/delete-runs/dto.ts
@@ -0,0 +1,12 @@
+import { z } from "zod";
+
+/** Same path shape as the other run endpoints — authorization reads the project
+ * straight off the path, no database round trip. */
+export const requestParamSchema = z.object({
+ projectId: z.string(),
+ routeId: z.string(),
+});
+
+export const responseSchema = z.object({
+ deleted: z.number(),
+});
diff --git a/apps/server/src/api/v1/test-suites/delete-runs/repository.ts b/apps/server/src/api/v1/test-suites/delete-runs/repository.ts
new file mode 100644
index 00000000..de599fe1
--- /dev/null
+++ b/apps/server/src/api/v1/test-suites/delete-runs/repository.ts
@@ -0,0 +1,24 @@
+import { and, eq } from "drizzle-orm";
+import { db } from "../../../../db";
+import { testRunsEntity } from "../../../../db/schema";
+
+/**
+ * Clears the run history for one route. Scoped by project AND route, so a route
+ * id from another project deletes nothing.
+ *
+ * The child `test_suite_runs` rows cascade with the parent — see the foreign key
+ * in `db/schema.ts`.
+ */
+export async function deleteTestRuns(projectId: string, routeId: string) {
+ const deleted = await db
+ .delete(testRunsEntity)
+ .where(
+ and(
+ eq(testRunsEntity.projectId, projectId),
+ eq(testRunsEntity.routeId, routeId),
+ ),
+ )
+ .returning({ id: testRunsEntity.id });
+
+ return deleted.length;
+}
diff --git a/apps/server/src/api/v1/test-suites/delete-runs/route.ts b/apps/server/src/api/v1/test-suites/delete-runs/route.ts
new file mode 100644
index 00000000..fe174c07
--- /dev/null
+++ b/apps/server/src/api/v1/test-suites/delete-runs/route.ts
@@ -0,0 +1,36 @@
+import { describeRoute, resolver, validator } from "hono-openapi";
+import { requireProjectAccess } from "../../../auth/middleware";
+import { validationErrorSchema } from "../../../../errors/validationError";
+import zodErrorCallbackParser from "../../../../middlewares/zodErrorCallbackParser";
+import { HonoServer } from "../../../../types";
+import { requestParamSchema, responseSchema } from "./dto";
+import handleRequest from "./service";
+
+export default function (app: HonoServer) {
+ app.delete(
+ "/",
+ describeRoute({
+ description: "Clears every test run recorded for a route.",
+ operationId: "delete-test-runs",
+ tags: ["Test Suites"],
+ responses: {
+ 200: {
+ description: "Successful",
+ content: { "application/json": { schema: resolver(responseSchema) } },
+ },
+ 400: {
+ description: "Invalid data",
+ content: {
+ "application/json": { schema: resolver(validationErrorSchema) },
+ },
+ },
+ },
+ }),
+ requireProjectAccess("creator", { key: "projectId", source: "param" }),
+ validator("param", requestParamSchema, zodErrorCallbackParser),
+ async (ctx) => {
+ const { projectId, routeId } = ctx.req.valid("param");
+ return ctx.json(await handleRequest(projectId, routeId));
+ },
+ );
+}
diff --git a/apps/server/src/api/v1/test-suites/delete-runs/service.ts b/apps/server/src/api/v1/test-suites/delete-runs/service.ts
new file mode 100644
index 00000000..41a0fff5
--- /dev/null
+++ b/apps/server/src/api/v1/test-suites/delete-runs/service.ts
@@ -0,0 +1,10 @@
+import { ServerError } from "../../../../errors/serverError";
+import { deleteTestRuns } from "./repository";
+
+export default async function handleRequest(projectId: string, routeId: string) {
+ try {
+ return { deleted: await deleteTestRuns(projectId, routeId) };
+ } catch (err: any) {
+ throw new ServerError(err.message || "Failed to clear the run history");
+ }
+}
diff --git a/apps/server/src/api/v1/test-suites/register.ts b/apps/server/src/api/v1/test-suites/register.ts
index de2059cd..c897c180 100644
--- a/apps/server/src/api/v1/test-suites/register.ts
+++ b/apps/server/src/api/v1/test-suites/register.ts
@@ -6,6 +6,7 @@ import appGetAll from "./get-all/route";
import appStartRun from "./start-run/route";
import appGetRuns from "./get-runs/route";
import appGetRunById from "./get-run-by-id/route";
+import appDeleteRuns from "./delete-runs/route";
import { HonoServer } from "../../../types";
@@ -32,5 +33,6 @@ export default {
appStartRun(runsRouter);
appGetRuns(runsRouter);
appGetRunById(runsRouter);
+ appDeleteRuns(runsRouter);
},
};
diff --git a/packages/components/index.ts b/packages/components/index.ts
index 3c62bce7..3607b5b5 100644
--- a/packages/components/index.ts
+++ b/packages/components/index.ts
@@ -11,6 +11,7 @@ export * from "./src/ConditionsBuilder";
export * from "./src/FieldMapEditor";
export * from "./src/IntegrationSelector";
export * from "./src/JsonEditor";
+export * from "./src/CodeViewer";
export * from "./src/ArrayEditor";
export * from "./src/JoinsEditor";
export * from "./src/SchemaEditor";
diff --git a/packages/components/src/CodeViewer/CodeViewer.tsx b/packages/components/src/CodeViewer/CodeViewer.tsx
new file mode 100644
index 00000000..4a01b3a7
--- /dev/null
+++ b/packages/components/src/CodeViewer/CodeViewer.tsx
@@ -0,0 +1,66 @@
+import Editor from "@monaco-editor/react";
+import clsx from "clsx";
+
+export type CodeViewerProps = {
+ value: string;
+ /** Monaco language id — "json", "xml", "html", "plaintext"… */
+ language?: string;
+ /** Editor height. Defaults to 220px. */
+ height?: number | string;
+ showLineNumbers?: boolean;
+ className?: string;
+ /** Overrides the theme picked from the document. */
+ theme?: string;
+};
+
+function resolveTheme(theme?: string) {
+ if (theme) return theme;
+ if (typeof document === "undefined") return "vs-dark";
+ return document.documentElement.classList.contains("dark") ? "vs-dark" : "light";
+}
+
+/**
+ * Read-only Monaco for showing a payload. `JavaScriptTextArea` is the editable,
+ * JavaScript-only sibling; this one just renders text in whatever language it is
+ * handed.
+ */
+export function CodeViewer({
+ value,
+ language = "plaintext",
+ height = 220,
+ showLineNumbers = false,
+ className,
+ theme,
+}: CodeViewerProps) {
+ return (
+ event.stopPropagation()}
+ >
+
+
+ );
+}
diff --git a/packages/components/src/CodeViewer/index.ts b/packages/components/src/CodeViewer/index.ts
new file mode 100644
index 00000000..85840146
--- /dev/null
+++ b/packages/components/src/CodeViewer/index.ts
@@ -0,0 +1 @@
+export { CodeViewer, type CodeViewerProps } from "./CodeViewer";