From ecc0d07127336c9e7c10fe8bc31eb9d5b300e729 Mon Sep 17 00:00:00 2001 From: Prajwal Aradhya Date: Sun, 16 Aug 2026 23:44:41 +0100 Subject: [PATCH] feat(portal): test suite UI with async runs and result history Adds the test-suite workbench the portal was missing (#227): a sibling route at /$projectId/canvas/$routeId/test-suites with a tab switcher in the canvas topbar, so suites are reachable without leaving the route. - Suite CRUD, mock request (path params read from the route's own segments), assertions and per-suite overrides. - Assertion target/operator rules mirror the server superRefine, so an invalid pair is never offered and a missing expected value blocks the save instead of returning a 400. - Runs are queued and polled; suite rows render as they settle, with timeout and error runs explaining themselves rather than showing an empty result. - Response body renders by content type: Monaco for text, native elements for media, a download for binary. - New DELETE /v1/:projectId/test-suites/route/:routeId/runs clears a route's run history; the portal had no way to do that. - CodeViewer (read-only Monaco) added to packages/components rather than pulling monaco into the portal. Co-Authored-By: Claude Opus 5 --- .../components/routes/RouteWorkbenchTabs.tsx | 60 +++ .../testSuites/AssertionsEditor.tsx | 201 ++++++++++ .../components/testSuites/OverridesEditor.tsx | 202 ++++++++++ .../components/testSuites/RequestEditor.tsx | 87 ++++ .../components/testSuites/ResponseViewer.tsx | 164 ++++++++ .../src/components/testSuites/RunResults.tsx | 339 ++++++++++++++++ .../testSuites/TestSuitesWorkbench.tsx | 373 ++++++++++++++++++ .../components/testSuites/assertions.test.ts | 85 ++++ .../src/components/testSuites/assertions.ts | 167 ++++++++ .../portal/src/components/testSuites/types.ts | 33 ++ apps/portal/src/query/testSuitesQuery.ts | 18 +- .../_authed/$projectId_.canvas.$routeId.tsx | 8 +- ...rojectId_.canvas.$routeId_.test-suites.tsx | 20 + apps/portal/src/services/testSuites.ts | 5 + .../src/api/v1/test-suites/delete-runs/dto.ts | 12 + .../v1/test-suites/delete-runs/repository.ts | 24 ++ .../api/v1/test-suites/delete-runs/route.ts | 36 ++ .../api/v1/test-suites/delete-runs/service.ts | 10 + .../server/src/api/v1/test-suites/register.ts | 2 + packages/components/index.ts | 1 + .../components/src/CodeViewer/CodeViewer.tsx | 66 ++++ packages/components/src/CodeViewer/index.ts | 1 + 22 files changed, 1911 insertions(+), 3 deletions(-) create mode 100644 apps/portal/src/components/routes/RouteWorkbenchTabs.tsx create mode 100644 apps/portal/src/components/testSuites/AssertionsEditor.tsx create mode 100644 apps/portal/src/components/testSuites/OverridesEditor.tsx create mode 100644 apps/portal/src/components/testSuites/RequestEditor.tsx create mode 100644 apps/portal/src/components/testSuites/ResponseViewer.tsx create mode 100644 apps/portal/src/components/testSuites/RunResults.tsx create mode 100644 apps/portal/src/components/testSuites/TestSuitesWorkbench.tsx create mode 100644 apps/portal/src/components/testSuites/assertions.test.ts create mode 100644 apps/portal/src/components/testSuites/assertions.ts create mode 100644 apps/portal/src/components/testSuites/types.ts create mode 100644 apps/portal/src/routes/_authed/$projectId_.canvas.$routeId_.test-suites.tsx create mode 100644 apps/server/src/api/v1/test-suites/delete-runs/dto.ts create mode 100644 apps/server/src/api/v1/test-suites/delete-runs/repository.ts create mode 100644 apps/server/src/api/v1/test-suites/delete-runs/route.ts create mode 100644 apps/server/src/api/v1/test-suites/delete-runs/service.ts create mode 100644 packages/components/src/CodeViewer/CodeViewer.tsx create mode 100644 packages/components/src/CodeViewer/index.ts 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 ( +
+ {children} +
+ ); +} 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 */} +
+ + + {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 })} + /> + )} + + + + {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.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 ( +
+
+
+
+ +

+ Test-only values for existing config keys. Applied before integrations resolve. +

+
+ +
+ + {appConfigOverrides.map((override, index) => ( + // eslint-disable-next-line react/no-array-index-key -- overrides are an ordered list with no id +
+ + {/* these are credentials in practice — never render them in plain text */} + patchConfig(index, { value: e.target.value })} + /> + + onChange({ + appConfigOverrides: appConfigOverrides.filter((_, i) => i !== index), + }) + } + /> +
+ ))} +
+ +
+
+
+ +

+ Swap an integration for another one, for this suite only. +

+
+ +
+ + {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 && } + +
+ ))} + + 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 ( +
+
+ +