Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,24 @@ 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",
"",
"[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",
Expand All @@ -30,6 +30,9 @@ describe("renderProjectToml", () => {
"enabled = true",
"draft = true",
"",
"[environments.default]",
'provider = "local"',
"",
].join("\n"),
);
});
Expand Down
21 changes: 13 additions & 8 deletions apps/fabro-web/app/components/playground/files/render-toml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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 [
Expand All @@ -43,5 +45,8 @@ export function renderProjectToml(_draft: WorkflowDraft): string {
"enabled = true",
"draft = true",
"",
"[environments.default]",
'provider = "local"',
"",
].join("\n");
}
2 changes: 2 additions & 0 deletions apps/fabro-web/app/components/playground/playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,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 identifier use the snake_case name", () => {
Expand Down
22 changes: 21 additions & 1 deletion apps/fabro-web/app/components/playground/state/build-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,7 +85,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: {
identifier: name,
path: workflowPath,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading