Reconcile the frontend with the round-1 backend contracts
Five backend branches merged and the TypeScript still compiled, because
none of this is a type error: a field that arrives `undefined`, a variant
nothing emits any more, a prompt whose loop never closes. Six things.
**Orphaned volumes are destructive now, not safe.** `ReclaimTarget::
OrphanVolume` is gone from Rust; the object is a `DestructiveTarget::
OrphanVolume { name, project_id }` confirmed against the *volume's* name,
there being no project to name. The TS union still listed it under
`ReclaimTarget`, and — worse — `DiskProjectTable` keys destructive items
off `project_id`, which an orphan's never matches. So the item existed in
the plan and appeared nowhere on screen. `DiskSettings` now splits the
plan's destructive list and gives orphans their own section with a
per-volume `TypedConfirmModal`. The copy says what a
`triple-c-claude-config-*` volume actually is — a Claude login
credential, every plugin and skill, every transcript that project had —
and keeps the sentence explaining that "no matching project" is a lookup
against the project list and is never inferred from a project being
stopped or having no image, which is the inference that once flagged two
live projects.
`TypedConfirmModal` grew a `subject` prop: asking a user for "the exact
project name" of a volume that has no project is asking for a string that
does not exist.
**Snapshot and Total reconcile.** `ProjectDiskRow.snapshot_attributed_bytes`
is the single figure `snapshot_attribution()` exists to produce. The
column rendered `snapshot_above_base_bytes` and fell back to `—` while
the Total was `size - shared` regardless — and in that branch `size -
shared` is the whole 4.7 GB base image, charged per project and then
added again as a base-image row. One field, one rule. The one branch
where the figure *is* the whole image says so rather than passing itself
off as a share.
**The overwrite loop closes.** Traced end to end: a `FILE_EXISTS:`
refusal raises the prompt, Replace re-invokes with `overwrite: true`,
Skip advances, "…all" answers the rest without asking, and picker and
host-drop both reach `uploadFileToContainer` through `uploadPaths`. Two
gaps: a second batch's `askOverwrite` overwrote the first's resolver,
leaving that batch awaiting an answer no dialog could produce; and the
backend's written refusals — a hidden host folder, a path outside the
write roots — were passed as a toast `detail`, which `ToastHost` renders
as collapsed monospace behind a "Details" button, so the only sentence
that explained anything was the part nobody saw. `readableRefusal`
promotes it to the headline when a batch failed the same way.
**The browser pane's sandbox is pinned.** `allow-same-origin` must stay
(the proxy's gate reads `Origin`/`Referer`, and an opaque origin sends
`null`); every top-navigation grant and `allow-popups-to-escape-sandbox`
must stay absent, and the test names the offending token rather than
printing a set diff.
**`@tauri-apps/plugin-store` is gone** from `package.json` — its
capability grants were removed as a host-file-write primitive and nothing
in `app/src` imports it. The lockfile was updated with
`--package-lock-only`, deliberately: `node_modules` is a symlink shared
with other worktrees and a real install would have pulled it out from
under them.
Nothing under `src-tauri/` is touched. 663 frontend tests pass (was 635),
`tsc --noEmit` clean, `npm run build` green, `cargo test` 446 unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -2,6 +2,7 @@ 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 {
|
||||
DestructiveItem,
|
||||
DiskUsageReport,
|
||||
ProjectDiskRow,
|
||||
ReclaimItem,
|
||||
@@ -45,6 +46,9 @@ const row = (over: Partial<ProjectDiskRow> = {}): ProjectDiskRow => ({
|
||||
home_volume_present: true,
|
||||
config_volume_bytes: 427_000_000,
|
||||
config_volume_present: true,
|
||||
// The one figure the Snapshot column shows and the Total is built from.
|
||||
// 8.44 + 0.868 + 4.86 + 0.427 == 14.596, and the table is expected to add up.
|
||||
snapshot_attributed_bytes: 8_440_966_715,
|
||||
total_bytes: 14_595_966_715,
|
||||
migrating: false,
|
||||
...over,
|
||||
@@ -115,6 +119,26 @@ const result = (over: Partial<ReclaimResult> = {}): ReclaimResult => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
/** An orphaned volume, as `list_reclaimable` now describes it: a
|
||||
* `DestructiveItem`, never a `ReclaimItem`. `project_name` carries the
|
||||
* *volume* name, because there is no project to name — that is the definition
|
||||
* of the variant, and it is what `destroy` compares the typed string against. */
|
||||
const orphan = (over: Partial<DestructiveItem> = {}): DestructiveItem => ({
|
||||
target: {
|
||||
kind: "orphan_volume",
|
||||
name: "triple-c-claude-config-gone",
|
||||
project_id: "gone",
|
||||
},
|
||||
project_id: "gone",
|
||||
project_name: "triple-c-claude-config-gone",
|
||||
label: "triple-c-claude-config-gone (config volume)",
|
||||
loses:
|
||||
"Named for project id gone, which is not in Triple-C's project list, and no container is attached to it. Docker created it on 2026-03-14T09:00:00Z. This is a `.claude` volume — it held that project's Claude credential, plugins and session transcripts. Not recoverable. Type the volume name to confirm.",
|
||||
bytes: 900_000,
|
||||
blocked: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const plan = (over: Partial<ReclaimPlan> = {}): ReclaimPlan => ({
|
||||
items: [item()],
|
||||
destructive: [],
|
||||
@@ -196,14 +220,43 @@ describe("DiskSettings", () => {
|
||||
expect(within(projectRow).queryByText("17")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an unmeasurable snapshot split as a dash, never as zero", async () => {
|
||||
it("builds the Snapshot column and the Total from the same attributed figure", async () => {
|
||||
// The bug this pins: the column rendered `snapshot_above_base_bytes` and
|
||||
// fell back to `—`, while the total was `snapshot_bytes -
|
||||
// snapshot_shared_bytes` regardless — which in the fallback branch is the
|
||||
// whole 4.7 GB base image, charged to every row and then added again as a
|
||||
// base-image row in the globals. One field, computed once in Rust.
|
||||
await renderAndScan();
|
||||
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||
expect(within(projectRow).getByText("8.4 GB")).toBeInTheDocument();
|
||||
expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says a snapshot figure is the whole image rather than passing it off as a share", async () => {
|
||||
// `snapshot_above_base_bytes` is null in exactly one branch: nothing
|
||||
// measurably shares layers with the snapshot *and* its base is gone. The
|
||||
// attributed figure is then the whole image — an honest cost, not a guess
|
||||
// and not zero — but it does not mean what the other rows' figures mean,
|
||||
// so the sub-line has to say which one this is.
|
||||
getDockerDiskUsage.mockResolvedValue(
|
||||
report({ projects: [row({ snapshot_above_base_bytes: null })] }),
|
||||
report({
|
||||
projects: [
|
||||
row({
|
||||
snapshot_shared_bytes: 0,
|
||||
snapshot_above_base_bytes: null,
|
||||
snapshot_attributed_bytes: 12_273_392_374,
|
||||
total_bytes: 18_428_392_374,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await renderAndScan();
|
||||
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||
expect(within(projectRow).queryByText("0 B")).not.toBeInTheDocument();
|
||||
expect(within(projectRow).getAllByText("—").length).toBeGreaterThan(0);
|
||||
expect(within(projectRow).getByText("12.3 GB")).toBeInTheDocument();
|
||||
expect(projectRow.textContent).toMatch(/whole image — base unknown/);
|
||||
// And it must not still claim the "N with base" split it cannot measure.
|
||||
expect(projectRow.textContent).not.toMatch(/with base/);
|
||||
});
|
||||
|
||||
it("marks a heavily stacked snapshot with a word, not just a colour", async () => {
|
||||
@@ -424,6 +477,79 @@ describe("DiskSettings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("never offers an orphaned volume as a tick in the safe bucket", async () => {
|
||||
// It used to be a `ReclaimTarget` at `Safety::Safe` — a tick and the group
|
||||
// Reclaim button, no confirmation — for a volume holding a Claude
|
||||
// credential and every transcript a project ever had. The Rust variant is
|
||||
// gone; this pins that the frontend cannot resurrect it.
|
||||
listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] }));
|
||||
await renderAndScan();
|
||||
const safe = await screen.findByTestId("disk-safe-bucket");
|
||||
expect(within(safe).getAllByRole("checkbox")).toHaveLength(1);
|
||||
expect(safe.textContent).not.toMatch(/triple-c-claude-config-gone/);
|
||||
// And it is reachable — an item that matches no project row would
|
||||
// otherwise simply vanish from the UI.
|
||||
expect(await screen.findByTestId("disk-orphan-bucket")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps orphaned volumes out of the per-project table", async () => {
|
||||
// The table keys off `project_id`, and an orphan's id matches no row by
|
||||
// definition. Passing them in anyway is how one would leak into the wrong
|
||||
// project's overflow menu if a row ever shared the id.
|
||||
listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] }));
|
||||
await renderAndScan();
|
||||
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||
expect(projectRow.textContent).not.toMatch(/triple-c-claude-config-gone/);
|
||||
});
|
||||
|
||||
it("says what a config volume actually holds, not 'volume data'", async () => {
|
||||
listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] }));
|
||||
await renderAndScan();
|
||||
const bucket = await screen.findByTestId("disk-orphan-bucket");
|
||||
expect(bucket.textContent).toMatch(/Claude login credential/i);
|
||||
expect(bucket.textContent).toMatch(/every plugin and skill installed into it/i);
|
||||
expect(bucket.textContent).toMatch(/every conversation transcript it ever had/i);
|
||||
// The derivation caveat travels with the offer, not only with the totals.
|
||||
expect(bucket.textContent).toMatch(/not.*inferred from a project being stopped/i);
|
||||
});
|
||||
|
||||
it("confirms an orphaned volume against its own name, never a project's", async () => {
|
||||
listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] }));
|
||||
destroyProjectDiskObject.mockResolvedValue({ results: [], total_freed_bytes: 0 });
|
||||
await renderAndScan();
|
||||
const bucket = await screen.findByTestId("disk-orphan-bucket");
|
||||
await act(async () => {
|
||||
fireEvent.click(within(bucket).getByRole("button", { name: /Delete/ }));
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
// Asking for "the exact project name" would be asking for a string that
|
||||
// does not exist.
|
||||
expect(within(dialog).getByRole("status")).toHaveTextContent(
|
||||
"Waiting for the exact volume name.",
|
||||
);
|
||||
const input = within(dialog).getByLabelText(/Type/);
|
||||
const confirm = within(dialog).getByRole("button", { name: "Delete volume" });
|
||||
|
||||
// The project id parsed out of the name is display only and must not open
|
||||
// the gate.
|
||||
fireEvent.change(input, { target: { value: "gone" } });
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
fireEvent.change(input, { target: { value: "triple-c-claude-config-gone" } });
|
||||
expect(confirm).toBeEnabled();
|
||||
await act(async () => {
|
||||
fireEvent.click(confirm);
|
||||
});
|
||||
|
||||
expect(destroyProjectDiskObject).toHaveBeenCalledWith(
|
||||
{ kind: "orphan_volume", name: "triple-c-claude-config-gone", project_id: "gone" },
|
||||
"triple-c-claude-config-gone",
|
||||
);
|
||||
// One volume, one confirmation — `reclaim` never sees it.
|
||||
expect(reclaim).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -647,7 +773,7 @@ describe("DiskSettings", () => {
|
||||
result({ target: { kind: "migration_pins" } }),
|
||||
result({ target: { kind: "probe_containers" } }),
|
||||
result({ target: { kind: "build_cache", all: true }, ok: false }),
|
||||
result({ target: { kind: "orphan_volume", name: "v" }, ok: false }),
|
||||
result({ target: { kind: "scrub_containers" }, ok: false }),
|
||||
],
|
||||
total_freed_bytes: 1_200_000_000,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user