mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
feat(web): allow retrying any terminal run from the runs list
The row actions menu only offered Retry for `failed`/`dead` runs, even though the server re-creates a fresh run from the stored spec for any terminal, non-archived run — succeeded ones included. Add a string-status `canRetryStatus` predicate (mirroring the existing `canRetry`, which needs the full lifecycle object) for list rows that carry only the flattened `lifecycleStatus`, and use it to gate the Retry action. Archived rows surface as `"archived"` and stay excluded.
This commit is contained in:
parent
6529f120de
commit
2297f34f9d
4 changed files with 136 additions and 1 deletions
|
|
@ -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(
|
||||
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>
|
||||
<ToastProvider>{node}</ToastProvider>
|
||||
</SWRConfig>,
|
||||
);
|
||||
});
|
||||
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(
|
||||
<RowActionsMenu run={makeRunWithStatus({ kind: "succeeded", reason: "completed" })} />,
|
||||
);
|
||||
expect(menuItemLabels(tree)).toContain("Retry");
|
||||
});
|
||||
|
||||
test("still offers Retry for failed and dead runs", () => {
|
||||
const failed = render(
|
||||
<RowActionsMenu run={makeRunWithStatus({ kind: "failed", reason: "workflow_error" })} />,
|
||||
);
|
||||
expect(menuItemLabels(failed)).toContain("Retry");
|
||||
|
||||
const dead = render(<RowActionsMenu run={makeRunWithStatus({ kind: "dead" })} />);
|
||||
expect(menuItemLabels(dead)).toContain("Retry");
|
||||
});
|
||||
|
||||
test("does not offer Retry for an archived (non-retryable) run", () => {
|
||||
const tree = render(
|
||||
<RowActionsMenu run={makeRunWithStatus({ kind: "succeeded", reason: "completed" }, true)} />,
|
||||
);
|
||||
expect(menuItemLabels(tree)).not.toContain("Retry");
|
||||
});
|
||||
|
||||
test("does not offer Retry for a still-running run", () => {
|
||||
const tree = render(<RowActionsMenu run={makeRunWithStatus({ kind: "running" })} />);
|
||||
expect(menuItemLabels(tree)).not.toContain("Retry");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -155,6 +155,16 @@ export function canRetry(run: Pick<Run, "lifecycle"> | 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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue