From 2fa4ee9899258f3bfe525ee8905d4c18b39a4cd4 Mon Sep 17 00:00:00 2001 From: ALIHAN DIKEL Date: Sat, 27 Jun 2026 01:24:15 +0300 Subject: [PATCH] feat(web): start the run when launching "Run for real" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal copy promises to redirect "to its run page when it starts", but `POST /api/v1/runs` only creates the run in `submitted` status and the run page has no start affordance — so launched runs sat `submitted` forever. After creating the run, POST `/api/v1/runs/{id}/start` and only redirect once it has actually started. A start failure is surfaced inline (e.g. 409 "not startable") instead of silently redirecting to a stuck run. --- .../playground/ui/run-for-real-modal.test.tsx | 131 ++++++++++++++++++ .../playground/ui/run-for-real-modal.tsx | 12 ++ 2 files changed, 143 insertions(+) create mode 100644 apps/fabro-web/app/components/playground/ui/run-for-real-modal.test.tsx diff --git a/apps/fabro-web/app/components/playground/ui/run-for-real-modal.test.tsx b/apps/fabro-web/app/components/playground/ui/run-for-real-modal.test.tsx new file mode 100644 index 000000000..c7e3983b6 --- /dev/null +++ b/apps/fabro-web/app/components/playground/ui/run-for-real-modal.test.tsx @@ -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 { + 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( {}} />); + 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( {}} />); + await clickAndSettle(launchButton(tree)); + + expect(tree.root.findByProps({ className: "break-words" }).props.children).toContain( + "Run is not startable", + ); + expect(assigned).toHaveLength(0); + }); +}); diff --git a/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx b/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx index d31c57cae..a377c600b 100644 --- a/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx +++ b/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx @@ -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));