mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +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>
274 lines
8.8 KiB
TypeScript
274 lines
8.8 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
import TestRenderer, { act } from "react-test-renderer";
|
||
import { MemoryRouter } from "react-router";
|
||
|
||
import {
|
||
RunSummaryPanelView,
|
||
type RunSummaryPanelViewProps,
|
||
} from "./run-summary-panel";
|
||
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
|
||
|
||
function instanceText(instance: TestRenderer.ReactTestInstance): string {
|
||
const parts: string[] = [];
|
||
for (const child of instance.children) {
|
||
if (typeof child === "string") parts.push(child);
|
||
else parts.push(instanceText(child));
|
||
}
|
||
return parts.join("");
|
||
}
|
||
|
||
function render(props: Partial<RunSummaryPanelViewProps> = {}) {
|
||
const full: RunSummaryPanelViewProps = {
|
||
run: null,
|
||
runLoading: false,
|
||
sandboxState: null,
|
||
sandboxResources: null,
|
||
sandboxLoading: false,
|
||
artifactsCount: null,
|
||
artifactsLoading: false,
|
||
...props,
|
||
};
|
||
let tree: TestRenderer.ReactTestRenderer | undefined;
|
||
act(() => {
|
||
tree = TestRenderer.create(<RunSummaryPanelView {...full} />);
|
||
});
|
||
return tree!;
|
||
}
|
||
|
||
function cellAfterLabel(
|
||
tree: TestRenderer.ReactTestRenderer,
|
||
label: string,
|
||
): TestRenderer.ReactTestInstance {
|
||
const labelNode = tree.root.find(
|
||
(node) =>
|
||
node.type === "div" &&
|
||
node.children.length === 1 &&
|
||
typeof node.children[0] === "string" &&
|
||
node.children[0] === label,
|
||
);
|
||
const parent = labelNode.parent;
|
||
if (!parent) throw new Error(`Could not find parent of label "${label}"`);
|
||
return parent.children[1] as TestRenderer.ReactTestInstance;
|
||
}
|
||
|
||
function makeRun(overrides: Record<string, any> = {}) {
|
||
return {
|
||
id: "run_1",
|
||
created_by: TEST_PRINCIPAL,
|
||
diff: null,
|
||
billing: null,
|
||
...overrides,
|
||
} as any;
|
||
}
|
||
|
||
const EMPTY_VALUE = "Not available";
|
||
|
||
describe("RunSummaryPanelView", () => {
|
||
test("renders all five column labels", () => {
|
||
const tree = render();
|
||
const rendered = JSON.stringify(tree.toJSON());
|
||
for (const label of ["Created by", "Changes", "Sandbox", "Cost", "Artifacts"]) {
|
||
expect(rendered).toContain(label);
|
||
}
|
||
});
|
||
|
||
test("shows creator and unavailable copy for optional missing run fields after load", () => {
|
||
const tree = render({ run: makeRun() });
|
||
expect(instanceText(cellAfterLabel(tree, "Created by"))).toBe("Ttest");
|
||
expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe(EMPTY_VALUE);
|
||
expect(instanceText(cellAfterLabel(tree, "Cost"))).toBe(EMPTY_VALUE);
|
||
});
|
||
|
||
test("shows unavailable copy when sandbox is absent", () => {
|
||
const tree = render({ run: makeRun(), sandboxResources: null });
|
||
expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe(EMPTY_VALUE);
|
||
});
|
||
|
||
test("renders planned sandbox on a failed run as not created", () => {
|
||
const tree = render({
|
||
run: makeRun({
|
||
lifecycle: { status: { kind: "failed", reason: "sandbox_init_failed" } },
|
||
sandbox: {
|
||
kind: "planned",
|
||
plan: { provider: "docker", image: null, snapshot: null },
|
||
},
|
||
}),
|
||
sandboxState: null,
|
||
sandboxResources: null,
|
||
});
|
||
|
||
expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("Not created");
|
||
});
|
||
|
||
test("renders sandbox lifecycle state before details are available", () => {
|
||
const tree = render({
|
||
run: makeRun({
|
||
sandbox: {
|
||
kind: "initializing",
|
||
plan: { provider: "docker", image: null, snapshot: null },
|
||
},
|
||
}),
|
||
sandboxState: null,
|
||
sandboxResources: null,
|
||
});
|
||
|
||
expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("Initializing");
|
||
});
|
||
|
||
test("renders failed sandbox lifecycle error before details are available", () => {
|
||
const tree = render({
|
||
run: makeRun({
|
||
sandbox: {
|
||
kind: "failed",
|
||
plan: { provider: "docker", image: null, snapshot: null },
|
||
failure: {
|
||
provider: "docker",
|
||
error: "Docker daemon unavailable",
|
||
causes: [],
|
||
duration_ms: 42,
|
||
},
|
||
},
|
||
}),
|
||
sandboxState: null,
|
||
sandboxResources: null,
|
||
});
|
||
|
||
expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("Failed");
|
||
});
|
||
|
||
test("shows unavailable copy when artifacts count is zero", () => {
|
||
const tree = render({ run: makeRun(), artifactsCount: 0 });
|
||
expect(instanceText(cellAfterLabel(tree, "Artifacts"))).toBe(EMPTY_VALUE);
|
||
});
|
||
|
||
test("renders diff additions/deletions/files with correct formatting", () => {
|
||
const tree = render({
|
||
run: makeRun({
|
||
diff: { additions: 124, deletions: 37, files_changed: 7 },
|
||
}),
|
||
});
|
||
expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe(
|
||
"+124 −37in 7 files",
|
||
);
|
||
});
|
||
|
||
test("singular 'file' when files_changed is 1", () => {
|
||
const tree = render({
|
||
run: makeRun({
|
||
diff: { additions: 3, deletions: 0, files_changed: 1 },
|
||
}),
|
||
});
|
||
expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe(
|
||
"+3 −0in 1 file",
|
||
);
|
||
});
|
||
|
||
test("renders cost from total_usd_micros", () => {
|
||
const tree = render({
|
||
run: makeRun({ billing: { total_usd_micros: 840_000 } }),
|
||
});
|
||
expect(instanceText(cellAfterLabel(tree, "Cost"))).toBe("$0.84");
|
||
});
|
||
|
||
test("renders sandbox CPU and memory", () => {
|
||
const tree = render({
|
||
run: makeRun(),
|
||
sandboxState: "running",
|
||
sandboxResources: { cpu_cores: 4, memory_bytes: 8 * 1024 * 1024 * 1024 } as any,
|
||
});
|
||
expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("4 CPU · 8 GiB");
|
||
});
|
||
|
||
test("renders sandbox status dot and falls back to state label without resources", () => {
|
||
const tree = render({ run: makeRun(), sandboxState: "stopped" });
|
||
const cell = cellAfterLabel(tree, "Sandbox");
|
||
expect(instanceText(cell)).toBe("Stopped");
|
||
const dot = cell.find(
|
||
(node) =>
|
||
typeof node.props.className === "string" &&
|
||
node.props.className.includes("bg-fg-muted"),
|
||
);
|
||
expect(dot.props["aria-hidden"]).toBe("true");
|
||
expect(dot.props.className).toContain("bg-fg-muted");
|
||
});
|
||
|
||
test("renders artifacts count when positive", () => {
|
||
const tree = render({ run: makeRun(), artifactsCount: 3 });
|
||
expect(instanceText(cellAfterLabel(tree, "Artifacts"))).toBe("3");
|
||
});
|
||
|
||
test("renders Retried from link when present", () => {
|
||
let tree: TestRenderer.ReactTestRenderer | undefined;
|
||
act(() => {
|
||
tree = TestRenderer.create(
|
||
<MemoryRouter>
|
||
<RunSummaryPanelView
|
||
run={makeRun({ retried_from: "01KRETRYFROMRUNID" })}
|
||
runLoading={false}
|
||
sandboxState={null}
|
||
sandboxResources={null}
|
||
sandboxLoading={false}
|
||
artifactsCount={null}
|
||
artifactsLoading={false}
|
||
/>
|
||
</MemoryRouter>,
|
||
);
|
||
});
|
||
const cell = cellAfterLabel(tree!, "Retried from");
|
||
const link = cell.find((node) => node.type === "a");
|
||
expect(link.props.href).toBe("/runs/01KRETRYFROMRUNID");
|
||
expect(instanceText(link)).toBe("01KRETRY");
|
||
});
|
||
|
||
test("renders user actor with login initial", () => {
|
||
const tree = render({
|
||
run: makeRun({
|
||
created_by: {
|
||
kind: "user",
|
||
identity: { issuer: "github", subject: "1" },
|
||
login: "brynary",
|
||
auth_method: "github",
|
||
},
|
||
}),
|
||
});
|
||
expect(instanceText(cellAfterLabel(tree, "Created by"))).toBe("Bbrynary");
|
||
});
|
||
|
||
test("renders user actor avatar when avatar_url is set", () => {
|
||
const tree = render({
|
||
run: makeRun({
|
||
created_by: {
|
||
kind: "user",
|
||
identity: { issuer: "github", subject: "1" },
|
||
login: "brynary",
|
||
auth_method: "github",
|
||
avatar_url: "https://example.com/brynary.png",
|
||
},
|
||
}),
|
||
});
|
||
const cell = cellAfterLabel(tree, "Created by");
|
||
const img = cell.find((node) => node.type === "img");
|
||
expect(img.props.src).toBe("https://example.com/brynary.png");
|
||
expect(instanceText(cell)).toBe("brynary");
|
||
});
|
||
|
||
test("renders non-user actor with kind label", () => {
|
||
for (const kind of ["agent", "system", "slack", "webhook", "worker"]) {
|
||
const tree = render({ run: makeRun({ created_by: { kind } as any }) });
|
||
expect(instanceText(cellAfterLabel(tree, "Created by"))).toContain(kind);
|
||
}
|
||
});
|
||
|
||
test("shows skeleton placeholders while queries are loading", () => {
|
||
const tree = render({
|
||
runLoading: true,
|
||
sandboxLoading: true,
|
||
artifactsLoading: true,
|
||
});
|
||
const rendered = JSON.stringify(tree.toJSON());
|
||
expect(rendered).toContain("animate-pulse");
|
||
expect(instanceText(cellAfterLabel(tree, "Created by"))).not.toContain(EMPTY_VALUE);
|
||
expect(instanceText(cellAfterLabel(tree, "Sandbox"))).not.toContain(EMPTY_VALUE);
|
||
expect(instanceText(cellAfterLabel(tree, "Artifacts"))).not.toContain(EMPTY_VALUE);
|
||
});
|
||
});
|