mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Compare commits
4 commits
4d115c17ee
...
2ebf4577a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ebf4577a0 | ||
|
|
2fa4ee9899 | ||
|
|
8067b18e04 | ||
|
|
2297f34f9d |
13 changed files with 582 additions and 16 deletions
|
|
@ -4,7 +4,10 @@ import { createInitialDraft } from "../state/draft";
|
|||
import { renderProjectToml, renderWorkflowToml } from "./render-toml";
|
||||
|
||||
describe("renderWorkflowToml", () => {
|
||||
test("points the workflow at workflow.fabro and pins sandbox to local", () => {
|
||||
test("points the workflow at its graph without an unsupported [run.sandbox] section", () => {
|
||||
// The server's RunLayer parses workflow.toml with deny_unknown_fields and
|
||||
// has no `sandbox` field, so a `[run.sandbox]` section makes the manifest
|
||||
// unparseable. Sandbox selection lives in project.toml instead.
|
||||
expect(renderWorkflowToml(createInitialDraft())).toBe(
|
||||
[
|
||||
"_version = 1",
|
||||
|
|
@ -12,16 +15,13 @@ describe("renderWorkflowToml", () => {
|
|||
"[workflow]",
|
||||
'graph = "workflow.fabro"',
|
||||
"",
|
||||
"[run.sandbox]",
|
||||
'provider = "local"',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderProjectToml", () => {
|
||||
test("enables draft PRs by default", () => {
|
||||
test("enables draft PRs and pins the default environment to the local sandbox", () => {
|
||||
expect(renderProjectToml(createInitialDraft())).toBe(
|
||||
[
|
||||
"_version = 1",
|
||||
|
|
@ -30,6 +30,9 @@ describe("renderProjectToml", () => {
|
|||
"enabled = true",
|
||||
"draft = true",
|
||||
"",
|
||||
"[environments.default]",
|
||||
'provider = "local"',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ import type { WorkflowDraft } from "../state/draft";
|
|||
/**
|
||||
* The contents of `.fabro/workflows/<name>/workflow.toml`.
|
||||
*
|
||||
* Points the workflow at its `.fabro` graph and pins the sandbox provider to
|
||||
* `local` so the downloaded artifact runs against the user's own machine
|
||||
* without any further setup.
|
||||
* Points the workflow at its `.fabro` graph. Sandbox selection deliberately
|
||||
* does NOT live here: the server parses this file into a `RunLayer` with
|
||||
* `deny_unknown_fields` and no `sandbox` field, so a `[run.sandbox]` section
|
||||
* makes the whole run manifest unparseable. The local sandbox is pinned at the
|
||||
* project level instead — see `renderProjectToml`.
|
||||
*/
|
||||
export function renderWorkflowToml(_draft: WorkflowDraft): string {
|
||||
return [
|
||||
|
|
@ -23,17 +25,17 @@ export function renderWorkflowToml(_draft: WorkflowDraft): string {
|
|||
"[workflow]",
|
||||
'graph = "workflow.fabro"',
|
||||
"",
|
||||
"[run.sandbox]",
|
||||
'provider = "local"',
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The contents of `.fabro/project.toml`.
|
||||
*
|
||||
* Mirrors the defaults shown in the explainer: PRs enabled and draft, so
|
||||
* a successful run opens a draft PR the user can review.
|
||||
* Mirrors the defaults shown in the explainer: PRs enabled and draft, so a
|
||||
* successful run opens a draft PR the user can review. Also pins the default
|
||||
* environment to the `local` sandbox so the workflow runs against the user's
|
||||
* own machine without any further setup (the supported home for sandbox
|
||||
* selection, unlike workflow.toml's rejected `[run.sandbox]`).
|
||||
*/
|
||||
export function renderProjectToml(_draft: WorkflowDraft): string {
|
||||
return [
|
||||
|
|
@ -43,5 +45,8 @@ export function renderProjectToml(_draft: WorkflowDraft): string {
|
|||
"enabled = true",
|
||||
"draft = true",
|
||||
"",
|
||||
"[environments.default]",
|
||||
'provider = "local"',
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import DownloadButton from "./ui/download-button";
|
|||
import NodeInspector from "./ui/node-inspector";
|
||||
import ResetButton from "./ui/reset-button";
|
||||
import RunForRealButton, { type RealRunRedirect } from "./ui/run-for-real-button";
|
||||
import SaveToRunsButton from "./ui/save-to-runs-button";
|
||||
import RunTrace from "./ui/run-trace";
|
||||
import SimulationControls from "./ui/simulation-controls";
|
||||
import WorkflowHeader from "./ui/workflow-header";
|
||||
|
|
@ -113,6 +114,7 @@ export default function Playground({
|
|||
<div className="ml-auto flex items-center gap-2">
|
||||
<ResetButton onReset={handleReset} />
|
||||
<DownloadButton draft={draft} />
|
||||
<SaveToRunsButton draft={draft} />
|
||||
<RunForRealButton draft={draft} redirect={realRunRedirect} />
|
||||
{!isChatOpen && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -34,7 +34,21 @@ describe("buildRunManifest", () => {
|
|||
expect(workflow!.source).toContain("digraph");
|
||||
expect(workflow!.source).toContain("start ->");
|
||||
expect(workflow!.config?.path).toBe("workflow.toml");
|
||||
expect(workflow!.config?.source).toContain("[run.sandbox]");
|
||||
// workflow.toml must stay parseable by the server's RunLayer, which
|
||||
// rejects the unknown `[run.sandbox]` section.
|
||||
expect(workflow!.config?.source).not.toContain("[run.sandbox]");
|
||||
expect(workflow!.config?.source).toContain("[workflow]");
|
||||
});
|
||||
|
||||
test("clamps an over-long goal so the title stays within the server's 100-char limit", () => {
|
||||
const goal = "a".repeat(150);
|
||||
const draft = { ...createInitialDraft(), name: "release_notes", goal };
|
||||
const manifest = buildRunManifest(draft);
|
||||
expect(manifest.title).toBeDefined();
|
||||
// Server caps RunManifest.title at 100 Unicode scalar values; count by
|
||||
// code point (not UTF-16 units) to match its `chars().count()` check.
|
||||
expect(Array.from(manifest.title!).length).toBeLessThanOrEqual(100);
|
||||
expect(manifest.title!.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
test("named draft → title and target path use the snake_case name", () => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,26 @@ import {
|
|||
*/
|
||||
const PLAYGROUND_CWD = "/tmp/fabro-playground";
|
||||
|
||||
/**
|
||||
* Server-side cap on `RunManifest.title` (`MAX_RUN_TITLE_CHARS`), counted in
|
||||
* Unicode scalar values. Titles longer than this are rejected at run creation,
|
||||
* so we clamp the goal-derived title here to keep "Save to Runs" working for
|
||||
* long goals.
|
||||
*/
|
||||
const MAX_TITLE_CHARS = 100;
|
||||
|
||||
/**
|
||||
* Clamp a candidate title to the server's character limit, counting by code
|
||||
* point (matching Rust's `chars().count()`) rather than UTF-16 units. When it
|
||||
* overflows, keep the first `MAX_TITLE_CHARS - 1` code points and append an
|
||||
* ellipsis so the result is exactly at the limit.
|
||||
*/
|
||||
function clampTitle(title: string): string {
|
||||
const codePoints = Array.from(title);
|
||||
if (codePoints.length <= MAX_TITLE_CHARS) return title;
|
||||
return `${codePoints.slice(0, MAX_TITLE_CHARS - 1).join("")}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal subset of `RunManifest` the playground needs to send. The
|
||||
* generated `RunManifest` type from `@qltysh/fabro-api-client` accepts
|
||||
|
|
@ -64,7 +84,7 @@ export function buildRunManifest(draft: WorkflowDraft): PlaygroundRunManifest {
|
|||
return {
|
||||
version: 1,
|
||||
cwd: PLAYGROUND_CWD,
|
||||
title: draft.goal && draft.goal.length > 0 ? draft.goal : `Playground: ${name}`,
|
||||
title: draft.goal && draft.goal.length > 0 ? clampTitle(draft.goal) : `Playground: ${name}`,
|
||||
target: {
|
||||
path: workflowPath,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import TestRenderer, { act } from "react-test-renderer";
|
||||
|
||||
import RunForRealModal from "./run-for-real-modal";
|
||||
import type { WorkflowDraft } from "../state/draft";
|
||||
|
||||
function withPlan(): WorkflowDraft {
|
||||
return {
|
||||
name: "release_notes",
|
||||
goal: "Generate release notes.",
|
||||
nodes: [
|
||||
{ id: "start", label: "Start", shape: "mdiamond" },
|
||||
{ id: "exit", label: "Exit", shape: "msquare" },
|
||||
{ id: "plan", label: "Plan", shape: "box", prompt: "Plan it." },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "plan" },
|
||||
{ from: "plan", to: "exit" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode): TestRenderer.ReactTestRenderer {
|
||||
let tree: TestRenderer.ReactTestRenderer | undefined;
|
||||
act(() => {
|
||||
tree = TestRenderer.create(node as TestRenderer.ReactTestRendererJSON);
|
||||
});
|
||||
return tree!;
|
||||
}
|
||||
|
||||
type CapturedRequest = { url: string; method?: string };
|
||||
|
||||
function stubFetch(
|
||||
responder: (req: CapturedRequest) => {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText?: string;
|
||||
body?: unknown;
|
||||
},
|
||||
): { requests: CapturedRequest[] } {
|
||||
const requests: CapturedRequest[] = [];
|
||||
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
||||
const req: CapturedRequest = { url: String(url), method: init?.method };
|
||||
requests.push(req);
|
||||
const res = responder(req);
|
||||
const payload = res.body ?? null;
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
statusText: res.statusText ?? "",
|
||||
json: async () => payload,
|
||||
clone: () => ({ json: async () => payload }),
|
||||
} as unknown as Response;
|
||||
}) as typeof fetch;
|
||||
return { requests };
|
||||
}
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
/** Install a minimal `window` whose `location.assign` records the redirect. */
|
||||
function stubWindowLocation(): { assigned: string[] } {
|
||||
const assigned: string[] = [];
|
||||
const stub = { location: { assign: (url: string) => void assigned.push(url) } };
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: stub, writable: true, configurable: true,
|
||||
});
|
||||
return { assigned };
|
||||
}
|
||||
|
||||
function launchButton(tree: TestRenderer.ReactTestRenderer) {
|
||||
return tree.root
|
||||
.findAll((n) => n.type === "button" && n.props.children === "Run in sandbox")[0]!;
|
||||
}
|
||||
|
||||
async function clickAndSettle(el: TestRenderer.ReactTestInstance): Promise<void> {
|
||||
await act(async () => {
|
||||
el.props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("RunForRealModal", () => {
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
});
|
||||
|
||||
test("creates the run, starts it, then redirects to its run page", async () => {
|
||||
const { assigned } = stubWindowLocation();
|
||||
const stub = stubFetch((req) =>
|
||||
req.url.endsWith("/start")
|
||||
? { ok: true, status: 200 }
|
||||
: { ok: true, status: 201, body: { id: "run-7" } },
|
||||
);
|
||||
|
||||
const tree = render(<RunForRealModal draft={withPlan()} onClose={() => {}} />);
|
||||
await clickAndSettle(launchButton(tree));
|
||||
|
||||
// create first, then start — both POST, in order.
|
||||
expect(stub.requests.map((r) => `${r.method?.toUpperCase()} ${r.url}`)).toEqual([
|
||||
"POST /api/v1/runs",
|
||||
"POST /api/v1/runs/run-7/start",
|
||||
]);
|
||||
// Only redirects once the run has actually been started.
|
||||
expect(assigned).toEqual(["/runs/run-7"]);
|
||||
});
|
||||
|
||||
test("surfaces a start failure and does not redirect", async () => {
|
||||
const { assigned } = stubWindowLocation();
|
||||
stubFetch((req) =>
|
||||
req.url.endsWith("/start")
|
||||
? {
|
||||
ok: false,
|
||||
status: 409,
|
||||
statusText: "Conflict",
|
||||
body: { errors: [{ status: "409", title: "Conflict", detail: "Run is not startable" }] },
|
||||
}
|
||||
: { ok: true, status: 201, body: { id: "run-7" } },
|
||||
);
|
||||
|
||||
const tree = render(<RunForRealModal draft={withPlan()} onClose={() => {}} />);
|
||||
await clickAndSettle(launchButton(tree));
|
||||
|
||||
expect(tree.root.findByProps({ className: "break-words" }).props.children).toContain(
|
||||
"Run is not startable",
|
||||
);
|
||||
expect(assigned).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -49,6 +49,18 @@ export default function RunForRealModal({
|
|||
if (!body.id) {
|
||||
throw new Error("Server did not return a run id.");
|
||||
}
|
||||
// `POST /runs` only creates the run in `submitted` status; "Run for real"
|
||||
// is a launch action ("…redirecting you to its run page when it starts"),
|
||||
// so kick off execution before redirecting. Without this the run sits
|
||||
// `submitted` forever — the run page has no start affordance.
|
||||
const startResponse = await fetch(`/api/v1/runs/${body.id}/start`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!startResponse.ok) {
|
||||
const detail = await readErrorDetail(startResponse);
|
||||
throw new Error(detail ?? `${startResponse.status} ${startResponse.statusText}`);
|
||||
}
|
||||
window.location.assign(`/runs/${body.id}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import TestRenderer, { act } from "react-test-renderer";
|
||||
|
||||
import SaveToRunsButton from "./save-to-runs-button";
|
||||
import { createInitialDraft, type WorkflowDraft } from "../state/draft";
|
||||
|
||||
function withPlan(): WorkflowDraft {
|
||||
return {
|
||||
name: "release_notes",
|
||||
goal: "Generate release notes.",
|
||||
nodes: [
|
||||
{ id: "start", label: "Start", shape: "mdiamond" },
|
||||
{ id: "exit", label: "Exit", shape: "msquare" },
|
||||
{ id: "plan", label: "Plan", shape: "box", prompt: "Plan it." },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "plan" },
|
||||
{ from: "plan", to: "exit" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode): TestRenderer.ReactTestRenderer {
|
||||
let tree: TestRenderer.ReactTestRenderer | undefined;
|
||||
act(() => {
|
||||
tree = TestRenderer.create(node as TestRenderer.ReactTestRendererJSON);
|
||||
});
|
||||
return tree!;
|
||||
}
|
||||
|
||||
type CapturedRequest = { url: string; method?: string; body: unknown };
|
||||
|
||||
function stubFetch(
|
||||
responder: (req: CapturedRequest) => {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText?: string;
|
||||
body?: unknown;
|
||||
},
|
||||
): { requests: CapturedRequest[] } {
|
||||
const requests: CapturedRequest[] = [];
|
||||
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
||||
const req: CapturedRequest = {
|
||||
url: String(url),
|
||||
method: init?.method,
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
};
|
||||
requests.push(req);
|
||||
const res = responder(req);
|
||||
const payload = res.body ?? null;
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
statusText: res.statusText ?? "",
|
||||
json: async () => payload,
|
||||
clone: () => ({ json: async () => payload }),
|
||||
} as unknown as Response;
|
||||
}) as typeof fetch;
|
||||
return { requests };
|
||||
}
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function findButton(tree: TestRenderer.ReactTestRenderer) {
|
||||
return tree.root.findByProps({ "aria-label": "Save to Runs" });
|
||||
}
|
||||
|
||||
async function clickAndSettle(
|
||||
el: TestRenderer.ReactTestInstance,
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
el.props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("SaveToRunsButton", () => {
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("POSTs the manifest to /api/v1/runs and links to the new run without starting it", async () => {
|
||||
const stub = stubFetch(() => ({
|
||||
ok: true,
|
||||
status: 201,
|
||||
body: { id: "run-9" },
|
||||
}));
|
||||
|
||||
const tree = render(<SaveToRunsButton draft={withPlan()} />);
|
||||
await clickAndSettle(findButton(tree));
|
||||
|
||||
// Exactly one request: create. No /start call (create-only).
|
||||
expect(stub.requests).toHaveLength(1);
|
||||
expect(stub.requests[0]!.url).toBe("/api/v1/runs");
|
||||
expect(stub.requests[0]!.method?.toUpperCase()).toBe("POST");
|
||||
expect(stub.requests.some((r) => r.url.includes("/start"))).toBe(false);
|
||||
|
||||
const manifest = stub.requests[0]!.body as {
|
||||
version: number;
|
||||
target: { identifier: string };
|
||||
};
|
||||
expect(manifest.version).toBe(1);
|
||||
expect(manifest.target.identifier).toBe("release_notes");
|
||||
|
||||
// Success surfaces a link into the runs list for the created run.
|
||||
expect(tree.root.findByProps({ href: "/runs/run-9" })).toBeDefined();
|
||||
});
|
||||
|
||||
test("is disabled in the welcome state", () => {
|
||||
const tree = render(<SaveToRunsButton draft={createInitialDraft()} />);
|
||||
expect(findButton(tree).props.disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("surfaces the server error detail and creates no run link on failure", async () => {
|
||||
const stub = stubFetch(() => ({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: "Bad Request",
|
||||
body: { errors: [{ status: "400", title: "Bad Request", detail: "Validation failed" }] },
|
||||
}));
|
||||
|
||||
const tree = render(<SaveToRunsButton draft={withPlan()} />);
|
||||
await clickAndSettle(findButton(tree));
|
||||
|
||||
expect(stub.requests).toHaveLength(1);
|
||||
const alert = tree.root.findByProps({ role: "alert" });
|
||||
expect(alert.props.title).toContain("Validation failed");
|
||||
expect(tree.root.findAllByProps({ href: "/runs/run-9" })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
ArchiveBoxArrowDownIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
import { buildRunManifest } from "../state/build-manifest";
|
||||
import type { WorkflowDraft } from "../state/draft";
|
||||
import { isWelcomeState } from "../state/draft";
|
||||
|
||||
type SaveState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "saving" }
|
||||
| { kind: "saved"; runId: string }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
const BUTTON_CLASS =
|
||||
"inline-flex items-center gap-1.5 rounded-md bg-sky-500/10 px-3 py-1.5 text-sm font-medium text-sky-200 ring-1 ring-sky-500/30 transition-colors hover:bg-sky-500/20 hover:text-sky-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-sky-500/10 disabled:hover:text-sky-200";
|
||||
|
||||
/**
|
||||
* "Save to Runs" toolbar button.
|
||||
*
|
||||
* Submits the finalized playground pipeline to `POST /api/v1/runs`, which
|
||||
* creates the run in the `submitted` state and lands it in the runs list —
|
||||
* without starting execution (that stays a deliberate, separate action from
|
||||
* the run page). On success the button morphs into a link into the runs list;
|
||||
* on failure it surfaces the server's error detail inline.
|
||||
*
|
||||
* Disabled in the welcome state — saving an empty `start → exit` skeleton is
|
||||
* pointless.
|
||||
*/
|
||||
export default function SaveToRunsButton({ draft }: { draft: WorkflowDraft }) {
|
||||
const [state, setState] = useState<SaveState>({ kind: "idle" });
|
||||
|
||||
const isWelcome = isWelcomeState(draft);
|
||||
const disabled = isWelcome || state.kind === "saving";
|
||||
|
||||
const save = async () => {
|
||||
setState({ kind: "saving" });
|
||||
try {
|
||||
const manifest = buildRunManifest(draft);
|
||||
const response = await fetch("/api/v1/runs", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(manifest),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await readErrorDetail(response);
|
||||
throw new Error(detail ?? `${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body = (await response.json()) as { id?: string };
|
||||
if (!body.id) {
|
||||
throw new Error("Server did not return a run id.");
|
||||
}
|
||||
setState({ kind: "saved", runId: body.id });
|
||||
} catch (e) {
|
||||
setState({ kind: "error", message: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
};
|
||||
|
||||
if (state.kind === "saved") {
|
||||
return (
|
||||
<a
|
||||
href={`/runs/${state.runId}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-emerald-500/10 px-3 py-1.5 text-sm font-medium text-emerald-200 ring-1 ring-emerald-500/30 transition-colors hover:bg-emerald-500/20 hover:text-emerald-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500"
|
||||
>
|
||||
<CheckCircleIcon className="size-4" />
|
||||
Saved — view in Runs
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{state.kind === "error" && (
|
||||
<span
|
||||
role="alert"
|
||||
className="inline-flex max-w-56 items-center gap-1 truncate text-xs text-rose-200"
|
||||
title={state.message}
|
||||
>
|
||||
<ExclamationTriangleIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{state.message}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Save to Runs"
|
||||
disabled={disabled}
|
||||
title={isWelcome ? "Add at least one node first" : undefined}
|
||||
onClick={save}
|
||||
className={BUTTON_CLASS}
|
||||
>
|
||||
<ArchiveBoxArrowDownIcon className="size-4" />
|
||||
{state.kind === "saving" ? "Saving…" : "Save to Runs"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function readErrorDetail(response: Response): Promise<string | null> {
|
||||
try {
|
||||
const body = (await response.clone().json()) as {
|
||||
errors?: { detail?: string; title?: string }[];
|
||||
};
|
||||
const first = body.errors?.[0];
|
||||
return first?.detail ?? first?.title ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
cancellationActionLabel,
|
||||
cancellationSuccessMessage,
|
||||
|
|
@ -44,7 +45,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,
|
||||
cancellationActionLabel,
|
||||
cancellationSuccessMessage,
|
||||
|
|
@ -430,6 +431,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", () => {
|
||||
|
|
|
|||
|
|
@ -158,6 +158,16 @@ export function isTerminalRunStatus(
|
|||
return !!status && TERMINAL_RUN_STATUSES.has(status as RunStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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