diff --git a/frontend/src/viewers/CteResultViewer.test.tsx b/frontend/src/viewers/CteResultViewer.test.tsx
index e56508c1..5b8fb6b9 100644
--- a/frontend/src/viewers/CteResultViewer.test.tsx
+++ b/frontend/src/viewers/CteResultViewer.test.tsx
@@ -76,6 +76,16 @@ test("shows empty-state when preview has no rows", async () => {
expect(screen.getByText(/no preview rows/i)).toBeInTheDocument();
});
+test("shows empty-state, no crash, when preview.rows is null (pre-upgrade records)", async () => {
+ const nullRows = {
+ ...okResult,
+ preview: { columns: ["paziente_id"], rows: null as unknown as (string | number)[][] },
+ };
+ render();
+ await screen.findByTestId("hl");
+ expect(screen.getByText(/no preview rows/i)).toBeInTheDocument();
+});
+
test("renders columns definition list with descriptions", async () => {
const { container } = render();
await screen.findByTestId("hl");
diff --git a/frontend/src/viewers/CteResultViewer.tsx b/frontend/src/viewers/CteResultViewer.tsx
index 23f8f2a7..7dc167b4 100644
--- a/frontend/src/viewers/CteResultViewer.tsx
+++ b/frontend/src/viewers/CteResultViewer.tsx
@@ -1,16 +1,11 @@
import { Badge } from "../components/ui/badge";
import { SqlViewer, type SqlBlock } from "./SqlViewer";
import { PreviewGrid } from "./PreviewGrid";
+import { statusBadgeClass } from "./statusBadge";
import type { CteResultV2 } from "./artifactV2";
const CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
-function statusBadgeVariant(status: string): "default" | "destructive" | "outline" {
- if (status === "ok") return "default";
- if (status === "error") return "destructive";
- return "outline";
-}
-
function statusLabel(status: string): string {
if (status === "ok") return "success";
if (status === "error") return "error";
@@ -24,7 +19,7 @@ function testStatus(status: string): SqlBlock["testStatus"] {
}
export function CteResultViewer({ result }: { result: CteResultV2 }) {
- const hasPreviewRows = !!result.preview && result.preview.rows.length > 0;
+ const hasPreviewRows = (result.preview?.rows?.length ?? 0) > 0;
const descriptions = (result.columns ?? []).map((c) => c.description ?? "");
const showColumns = descriptions.some((d) => d.trim() !== "");
const dependsOn = result.depends_on ?? [];
@@ -42,7 +37,7 @@ export function CteResultViewer({ result }: { result: CteResultV2 }) {
CTE {result.index}/{result.total}
{result.name}
- {statusLabel(result.status)}
+ {statusLabel(result.status)}
{result.execution_ms !== undefined && (
{result.execution_ms} ms
)}
diff --git a/frontend/src/viewers/PhaseSummaryViewer.test.tsx b/frontend/src/viewers/PhaseSummaryViewer.test.tsx
index d3380f1a..fc33a001 100644
--- a/frontend/src/viewers/PhaseSummaryViewer.test.tsx
+++ b/frontend/src/viewers/PhaseSummaryViewer.test.tsx
@@ -81,6 +81,16 @@ test("renders open_questions as a bullet list", () => {
expect(screen.getByText("Serve confermare la finestra temporale?")).toBeInTheDocument();
});
+test("renders a section with title only (no items key) without crashing", () => {
+ const proseOnly: PhaseSummaryV2 = {
+ schema_version: 2,
+ phase: { id: "F5", num: 5, name: "sintesi" },
+ sections: [{ title: "Note libere" }],
+ };
+ render();
+ expect(screen.getByText("Note libere")).toBeInTheDocument();
+});
+
test("omits open_questions section when empty and shows no 'undefined' text", () => {
const minimal: PhaseSummaryV2 = {
schema_version: 2,
diff --git a/frontend/src/viewers/PhaseSummaryViewer.tsx b/frontend/src/viewers/PhaseSummaryViewer.tsx
index 51fc8a35..6badb2cd 100644
--- a/frontend/src/viewers/PhaseSummaryViewer.tsx
+++ b/frontend/src/viewers/PhaseSummaryViewer.tsx
@@ -1,19 +1,14 @@
import { Badge } from "../components/ui/badge";
import { MarkdownView } from "./MarkdownView";
+import { statusBadgeClass } from "./statusBadge";
import type { PhaseCheck, PhaseSection, PhaseSectionItem, PhaseSummaryV2, PhaseTable } from "./artifactV2";
const CODE_CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
-function checkBadgeVariant(status: string): "default" | "destructive" | "outline" {
- if (status === "ok") return "default";
- if (status === "fail") return "destructive";
- return "outline";
-}
-
function Check({ check }: { check: PhaseCheck }) {
return (
-
{check.status}
+
{check.status}
{check.label}
{check.detail &&
{check.detail}}
@@ -42,7 +37,7 @@ function Section({ section }: { section: PhaseSection }) {
{section.title}
- {section.items.map((item, i) => (
+ {(section.items ?? []).map((item, i) => (
))}
@@ -55,7 +50,7 @@ function TableRecap({ table }: { table: PhaseTable }) {
{table.name}
- {table.role}
+ {table.role}
{table.description &&
{table.description}
}
{table.columns && table.columns.length > 0 && (
diff --git a/frontend/src/viewers/SqlViewer.tsx b/frontend/src/viewers/SqlViewer.tsx
index 3e371cdf..8778b551 100644
--- a/frontend/src/viewers/SqlViewer.tsx
+++ b/frontend/src/viewers/SqlViewer.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { highlightSql } from "./highlight";
+import { statusBadgeClass } from "./statusBadge";
export interface SqlBlock {
name: string;
@@ -17,12 +18,6 @@ const STATUS_LABELS: Record
= {
untested: "untested",
};
-const STATUS_CLASSES: Record = {
- passed: "bg-[oklch(var(--success)/0.15)] text-[oklch(0.45_0.12_165)] ring-1 ring-[oklch(var(--success)/0.3)]",
- failed: "bg-destructive/10 text-destructive ring-1 ring-destructive/25",
- untested: "bg-muted text-muted-foreground ring-1 ring-border",
-};
-
function Block({ block }: { block: SqlBlock }) {
const [open, setOpen] = useState(true);
const [html, setHtml] = useState("");
@@ -39,7 +34,7 @@ function Block({ block }: { block: SqlBlock }) {
const badge = block.testStatus ? STATUS_LABELS[block.testStatus] : null;
const badgeClass = block.testStatus
- ? STATUS_CLASSES[block.testStatus]
+ ? statusBadgeClass(block.testStatus)
: "";
return (
diff --git a/frontend/src/viewers/artifactV2.ts b/frontend/src/viewers/artifactV2.ts
index 4dee0963..caca0e09 100644
--- a/frontend/src/viewers/artifactV2.ts
+++ b/frontend/src/viewers/artifactV2.ts
@@ -94,7 +94,7 @@ export interface PhaseSectionItem {
export interface PhaseSection {
title: string;
- items: PhaseSectionItem[];
+ items?: PhaseSectionItem[];
}
export interface PhaseTableColumn {
diff --git a/frontend/src/viewers/statusBadge.ts b/frontend/src/viewers/statusBadge.ts
new file mode 100644
index 00000000..3338536a
--- /dev/null
+++ b/frontend/src/viewers/statusBadge.ts
@@ -0,0 +1,25 @@
+// Shared status → badge class mapping so success/warning/error colors can't drift
+// between SqlViewer, CteResultViewer and PhaseSummaryViewer.
+// ok/success/passed/promoted -> green (--success)
+// warn/untested-with-warning -> amber (--warning)
+// error/failed -> destructive/red
+// anything else -> neutral outline
+const SUCCESS_STATUSES = new Set(["ok", "success", "passed", "promoted"]);
+const WARN_STATUSES = new Set(["warn", "warning"]);
+const ERROR_STATUSES = new Set(["error", "failed", "fail"]);
+
+export const STATUS_BADGE_CLASSES = {
+ success:
+ "bg-[oklch(var(--success)/0.15)] text-[oklch(0.45_0.12_165)] ring-1 ring-[oklch(var(--success)/0.3)]",
+ warning:
+ "bg-[oklch(var(--warning)/0.15)] text-[oklch(0.46_0.11_79)] ring-1 ring-[oklch(var(--warning)/0.4)]",
+ error: "bg-destructive/10 text-destructive ring-1 ring-destructive/25",
+ neutral: "bg-muted text-muted-foreground ring-1 ring-border",
+} as const;
+
+export function statusBadgeClass(status: string): string {
+ if (SUCCESS_STATUSES.has(status)) return STATUS_BADGE_CLASSES.success;
+ if (WARN_STATUSES.has(status)) return STATUS_BADGE_CLASSES.warning;
+ if (ERROR_STATUSES.has(status)) return STATUS_BADGE_CLASSES.error;
+ return STATUS_BADGE_CLASSES.neutral;
+}
diff --git a/harness/.pi/extensions/gate/__tests__/enrich.test.js b/harness/.pi/extensions/gate/__tests__/enrich.test.js
index 6a4bfdf6..b5394f61 100644
--- a/harness/.pi/extensions/gate/__tests__/enrich.test.js
+++ b/harness/.pi/extensions/gate/__tests__/enrich.test.js
@@ -132,6 +132,15 @@ test("buildCteResultV2 builds preview from last_test.columns/preview_rows", () =
assert.deepEqual(out.warnings, []);
});
+test("buildCteResultV2: last_test.preview_rows null (pre-upgrade ok records) -> preview.rows is []", () => {
+ const cteInfo = {
+ name: "a", index: 1, total: 1, sql: "WITH a AS (SELECT 1)", doc: null,
+ last_test: { status: "ok", execution_ms: 1, row_sample: 1, warnings: [], sql_hash: "h", columns: ["cod_paz"], preview_rows: null },
+ };
+ const out = buildCteResultV2({ schema_version: 2 }, cteInfo);
+ assert.deepEqual(out.preview.rows, []);
+});
+
test("buildCteResultV2: columns[] carries name, with description left as an empty placeholder (filled separately by the gate)", () => {
const cteInfo = {
name: "a", index: 1, total: 1, sql: "WITH a AS (SELECT 1)", doc: null,
diff --git a/harness/.pi/extensions/gate/enrich.js b/harness/.pi/extensions/gate/enrich.js
index fd629d0f..f90e47bf 100644
--- a/harness/.pi/extensions/gate/enrich.js
+++ b/harness/.pi/extensions/gate/enrich.js
@@ -85,7 +85,7 @@ function buildCteResultV2(thinData, cteInfo) {
warnings: lt.warnings || [],
sql_hash: lt.sql_hash,
columns: (lt.columns || []).map((name) => ({ name, description: "" })),
- preview: { columns: lt.columns || [], rows: lt.preview_rows || null },
+ preview: { columns: lt.columns || [], rows: lt.preview_rows || [] },
note: thin.note,
};
}