Add a Disk section: see where the bytes went, and get them back
Every recreation runs `docker commit`, which stacks a layer and never rewrites one, and 24 conditions in `container_needs_recreation` trigger a recreation. Prevention landed earlier on this branch; this is the half a user can act on. The per-project table leads with the two numbers that explain the mechanism rather than just the total: how many commit layers a snapshot has stacked above its base, and what the container's writable layer will add at the next commit. Backend (`docker/disk.rs`, commands in `docker_commands.rs`): - `get_docker_disk_usage` — one `df()` joined against the project store, behind an explicit Scan button because it walks the whole daemon. - `list_reclaimable` / `reclaim` — classified buckets with measured bytes, planned off the existing report so re-planning costs no second scan. - `destroy_project_disk_object` — one object, typed confirmation. - `sweep_orphaned_snapshots` — exposed, so its report is finally visible. Safety is structural: `reclaim` takes `ReclaimTarget`, which has no variant that can name a live project's data. Destructive work is a separate type reached only through `destroy`. No unfiltered prune is called anywhere, and nothing outside a `triple-c*` name or `triple-c.*` label is touched. Orphan detection subtracts ids from the project store and consults nothing else. From the daemon's side an idle live project and a deleted one are indistinguishable — volumes present, no container, no image — so inferring from container or image absence would offer a live project's credentials and transcripts for deletion. A store that loaded empty from an existing `projects.json` is treated as a failed load, not as "no projects", because `ProjectsStore::new()` recovers from a corrupt file by starting empty. Three things verified against a live Docker 29.7.2 rather than assumed: - Compaction is a two-stage build (`FROM scratch` + `COPY --from`), which keeps every byte inside the daemon; bollard's import buffers a whole image into memory. uid/gid and setuid survive; a 192.6 MB/4-layer synthetic came out 45.7 MB/1 layer. Image config does not survive, so it is replayed via create+commit, which round-trips a multi-line env var that a Dockerfile `ENV` could not. - Flattening breaks base-layer sharing, so the result carries its own copy of the base. Eight of ten real projects had a 0.10–1.32 GB delta over a 4.72 GB shared base — compacting those costs ~4 GB. The bound now subtracts that penalty, such projects are not offered at all, and the run compares unique bytes and abandons a rewrite that would grow. - `docker builder prune` reports `Total:`, not `Total reclaimed space:`, so the first parser scored every prune as freeing nothing. The Windows/WSL2 note is mandatory and its copy lives in Rust beside the tests that pin it: pruning frees space inside `ext4.vhdx`, which never shrinks on its own, so C: does not change until the disk is compacted. Also adds `lib/formatBytes.ts` — the app had four disagreeing copies, and `projects/home/format.ts` and `migrationCopy.ts` now delegate to it with byte-identical output. Base 1000 by default, matching what Docker prints. Tests: 502 frontend (was 453), 365 Rust (was 322). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react";
|
||||
import DiskSettings from "./DiskSettings";
|
||||
import type {
|
||||
DiskUsageReport,
|
||||
ProjectDiskRow,
|
||||
ReclaimItem,
|
||||
ReclaimPlan,
|
||||
ReclaimTarget,
|
||||
} from "../../lib/types";
|
||||
|
||||
const getDockerDiskUsage = vi.fn();
|
||||
const listReclaimable = vi.fn();
|
||||
const reclaim = vi.fn();
|
||||
const destroyProjectDiskObject = vi.fn();
|
||||
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
getDockerDiskUsage: () => getDockerDiskUsage(),
|
||||
listReclaimable: (report: DiskUsageReport) => listReclaimable(report),
|
||||
reclaim: (targets: ReclaimTarget[]) => reclaim(targets),
|
||||
destroyProjectDiskObject: (target: unknown, confirmation: string) =>
|
||||
destroyProjectDiskObject(target, confirmation),
|
||||
sweepOrphanedSnapshots: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const row = (over: Partial<ProjectDiskRow> = {}): ProjectDiskRow => ({
|
||||
project_id: "p-whp",
|
||||
project_name: "whp",
|
||||
snapshot_image: "triple-c-snapshot-p-whp:latest",
|
||||
snapshot_exists: true,
|
||||
snapshot_bytes: 12_273_392_374,
|
||||
snapshot_shared_bytes: 3_832_425_659,
|
||||
snapshot_commit_layers: 14,
|
||||
snapshot_above_base_bytes: 8_440_966_715,
|
||||
container_exists: true,
|
||||
container_running: false,
|
||||
container_writable_bytes: 868_000_000,
|
||||
home_volume_bytes: 4_860_000_000,
|
||||
home_volume_present: true,
|
||||
config_volume_bytes: 427_000_000,
|
||||
config_volume_present: true,
|
||||
total_bytes: 14_595_966_715,
|
||||
migrating: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const report = (over: Partial<DiskUsageReport> = {}): DiskUsageReport => ({
|
||||
scanned_at: "2026-08-23T10:00:00Z",
|
||||
projects: [row()],
|
||||
base_images: [
|
||||
{
|
||||
reference: "ghcr.io/shadowdao/triple-c-sandbox:latest",
|
||||
bytes: 4_724_062_366,
|
||||
shared_bytes: 4_723_860_396,
|
||||
containers: 2,
|
||||
is_labelled_base: true,
|
||||
},
|
||||
],
|
||||
base_images_bytes: 4_724_062_366,
|
||||
orphan_image_bytes: 11_900_000_000,
|
||||
orphan_image_count: 3,
|
||||
orphan_volumes: [],
|
||||
orphan_volume_bytes: 0,
|
||||
orphan_volumes_unavailable: null,
|
||||
build_cache: {
|
||||
total_bytes: 28_000_000_000,
|
||||
reclaimable_bytes: 28_000_000_000,
|
||||
stale_bytes: 20_000_000_000,
|
||||
source: "buildx du",
|
||||
cli_error: null,
|
||||
},
|
||||
images_total_bytes: 104_500_000_000,
|
||||
containers_total_bytes: 7_497_000_000,
|
||||
volumes_total_bytes: 72_890_000_000,
|
||||
triple_c_total_bytes: 116_000_000_000,
|
||||
host: {
|
||||
docker_root_dir: "/var/lib/docker",
|
||||
operating_system: "Docker Desktop",
|
||||
is_docker_desktop: true,
|
||||
is_windows_host: false,
|
||||
vhdx_applies: false,
|
||||
vhdx_note: "",
|
||||
vhdx_fix: [],
|
||||
vhdx_fix_gui: "",
|
||||
},
|
||||
...over,
|
||||
});
|
||||
|
||||
const item = (over: Partial<ReclaimItem> = {}): ReclaimItem => ({
|
||||
target: { kind: "dangling_snapshots" },
|
||||
safety: "safe",
|
||||
daemon_wide: false,
|
||||
label: "Superseded snapshot layers (3 images)",
|
||||
detail: "Untagged images left behind by past container recreations.",
|
||||
bytes: 11_900_000_000,
|
||||
bytes_are_exact: true,
|
||||
bytes_floor: null,
|
||||
blocked: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const plan = (over: Partial<ReclaimPlan> = {}): ReclaimPlan => ({
|
||||
items: [item()],
|
||||
destructive: [],
|
||||
store_error: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
async function renderAndScan() {
|
||||
render(<DiskSettings />);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getDockerDiskUsage.mockResolvedValue(report());
|
||||
listReclaimable.mockResolvedValue(plan());
|
||||
reclaim.mockResolvedValue({ results: [], total_freed_bytes: 0 });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DiskSettings", () => {
|
||||
it("never scans until the button is pressed", async () => {
|
||||
// `df()` walks the whole daemon and takes seconds on a large store, and
|
||||
// AccordionSection unmounts its body when collapsed — so a scan on mount
|
||||
// would re-run every time the section was opened.
|
||||
render(<DiskSettings />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(getDockerDiskUsage).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/never done for you/)).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
|
||||
});
|
||||
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("says it is scanning in words, not only in colour", async () => {
|
||||
let resolve: (value: DiskUsageReport) => void = () => {};
|
||||
getDockerDiskUsage.mockReturnValue(
|
||||
new Promise<DiskUsageReport>((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
render(<DiskSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
|
||||
expect(screen.getByText("Scanning")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
resolve(report());
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText(/^Scanned /)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("shows the layer count and the cost of the next commit", async () => {
|
||||
// The two numbers that explain the growth mechanism. A total alone never
|
||||
// says why the disk filled up.
|
||||
await renderAndScan();
|
||||
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||
expect(within(projectRow).getByText("14")).toBeInTheDocument();
|
||||
expect(within(projectRow).getByText("+868.0 MB")).toBeInTheDocument();
|
||||
expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("charges the shared base to the globals, not to every project row", async () => {
|
||||
// The base is one 4.7 GB image every project descends from. Counting it per
|
||||
// row would show it eight times and make the column meaningless.
|
||||
await renderAndScan();
|
||||
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||
expect(within(projectRow).getByText("8.4 GB")).toBeInTheDocument();
|
||||
expect(within(projectRow).getByText(/12\.3 GB with base/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("plans from the report it already has rather than scanning twice", async () => {
|
||||
await renderAndScan();
|
||||
await waitFor(() => expect(listReclaimable).toHaveBeenCalledTimes(1));
|
||||
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
|
||||
expect(listReclaimable).toHaveBeenCalledWith(expect.objectContaining({ projects: expect.any(Array) }));
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Selection plumbing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("sends exactly the ticked targets and nothing else", async () => {
|
||||
listReclaimable.mockResolvedValue(
|
||||
plan({
|
||||
items: [
|
||||
item(),
|
||||
item({
|
||||
target: { kind: "migration_staging" },
|
||||
label: "Migration staging files",
|
||||
bytes: 500_000_000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await renderAndScan();
|
||||
await screen.findByTestId("disk-safe-bucket");
|
||||
|
||||
const boxes = screen.getAllByRole("checkbox");
|
||||
await act(async () => {
|
||||
fireEvent.click(boxes[1]);
|
||||
});
|
||||
expect(screen.getByText(/1 selected, 500\.0 MB/)).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
|
||||
});
|
||||
expect(reclaim).toHaveBeenCalledWith([{ kind: "migration_staging" }]);
|
||||
});
|
||||
|
||||
it("cannot reclaim with nothing ticked", async () => {
|
||||
await renderAndScan();
|
||||
await screen.findByTestId("disk-safe-bucket");
|
||||
expect(screen.getByRole("button", { name: "Reclaim" })).toBeDisabled();
|
||||
expect(screen.getByText("Nothing ticked.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refuses to tick a blocked item", async () => {
|
||||
listReclaimable.mockResolvedValue(
|
||||
plan({
|
||||
items: [item({ blocked: "A base-image migration is in flight for this project." })],
|
||||
}),
|
||||
);
|
||||
await renderAndScan();
|
||||
await screen.findByTestId("disk-safe-bucket");
|
||||
const box = screen.getByRole("checkbox");
|
||||
expect(box).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("A base-image migration is in flight for this project."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps semi-safe work out of the one-button bucket", async () => {
|
||||
// Compaction is a rewrite and cache clearing costs a re-download. Neither
|
||||
// may be swept up by a Reclaim press aimed at the free wins.
|
||||
listReclaimable.mockResolvedValue(
|
||||
plan({
|
||||
items: [
|
||||
item(),
|
||||
item({
|
||||
target: { kind: "compact_snapshot", project_id: "p-whp" },
|
||||
safety: "semi_safe",
|
||||
label: "Compact whp's snapshot",
|
||||
bytes: 5_100_000_000,
|
||||
bytes_are_exact: false,
|
||||
bytes_floor: 0,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await renderAndScan();
|
||||
|
||||
const safe = await screen.findByTestId("disk-safe-bucket");
|
||||
expect(within(safe).getAllByRole("checkbox")).toHaveLength(1);
|
||||
expect(within(safe).queryByText(/Compact whp/)).not.toBeInTheDocument();
|
||||
|
||||
const semi = screen.getByTestId("disk-semi-bucket");
|
||||
expect(within(semi).getByText("Compact whp's snapshot")).toBeInTheDocument();
|
||||
expect(within(semi).queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks a compaction's yield as a bound, never as a measurement", async () => {
|
||||
listReclaimable.mockResolvedValue(
|
||||
plan({
|
||||
items: [
|
||||
item({
|
||||
target: { kind: "compact_snapshot", project_id: "p-whp" },
|
||||
safety: "semi_safe",
|
||||
label: "Compact whp's snapshot",
|
||||
bytes: 5_100_000_000,
|
||||
bytes_are_exact: false,
|
||||
bytes_floor: 0,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await renderAndScan();
|
||||
const semi = await screen.findByTestId("disk-semi-bucket");
|
||||
expect(within(semi).getByText("up to 5.1 GB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says out loud when an action reaches the whole daemon", async () => {
|
||||
// The user's daemon also holds unrelated postgres and site-builder work,
|
||||
// and a build-cache prune takes their warm cache with ours.
|
||||
listReclaimable.mockResolvedValue(
|
||||
plan({
|
||||
items: [
|
||||
item({
|
||||
target: { kind: "build_cache", all: true },
|
||||
daemon_wide: true,
|
||||
label: "Build cache, all of it",
|
||||
bytes: 28_000_000_000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await renderAndScan();
|
||||
const safe = await screen.findByTestId("disk-safe-bucket");
|
||||
expect(within(safe).getByText("whole daemon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Orphan copy — the correction that matters most
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("says what a 'no matching project' volume is derived from", async () => {
|
||||
// An idle live project has volumes, no container and possibly no image —
|
||||
// indistinguishable from a deleted one unless you consult the project
|
||||
// store. The copy must not invite the inference that made that mistake.
|
||||
getDockerDiskUsage.mockResolvedValue(
|
||||
report({
|
||||
orphan_volumes: [
|
||||
{
|
||||
name: "triple-c-home-gone",
|
||||
project_id: "gone",
|
||||
bytes: 900_000,
|
||||
role: "home",
|
||||
created_at: "2026-03-14T09:00:00Z",
|
||||
},
|
||||
],
|
||||
orphan_volume_bytes: 900_000,
|
||||
}),
|
||||
);
|
||||
await renderAndScan();
|
||||
const globals = await screen.findByTestId("disk-globals");
|
||||
expect(
|
||||
within(globals).getByText(/Volumes with no matching project in Triple-C/),
|
||||
).toBeInTheDocument();
|
||||
// The sentence is split by an <em>, so match on the container's text.
|
||||
expect(globals.textContent).toMatch(/is not inferred from a project being stopped/i);
|
||||
expect(globals.textContent).toMatch(
|
||||
/A project you have not opened in a while has no container and no snapshot either, and that is normal/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("explains a suppressed orphan list instead of showing an empty one", async () => {
|
||||
// With the project store unreadable every project's volumes look
|
||||
// unclaimed. Showing nothing is right; showing nothing *silently* is not.
|
||||
getDockerDiskUsage.mockResolvedValue(
|
||||
report({
|
||||
orphan_volumes: [],
|
||||
orphan_volumes_unavailable:
|
||||
"projects.json could not be read, so there is no way to tell an orphaned volume from a live project's.",
|
||||
}),
|
||||
);
|
||||
await renderAndScan();
|
||||
const banner = await screen.findByTestId("disk-store-error");
|
||||
expect(within(banner).getByText("Could not read the project list")).toBeInTheDocument();
|
||||
expect(within(banner).getByText(/no way to tell/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Windows / WSL2
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("spells out that pruning will not shrink C: on Docker Desktop for Windows", async () => {
|
||||
getDockerDiskUsage.mockResolvedValue(
|
||||
report({
|
||||
host: {
|
||||
docker_root_dir: "/var/lib/docker",
|
||||
operating_system: "Docker Desktop",
|
||||
is_docker_desktop: true,
|
||||
is_windows_host: true,
|
||||
vhdx_applies: true,
|
||||
vhdx_note: "Docker Desktop keeps this daemon inside ext4.vhdx on C:.",
|
||||
vhdx_fix: ["wsl --shutdown", 'Optimize-VHD -Path "…docker_data.vhdx" -Mode Full'],
|
||||
vhdx_fix_gui: "Docker Desktop → Settings → Resources → Advanced → Clean up / Purge data",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await renderAndScan();
|
||||
const note = await screen.findByTestId("disk-vhdx-note");
|
||||
expect(
|
||||
within(note).getByText("Reclaiming here will not shrink your C: drive"),
|
||||
).toBeInTheDocument();
|
||||
expect(within(note).getByText(/wsl --shutdown/)).toBeInTheDocument();
|
||||
expect(within(note).getByText(/Optimize-VHD/)).toBeInTheDocument();
|
||||
expect(within(note).getByText(/Purge data/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the vhdx note off a host it does not apply to", async () => {
|
||||
await renderAndScan();
|
||||
await screen.findByTestId("disk-globals");
|
||||
expect(screen.queryByTestId("disk-vhdx-note")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Destructive path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("needs the project name typed before it will delete a config volume", async () => {
|
||||
listReclaimable.mockResolvedValue(
|
||||
plan({
|
||||
destructive: [
|
||||
{
|
||||
target: { kind: "config_volume", project_id: "p-whp" },
|
||||
project_id: "p-whp",
|
||||
project_name: "whp",
|
||||
label: "Claude config volume",
|
||||
loses: "The Claude login credential, plugins, and EVERY conversation transcript.",
|
||||
bytes: 427_000_000,
|
||||
blocked: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
destroyProjectDiskObject.mockResolvedValue({
|
||||
target: { kind: "orphan_volume", name: "triple-c-claude-config-p-whp" },
|
||||
ok: true,
|
||||
freed_bytes: 427_000_000,
|
||||
projected_bytes: null,
|
||||
message: "Removed volume.",
|
||||
});
|
||||
|
||||
await renderAndScan();
|
||||
await screen.findByTestId("disk-row-p-whp");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete whp data" }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Delete claude config volume/ }));
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const confirm = within(dialog).getByRole("button", { name: "Delete claude config volume" });
|
||||
expect(confirm).toBeDisabled();
|
||||
expect(within(dialog).getByText(/EVERY conversation transcript/)).toBeInTheDocument();
|
||||
|
||||
// The wrong name does not open the gate.
|
||||
fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "who" } });
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } });
|
||||
expect(confirm).toBeEnabled();
|
||||
await act(async () => {
|
||||
fireEvent.click(confirm);
|
||||
});
|
||||
expect(destroyProjectDiskObject).toHaveBeenCalledWith(
|
||||
{ kind: "config_volume", project_id: "p-whp" },
|
||||
"whp",
|
||||
);
|
||||
});
|
||||
|
||||
it("never routes a destructive object through the bulk Reclaim button", async () => {
|
||||
listReclaimable.mockResolvedValue(
|
||||
plan({
|
||||
destructive: [
|
||||
{
|
||||
target: { kind: "home_volume", project_id: "p-whp" },
|
||||
project_id: "p-whp",
|
||||
project_name: "whp",
|
||||
label: "Home volume",
|
||||
loses: "Shell history, dotfiles, toolchains.",
|
||||
bytes: 4_860_000_000,
|
||||
blocked: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await renderAndScan();
|
||||
const safe = await screen.findByTestId("disk-safe-bucket");
|
||||
// One tick, for the dangling images — the home volume is not in this list
|
||||
// at any price.
|
||||
expect(within(safe).getAllByRole("checkbox")).toHaveLength(1);
|
||||
expect(within(safe).queryByText(/Home volume/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports what was actually freed against what was projected", async () => {
|
||||
reclaim.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
target: { kind: "compact_snapshot", project_id: "p-whp" },
|
||||
ok: true,
|
||||
freed_bytes: 5_100_000_000,
|
||||
projected_bytes: 7_000_000_000,
|
||||
message: "Rewrote the snapshot into a single layer.",
|
||||
},
|
||||
],
|
||||
total_freed_bytes: 5_100_000_000,
|
||||
});
|
||||
await renderAndScan();
|
||||
await screen.findByTestId("disk-safe-bucket");
|
||||
fireEvent.click(screen.getAllByRole("checkbox")[0]);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
|
||||
});
|
||||
|
||||
const outcome = await screen.findByTestId("disk-outcome");
|
||||
expect(within(outcome).getByText("Reclaimed 5.1 GB")).toBeInTheDocument();
|
||||
expect(within(outcome).getByText(/projected up to 7\.0 GB, actually 5\.1 GB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a scan failure rather than showing stale numbers", async () => {
|
||||
getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host");
|
||||
render(<DiskSettings />);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
|
||||
});
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(/no such host/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user