diff --git a/apps/fabro-web/app/components/run-summary-panel.test.tsx b/apps/fabro-web/app/components/run-summary-panel.test.tsx new file mode 100644 index 000000000..80c37f8ff --- /dev/null +++ b/apps/fabro-web/app/components/run-summary-panel.test.tsx @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; + +import { + RunSummaryPanelView, + type RunSummaryPanelViewProps, +} from "./run-summary-panel"; + +function instanceText(instance: TestRenderer.ReactTestInstance): string { + const parts: string[] = []; + for (const child of instance.children) { + if (typeof child === "string") parts.push(child); + else parts.push(instanceText(child)); + } + return parts.join(""); +} + +function render(props: Partial = {}) { + const full: RunSummaryPanelViewProps = { + run: null, + runLoading: false, + sandboxResources: null, + sandboxLoading: false, + artifactsCount: null, + artifactsLoading: false, + ...props, + }; + let tree: TestRenderer.ReactTestRenderer | undefined; + act(() => { + tree = TestRenderer.create(); + }); + return tree!; +} + +function cellAfterLabel( + tree: TestRenderer.ReactTestRenderer, + label: string, +): TestRenderer.ReactTestInstance { + const labelNode = tree.root.find( + (node) => + node.type === "div" && + node.children.length === 1 && + typeof node.children[0] === "string" && + node.children[0] === label, + ); + const parent = labelNode.parent; + if (!parent) throw new Error(`Could not find parent of label "${label}"`); + return parent.children[1] as TestRenderer.ReactTestInstance; +} + +function makeRun(overrides: Record = {}) { + return { + id: "run_1", + created_by: null, + diff: null, + billing: null, + ...overrides, + } as any; +} + +const EM_DASH = "—"; + +describe("RunSummaryPanelView", () => { + test("renders all five column labels", () => { + const tree = render(); + const rendered = JSON.stringify(tree.toJSON()); + for (const label of ["Created by", "Changes", "Sandbox", "Cost", "Artifacts"]) { + expect(rendered).toContain(label); + } + }); + + test("shows em dash for missing run fields after load", () => { + const tree = render({ run: makeRun() }); + expect(instanceText(cellAfterLabel(tree, "Created by"))).toBe(EM_DASH); + expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe(EM_DASH); + expect(instanceText(cellAfterLabel(tree, "Cost"))).toBe(EM_DASH); + }); + + test("shows em dash when sandbox is absent", () => { + const tree = render({ run: makeRun(), sandboxResources: null }); + expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe(EM_DASH); + }); + + test("shows em dash when artifacts count is zero", () => { + const tree = render({ run: makeRun(), artifactsCount: 0 }); + expect(instanceText(cellAfterLabel(tree, "Artifacts"))).toBe(EM_DASH); + }); + + test("renders diff additions/deletions/files with correct formatting", () => { + const tree = render({ + run: makeRun({ + diff: { additions: 124, deletions: 37, files_changed: 7 }, + }), + }); + expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe( + "+124 −37in 7 files", + ); + }); + + test("singular 'file' when files_changed is 1", () => { + const tree = render({ + run: makeRun({ + diff: { additions: 3, deletions: 0, files_changed: 1 }, + }), + }); + expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe( + "+3 −0in 1 file", + ); + }); + + test("renders cost from total_usd_micros", () => { + const tree = render({ + run: makeRun({ billing: { total_usd_micros: 840_000 } }), + }); + expect(instanceText(cellAfterLabel(tree, "Cost"))).toBe("$0.84"); + }); + + test("renders sandbox CPU and memory", () => { + const tree = render({ + run: makeRun(), + sandboxResources: { cpu_cores: 4, memory_bytes: 8 * 1024 * 1024 * 1024 } as any, + }); + expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("4 CPU · 8 GiB"); + }); + + test("renders artifacts count when positive", () => { + const tree = render({ run: makeRun(), artifactsCount: 3 }); + expect(instanceText(cellAfterLabel(tree, "Artifacts"))).toBe("3"); + }); + + test("renders user actor with login initial", () => { + const tree = render({ + run: makeRun({ + created_by: { + kind: "user", + identity: { issuer: "github", subject: "1" }, + login: "brynary", + auth_method: "oauth", + }, + }), + }); + expect(instanceText(cellAfterLabel(tree, "Created by"))).toBe("Bbrynary"); + }); + + test("renders non-user actor with kind label", () => { + for (const kind of ["agent", "system", "slack", "webhook", "worker", "anonymous"]) { + const tree = render({ run: makeRun({ created_by: { kind } as any }) }); + expect(instanceText(cellAfterLabel(tree, "Created by"))).toContain(kind); + } + }); + + test("shows skeleton placeholders while queries are loading", () => { + const tree = render({ + runLoading: true, + sandboxLoading: true, + artifactsLoading: true, + }); + const rendered = JSON.stringify(tree.toJSON()); + expect(rendered).toContain("animate-pulse"); + expect(instanceText(cellAfterLabel(tree, "Created by"))).not.toContain(EM_DASH); + expect(instanceText(cellAfterLabel(tree, "Sandbox"))).not.toContain(EM_DASH); + expect(instanceText(cellAfterLabel(tree, "Artifacts"))).not.toContain(EM_DASH); + }); +}); diff --git a/apps/fabro-web/app/components/run-summary-panel.tsx b/apps/fabro-web/app/components/run-summary-panel.tsx new file mode 100644 index 000000000..d9d43ab95 --- /dev/null +++ b/apps/fabro-web/app/components/run-summary-panel.tsx @@ -0,0 +1,204 @@ +import type { ReactNode } from "react"; +import { + BoltIcon, + ChatBubbleLeftEllipsisIcon, + Cog6ToothIcon, + CpuChipIcon, + QuestionMarkCircleIcon, + ServerIcon, +} from "@heroicons/react/20/solid"; +import type { Principal, Run, SandboxResources } from "@qltysh/fabro-api-client"; + +import { + formatBytesAsMemory, + formatCpuCores, + formatUsdMicros, +} from "../lib/format"; +import { useRun, useRunArtifacts, useRunSandboxDetails } from "../lib/queries"; + +const LABEL_CLASS = + "text-[10px] font-medium uppercase tracking-[0.08em] text-fg-muted"; +const VALUE_WRAPPER_CLASS = "mt-1.5"; +const VALUE_CLASS = "text-sm text-fg"; +const VALUE_MONO_CLASS = "text-sm text-fg font-mono tabular-nums"; +const EM_DASH_CLASS = "text-sm text-fg-muted font-mono"; + +function EmDash() { + return —; +} + +function Skeleton({ widthClass }: { widthClass: string }) { + return ( + {demoMode && } + {run.pullRequestUrl && run.number != null && ( + + + #{run.number} + + )} + -
+
+ {graphSvg === undefined && graphQuery.isLoading ? (
) : graphSvg ? ( diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx index 1ae03e0e6..c40763916 100644 --- a/apps/fabro-web/app/routes/run-sandbox.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx @@ -80,8 +80,9 @@ mock.module("@pierre/diffs/react", () => ({ ), })); -const { default: RunSandbox, formatBytesAsMemory, normalizeSandboxMode } = +const { default: RunSandbox, normalizeSandboxMode } = await import("./run-sandbox"); +const { formatBytesAsMemory } = await import("../lib/format"); mock.restore(); const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; diff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx index 8cc0dbc24..3a8402e12 100644 --- a/apps/fabro-web/app/routes/run-sandbox.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.tsx @@ -4,7 +4,11 @@ import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; import TerminalView, { TERMINAL_DOCK_CLEARANCE_CLASS } from "../components/terminal-view"; import { EmptyState, ErrorState } from "../components/state"; -import { formatAbsoluteTs } from "../lib/format"; +import { + formatAbsoluteTs, + formatBytesAsMemory, + formatCpuCores, +} from "../lib/format"; import { useRunSandboxDetails } from "../lib/queries"; import type { SandboxDetails, @@ -52,25 +56,6 @@ const STATE_DISPLAY: Record= BYTES_PER_GIB) { - const gib = bytes / BYTES_PER_GIB; - return `${Number.isInteger(gib) ? gib : gib.toFixed(1)} GiB`; - } - if (bytes >= BYTES_PER_MIB) { - const mib = bytes / BYTES_PER_MIB; - return `${Number.isInteger(mib) ? mib : mib.toFixed(1)} MiB`; - } - return `${bytes} B`; -} - -function formatCpuCores(cores: number): string { - return Number.isInteger(cores) ? cores.toString() : cores.toFixed(2); -} - function nullable(value: string | null | undefined): string { return value && value.length > 0 ? value : EMPTY_VALUE; }