fabro/apps/fabro-web/app/components/run-summary-panel.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

274 lines
8.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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