feat(web): start the run when launching "Run for real"

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.
This commit is contained in:
ALIHAN DIKEL 2026-06-27 01:24:15 +03:00
parent 8067b18e04
commit 2fa4ee9899
2 changed files with 143 additions and 0 deletions

View file

@ -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);
});
});

View file

@ -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));