diff --git a/apps/fabro-web/app/components/runs-list/row-actions-menu.test.tsx b/apps/fabro-web/app/components/runs-list/row-actions-menu.test.tsx new file mode 100644 index 000000000..774219f6e --- /dev/null +++ b/apps/fabro-web/app/components/runs-list/row-actions-menu.test.tsx @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { createElement } from "react"; +import TestRenderer, { act } from "react-test-renderer"; +import { SWRConfig } from "swr"; + +import { ToastProvider } from "../toast"; +import { setupReactTestEnv } from "../../lib/test-utils"; +import type { RunWithStatus } from "../../data/runs"; +import { RowActionsMenu } from "./row-actions-menu"; + +// Render Headless UI's Menu primitives inline so menu items are always in the +// tree regardless of open state — we only care which actions the menu offers +// for a given run status, not the open/close interaction. +mock.module("@headlessui/react", () => ({ + Menu: ({ children }: any) => + createElement("div", null, typeof children === "function" ? children({ open: true }) : children), + MenuButton: ({ children, ...props }: any) => + createElement("button", props, typeof children === "function" ? children({ open: true }) : children), + MenuItems: ({ children }: any) => + createElement("div", null, typeof children === "function" ? children({ open: true }) : children), + MenuItem: ({ children }: any) => + createElement("div", null, typeof children === "function" ? children({ close: () => {}, active: false }) : children), + Dialog: ({ open, children }: any) => (open ? createElement("div", { role: "dialog" }, children) : null), + DialogPanel: ({ children, ...props }: any) => createElement("div", props, children), + DialogTitle: ({ children, ...props }: any) => createElement("h2", props, children), +})); + +let teardownReactEnv: (() => void) | undefined; + +function makeRunWithStatus( + status: { kind: string; reason?: string }, + archived = false, +): RunWithStatus { + return { + id: "run-1", + title: "Fix the build", + lifecycleStatus: archived ? "archived" : status.kind, + pendingApproval: false, + lifecycle: { + status, + approval: null, + pending_control: null, + queue_position: null, + error: null, + archived, + archived_at: archived ? "2026-04-20T12:05:00Z" : null, + }, + } as unknown as RunWithStatus; +} + +function render(node: React.ReactNode): TestRenderer.ReactTestRenderer { + let tree: TestRenderer.ReactTestRenderer | undefined; + act(() => { + tree = TestRenderer.create( + new Map(), dedupingInterval: 0 }}> + {node} + , + ); + }); + return tree!; +} + +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 menuItemLabels(tree: TestRenderer.ReactTestRenderer): string[] { + return tree.root.findAllByType("button").map((b) => instanceText(b).trim()); +} + +describe("RowActionsMenu retry gating", () => { + beforeEach(() => { + teardownReactEnv = setupReactTestEnv(); + }); + afterEach(() => { + teardownReactEnv?.(); + teardownReactEnv = undefined; + }); + + test("offers Retry for a succeeded run", () => { + const tree = render( + , + ); + expect(menuItemLabels(tree)).toContain("Retry"); + }); + + test("still offers Retry for failed and dead runs", () => { + const failed = render( + , + ); + expect(menuItemLabels(failed)).toContain("Retry"); + + const dead = render(); + expect(menuItemLabels(dead)).toContain("Retry"); + }); + + test("does not offer Retry for an archived (non-retryable) run", () => { + const tree = render( + , + ); + expect(menuItemLabels(tree)).not.toContain("Retry"); + }); + + test("does not offer Retry for a still-running run", () => { + const tree = render(); + expect(menuItemLabels(tree)).not.toContain("Retry"); + }); +}); diff --git a/apps/fabro-web/app/components/runs-list/row-actions-menu.tsx b/apps/fabro-web/app/components/runs-list/row-actions-menu.tsx index b1582dbef..05a0917b6 100644 --- a/apps/fabro-web/app/components/runs-list/row-actions-menu.tsx +++ b/apps/fabro-web/app/components/runs-list/row-actions-menu.tsx @@ -11,6 +11,7 @@ import { canArchive, canCancel, canDelete, + canRetryStatus, canUnarchive, cancelRun, deleteRun, @@ -39,7 +40,10 @@ export function RowActionsMenu({ run }: { run: RunWithStatus }) { const status = run.lifecycleStatus; const showApprove = run.pendingApproval === true; const showDeny = run.pendingApproval === true; - const showRetry = status === "failed" || status === "dead"; + // Any terminal, non-archived run can be retried (the server re-creates a + // fresh run from the stored spec) — including succeeded ones. Row data is the + // flattened lifecycle status string, so use the string-form predicate. + const showRetry = canRetryStatus(status); const showArchive = canArchive(status); const showUnarchive = canUnarchive(status); const showCancel = canCancel(status); diff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts index d88fdd3af..46c8d82f3 100644 --- a/apps/fabro-web/app/lib/run-actions.test.ts +++ b/apps/fabro-web/app/lib/run-actions.test.ts @@ -14,6 +14,7 @@ import { canApprove, canCancel, canRetry, + canRetryStatus, canUnarchive, cancelRun, deleteRuns, @@ -409,6 +410,13 @@ describe("run lifecycle actions", () => { expect(canUnarchive("archived")).toBe(true); expect(canUnarchive("failed")).toBe(false); + + expect(canRetryStatus("succeeded")).toBe(true); + expect(canRetryStatus("failed")).toBe(true); + expect(canRetryStatus("dead")).toBe(true); + expect(canRetryStatus("running")).toBe(false); + expect(canRetryStatus("archived")).toBe(false); + expect(canRetryStatus(null)).toBe(false); }); test("approval predicate requires pending status and pending approval state", () => { diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts index d51c1210a..f132b4b97 100644 --- a/apps/fabro-web/app/lib/run-actions.ts +++ b/apps/fabro-web/app/lib/run-actions.ts @@ -155,6 +155,16 @@ export function canRetry(run: Pick | null | undefined): boolea return status.kind === "succeeded" || status.kind === "failed" || status.kind === "dead"; } +/** + * String-status form of {@link canRetry} for list rows that only carry the + * flattened `lifecycleStatus` (no full `lifecycle` object). A run is retryable + * once it reaches a terminal, non-archived state — archived rows surface as + * `"archived"`, so they're naturally excluded. + */ +export function canRetryStatus(status: string | null | undefined): boolean { + return status === "succeeded" || status === "failed" || status === "dead"; +} + export function canDelete(status: string | null | undefined): boolean { return status === "archived"; }