fabro/apps/fabro-web/app/routes/run-files.render.test.tsx
Bryan Helmkamp bc0bda73a6
Some checks are pending
Rust / Clippy (push) Waiting to run
Rust / Format (push) Waiting to run
TypeScript / Build (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
feat(web): add server-managed Environments CRUD settings UI (#462)
## What

Adds a CRUD interface for **server-managed Environments** at
`/settings/environments`, driven by the `/api/v1/environments` REST API
(list / create / retrieve / replace / delete), and reshapes how built-in
environments are provisioned and protected.

The page lives in the **Workflows** settings nav section (also
introduced in this branch), positioned before Variables.

## Why

The Environments REST API shipped (#453) but had no UI — environments
could only be managed via the API/CLI. This gives operators a web UI
alongside Variables and Secrets, and along the way tightens the model:
environments are seeded at install time (not silently re-created on
every boot), and the `default` fallback is an ordinary, deletable
environment.

## Web UI

**Pages & component**
- `settings-environments.tsx` — list view: provider badge,
image/resource summary, row actions (Edit/Delete). **"New environment"
is a dropdown** of the enabled sandbox providers; the chosen provider is
fixed for the environment's lifetime.
- `settings-environments-new.tsx` / `settings-environments-edit.tsx` —
create/edit flows; create reads the provider from a query param.
- `environment-form.tsx` — shared form, reorganized:
- **General** panel (merged identity + image): id, and an **image-source
selector** (Image reference *vs* inline Dockerfile) that shows,
requires, and sends only the selected, mutually-exclusive source.
- **Resources**: CPU / memory / disk as **range sliders** (CPU 1–8,
memory 1–16 GB, disk 1–20 GB), each always writing a concrete value.
  - **Environment variables** key/value editor.
- **Advanced** progressive-disclosure section holding **Network** (a
single "Block all network access" toggle — allow-all vs block) and
**Lifecycle** (preserve / stop-on-terminal / auto-stop). Opens by
default when any advanced value is non-default.
- The in-form **provider control and the Labels editor were removed** —
labels remain API-managed and are round-tripped untouched so UI edits
never clear them.

**Data layer**: `environmentsApi` client, `queryKeys.environments`,
`useEnvironments` / `useEnvironment` SWR hooks.

**Nav & routing**: "Environments" item in the Workflows section before
Variables; routes registered in `router.tsx`.

## Backend: seed at install, deletable `default`

- **Seeding moved to install time.** The server no longer seeds
built-ins on startup; `EnvironmentStore::load_or_seed` → `load`
(load-only). A new public `seed_environments(dir)` (idempotent,
preserves operator edits) is called by both the web installer and the
CLI installer. An uninstalled instance therefore has no managed
environments, and a run selecting an absent environment fails explicitly
(`unknown environment: default`) rather than resurrecting a built-in.
- **`default` is no longer protected.** The delete guard and the
`Protected` error variant are gone; deleting `default` succeeds (204)
and removes the run fallback on purpose — forcing an explicit choice.
`local` is unchanged (reserved, in-memory).
- **`volumes` removed** from environment settings across the OpenAPI
spec, generated Rust + TS clients, config layers,
sandbox/server/workflow plumbing, docs, and tests.

## API contract details honored
- Edit sends the environment `revision` as `If-Match`; 409 conflicts
surface a "changed since you opened it" message.
- The REST API accepts inline Dockerfiles only — the form never sends a
Dockerfile path.

## Verification
- Rust: `cargo build` (touched crates) ✅, `cargo nextest -p
fabro-environment` 21/21 ✅, server env unit + `tests/it` integration 2/2
+ 15/15 ✅, `clippy` (nightly, touched crates, all targets) clean ✅, `fmt
--check` clean ✅. Full `--workspace` suite not run here — worth a CI
pass.
- Web: `bun run typecheck` ✅, `bun run build` ✅,
`environment-form.test.ts` 5/5 ✅. Web suite: 512 pass / 1 unrelated
pre-existing `RunDetail` failure.
- **Not visually verified in-browser** — the local app is login-gated
and automated loads redirect to `/login`; rendering of the form, the
New-environment dropdown, and `default` delete should be confirmed in a
logged-in session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: fabro-sh-0530[bot] <281434857+fabro-sh-0530[bot]@users.noreply.github.com>
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Release Repro <release-repro@example.com>
2026-06-13 08:44:38 -04:00

341 lines
11 KiB
TypeScript

import { afterEach, describe, expect, mock, test } from "bun:test";
import { useRef } from "react";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter, Route, Routes } from "react-router";
import { toast as sonnerToast } from "sonner";
import { ToastProvider } from "../components/toast";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
let currentFilesPayload: any = null;
let currentCommitsPayload: any = null;
let currentRunStatus = "succeeded";
const useRunFilesCalls: any[] = [];
const multiFileDiffCalls: any[] = [];
const patchDiffCalls: any[] = [];
let patchDiffMountSeq = 0;
const virtualizerCalls: any[] = [];
const providerCalls: any[] = [];
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
mock.module("@pierre/diffs/react", () => ({
MultiFileDiff: (props: any) => {
multiFileDiffCalls.push(props);
return <div data-pierre-multi="true">{props.newFile.name}</div>;
},
PatchDiff: (props: any) => {
const mountId = useRef(++patchDiffMountSeq);
patchDiffCalls.push({ ...props, mountId: mountId.current });
return (
<div data-pierre-patch="true" data-mount-id={mountId.current}>
{props.patch}
</div>
);
},
Virtualizer: (props: any) => {
virtualizerCalls.push(props);
return <div data-pierre-virtualizer="true">{props.children}</div>;
},
WorkerPoolContextProvider: (props: any) => {
providerCalls.push(props);
return <div data-pierre-worker-pool="true">{props.children}</div>;
},
}));
mock.module("../lib/queries", () => ({
useRun: () => ({
data: {
id: "run_1",
goal: "Run 1",
title: "Run 1",
workflow: { slug: "default", name: "Default", graph_name: null, node_count: 0, edge_count: 0 },
automation: null,
repository: { name: "fabro", origin_url: null, provider: "unknown" },
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {
status: { kind: currentRunStatus },
approval: null,
pending_control: null,
queue_position: null,
error: null,
archived: false,
archived_at: null,
},
sandbox: null,
models: [],
source_directory: null,
timestamps: {
created_at: "2026-05-05T12:00:00Z",
started_at: null,
last_event_at: null,
completed_at: null,
},
billing: null,
size: "XS",
diff: null,
pull_request: null,
current_question: null,
superseded_by: null,
retried_from: null,
links: { web: null },
},
}),
useRunCommits: () => ({
data: currentCommitsPayload,
error: null,
isLoading: false,
isValidating: false,
mutate: mock(() => Promise.resolve(currentCommitsPayload)),
}),
useRunFiles: (id: string | undefined, selection: any) => {
useRunFilesCalls.push({ id, selection });
return {
data: currentFilesPayload,
error: null,
isLoading: false,
isValidating: false,
mutate: mock(() => Promise.resolve(currentFilesPayload)),
};
},
useRunQuestions: () => ({ data: [] }),
}));
const { default: RunFiles } = await import("./run-files");
function makeFiles(count: number) {
return Array.from({ length: count }, (_, index) => {
const name = `src/file-${index}.ts`;
return {
change_kind: "modified",
old_file: { name, contents: `old ${index}\n` },
new_file: { name, contents: `new ${index}\n` },
};
});
}
function makePayload(count: number, source = "sandbox") {
return {
data: makeFiles(count),
meta: {
source,
scope: "committed",
degraded: false,
degraded_reason: null,
total_changed: count,
stats: { additions: count, deletions: count },
truncated: false,
to_sha: "abc1234",
to_sha_committed_at: "2026-05-05T12:00:00Z",
},
};
}
function makePatchPayload(patch: string) {
return {
data: [
{
change_kind: "modified",
old_file: { name: "docs/live.md", contents: null },
new_file: { name: "docs/live.md", contents: null },
unified_patch: patch,
},
],
meta: {
source: "sandbox",
scope: "committed",
degraded: false,
degraded_reason: null,
total_changed: 1,
stats: { additions: 1, deletions: 0 },
truncated: false,
to_sha: "abc1234",
to_sha_committed_at: "2026-05-05T12:00:00Z",
},
};
}
function runFilesTree(initialEntry = "/runs/run_1/files") {
return (
<ToastProvider>
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/runs/:id/files" element={<RunFiles />} />
</Routes>
</MemoryRouter>
</ToastProvider>
);
}
function renderRunFiles(initialEntry = "/runs/run_1/files") {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let renderer: TestRenderer.ReactTestRenderer | undefined;
act(() => {
renderer = TestRenderer.create(runFilesTree(initialEntry));
});
mountedRenderers.push(renderer!);
return renderer!;
}
function treeText(
node: ReturnType<TestRenderer.ReactTestRenderer["toJSON"]>,
): string {
if (!node) return "";
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(treeText).join("");
return (node.children ?? []).map(treeText).join("");
}
async function flushAsyncUpdates() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
describe("RunFiles rendering", () => {
afterEach(() => {
act(() => {
for (const renderer of mountedRenderers.splice(0)) {
renderer.unmount();
}
});
currentFilesPayload = null;
currentCommitsPayload = null;
currentRunStatus = "succeeded";
multiFileDiffCalls.length = 0;
patchDiffCalls.length = 0;
patchDiffMountSeq = 0;
virtualizerCalls.length = 0;
providerCalls.length = 0;
useRunFilesCalls.length = 0;
sonnerToast.dismiss();
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
});
test("renders a one-file payload through Pierre Virtualizer", () => {
currentFilesPayload = makePayload(1);
const renderer = renderRunFiles();
expect(virtualizerCalls).toHaveLength(1);
expect(renderer.root.findAllByProps({ "data-run-file-row": "true" })).toHaveLength(1);
expect(multiFileDiffCalls[0].options.diffStyle).toBe("split");
});
test("passes the selected URL scope to useRunFiles", () => {
currentFilesPayload = makePayload(1);
renderRunFiles("/runs/run_1/files?scope=all#file=src/file-0.ts");
expect(useRunFilesCalls[0]).toEqual({
id: "run_1",
selection: { kind: "scope", scope: "all" },
});
});
test("passes a selected commit range to useRunFiles", () => {
currentFilesPayload = makePayload(1);
currentCommitsPayload = {
data: [
{
sha: "b".repeat(40),
short_sha: "bbbbbbb",
subject: "fabro(run_1): implement (succeeded)",
parents: [{ sha: "a".repeat(40), short_sha: "aaaaaaa" }],
},
],
};
renderRunFiles(`/runs/run_1/files?commit=${"b".repeat(40)}`);
expect(useRunFilesCalls[0]).toEqual({
id: "run_1",
selection: {
kind: "commit",
fromSha: "a".repeat(40),
toSha: "b".repeat(40),
},
});
});
test("shows the scope picker only for sandbox responses", () => {
currentFilesPayload = makePayload(1, "sandbox");
const sandboxRenderer = renderRunFiles();
expect(
sandboxRenderer.root.findAllByProps({ "aria-label": "Diff selection" }),
).not.toHaveLength(0);
act(() => sandboxRenderer.unmount());
mountedRenderers.pop();
currentFilesPayload = makePayload(1, "final_patch");
const fallbackRenderer = renderRunFiles("/runs/run_1/files?scope=all");
expect(
fallbackRenderer.root.findAllByProps({ "aria-label": "Diff selection" }),
).toHaveLength(0);
});
test("renders a 27-file payload through one Pierre Virtualizer", () => {
currentFilesPayload = makePayload(27);
const renderer = renderRunFiles();
expect(virtualizerCalls).toHaveLength(1);
expect(renderer.root.findAllByProps({ "data-run-file-row": "true" })).toHaveLength(27);
});
test("passes stable Pierre cache keys across unrelated re-renders", () => {
currentFilesPayload = makePayload(1);
const renderer = renderRunFiles();
const firstOldKey = multiFileDiffCalls[0].oldFile.cacheKey;
const firstNewKey = multiFileDiffCalls[0].newFile.cacheKey;
act(() => {
renderer.update(runFilesTree());
});
const lastCall = multiFileDiffCalls[multiFileDiffCalls.length - 1];
expect(firstOldKey).toBe(lastCall.oldFile.cacheKey);
expect(firstNewKey).toBe(lastCall.newFile.cacheKey);
expect(firstOldKey).toContain("fabro-run-file:run_1:abc1234:old:src/file-0.ts:");
expect(firstNewKey).toContain("fabro-run-file:run_1:abc1234:new:src/file-0.ts:");
expect(lastCall.options).not.toHaveProperty("theme");
});
test("remounts patch diffs when the patch changes for the same file", () => {
currentFilesPayload = makePatchPayload(
"diff --git a/docs/live.md b/docs/live.md\n@@ -1 +1 @@\n+committed\n",
);
const renderer = renderRunFiles("/runs/run_1/files?scope=all");
const firstMountId = patchDiffCalls[0].mountId;
currentFilesPayload = makePatchPayload(
"diff --git a/docs/live.md b/docs/live.md\n@@ -1,0 +1,2 @@\n+committed\n+uncommitted\n",
);
act(() => {
renderer.update(runFilesTree("/runs/run_1/files?scope=all"));
});
const lastCall = patchDiffCalls[patchDiffCalls.length - 1];
expect(lastCall.patch).toContain("+uncommitted");
expect(lastCall.mountId).not.toBe(firstMountId);
});
test("refreshing from a populated diff to an empty diff shows a no-changes toast", async () => {
currentFilesPayload = makePayload(1);
const renderer = renderRunFiles("/runs/run_1/files?scope=all");
currentFilesPayload = makePayload(0);
await act(async () => {
renderer.root.findByProps({ "aria-label": "Refresh files" }).props.onClick();
});
await flushAsyncUpdates();
expect(treeText(renderer.toJSON())).toContain("No changes in this run.");
});
});