mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
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
## 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>
341 lines
11 KiB
TypeScript
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.");
|
|
});
|
|
});
|