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
149 lines
6.1 KiB
TypeScript
149 lines
6.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
errorText,
|
|
FILE_EXISTS_MARKER,
|
|
fileExistsPath,
|
|
isFileExistsError,
|
|
readableRefusal,
|
|
} from "./uploadErrors";
|
|
|
|
/**
|
|
* The shapes here are the point of the module.
|
|
*
|
|
* A Tauri command error crosses the IPC boundary as whatever `serde` made of
|
|
* it, and the Rust side is free to change from `Err(String)` to a serialised
|
|
* error enum without anyone thinking of this file. Every one of these has to
|
|
* keep meaning "that name is taken", or an upload that could have been
|
|
* retried with `overwrite: true` degrades into a raw string in a toast.
|
|
*/
|
|
describe("isFileExistsError", () => {
|
|
it("recognises the agreed prose form", () => {
|
|
expect(isFileExistsError("FILE_EXISTS: /workspace/notes.txt already exists")).toBe(true);
|
|
});
|
|
|
|
it("recognises a bare marker", () => {
|
|
expect(isFileExistsError(FILE_EXISTS_MARKER)).toBe(true);
|
|
});
|
|
|
|
it("recognises a serialised error enum, whatever case it is written in", () => {
|
|
expect(isFileExistsError({ kind: "FileExists", path: "/workspace/a.txt" })).toBe(true);
|
|
expect(isFileExistsError({ code: "file-exists" })).toBe(true);
|
|
expect(isFileExistsError({ type: "file_exists" })).toBe(true);
|
|
});
|
|
|
|
it("recognises it inside a message field", () => {
|
|
expect(isFileExistsError({ message: "upload refused: FILE_EXISTS" })).toBe(true);
|
|
expect(isFileExistsError(new Error("FILE_EXISTS: /workspace/a.txt"))).toBe(true);
|
|
});
|
|
|
|
it("looks one level into a wrapped error", () => {
|
|
expect(isFileExistsError({ error: { kind: "FileExists" } })).toBe(true);
|
|
});
|
|
|
|
it("says no to every other failure, which must not raise an overwrite prompt", () => {
|
|
expect(isFileExistsError("File too large to upload (900 MB; limit 256 MB)")).toBe(false);
|
|
expect(isFileExistsError("cp: cannot create regular file: Permission denied")).toBe(false);
|
|
expect(isFileExistsError({ kind: "NotRunning" })).toBe(false);
|
|
expect(isFileExistsError(null)).toBe(false);
|
|
expect(isFileExistsError(undefined)).toBe(false);
|
|
expect(isFileExistsError(42)).toBe(false);
|
|
expect(isFileExistsError({})).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("fileExistsPath", () => {
|
|
it("reads the path out of the agreed prose form", () => {
|
|
expect(fileExistsPath("FILE_EXISTS: /workspace/notes.txt already exists")).toBe(
|
|
"/workspace/notes.txt",
|
|
);
|
|
});
|
|
|
|
it("prefers a structured field", () => {
|
|
expect(fileExistsPath({ kind: "FileExists", path: "/workspace/a.txt" })).toBe(
|
|
"/workspace/a.txt",
|
|
);
|
|
expect(fileExistsPath({ kind: "FileExists", container_path: "/workspace/b.txt" })).toBe(
|
|
"/workspace/b.txt",
|
|
);
|
|
});
|
|
|
|
it("finds one in a wrapped error", () => {
|
|
expect(fileExistsPath({ error: { kind: "FileExists", path: "/workspace/c.txt" } })).toBe(
|
|
"/workspace/c.txt",
|
|
);
|
|
});
|
|
|
|
it("returns null rather than guessing", () => {
|
|
// The caller falls back to the host path it was uploading, which is always
|
|
// known — so "no path" is a perfectly good answer.
|
|
expect(fileExistsPath("FILE_EXISTS")).toBeNull();
|
|
expect(fileExistsPath({ kind: "FileExists" })).toBeNull();
|
|
expect(fileExistsPath(null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The other half of the contract: refusals that are *not* a name clash, but are
|
|
* a sentence the backend wrote for the person reading it. They used to arrive
|
|
* as a toast's `detail`, which renders as collapsed monospace behind a
|
|
* "Details" button — so the only part of the message that explained anything
|
|
* was the part nobody saw.
|
|
*/
|
|
describe("readableRefusal", () => {
|
|
const hidden =
|
|
'".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.';
|
|
const outside =
|
|
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
|
|
|
|
it("recognises the hidden-host-folder refusal, in both directions", () => {
|
|
expect(readableRefusal(hidden)).toBe(hidden);
|
|
expect(
|
|
readableRefusal('".aws" is a hidden folder — Triple-C will not read there. Choose a visible location.'),
|
|
).toContain("hidden folder");
|
|
});
|
|
|
|
it("recognises the container write-root refusal", () => {
|
|
expect(readableRefusal(outside)).toBe(outside);
|
|
});
|
|
|
|
it("strips a wrapper a JS layer put in front of the sentence", () => {
|
|
// `invoke` rejects with the bare string today, but an `Error` anywhere in
|
|
// between would otherwise put "Error: " in front of prose meant to be read.
|
|
expect(readableRefusal(new Error(hidden))).toBe(hidden);
|
|
expect(readableRefusal(`Error: ${hidden}`)).toBe(hidden);
|
|
expect(readableRefusal(`Uncaught (in promise) Error: ${outside}`)).toBe(outside);
|
|
expect(readableRefusal({ message: `invoke failed: ${outside}` })).toBe(outside);
|
|
});
|
|
|
|
it("says nothing about failures that are not a written refusal", () => {
|
|
// Promotion is an improvement, not a fallback: anything unrecognised keeps
|
|
// reporting exactly as it did before.
|
|
expect(readableRefusal("File too large to upload (900 MB; limit 256 MB)")).toBeNull();
|
|
expect(readableRefusal("FILE_EXISTS: /workspace/a.txt already exists")).toBeNull();
|
|
expect(readableRefusal("cp: Permission denied")).toBeNull();
|
|
expect(readableRefusal(null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("errorText", () => {
|
|
it("keeps an ordinary message intact", () => {
|
|
expect(errorText("cp: cannot create regular file: Permission denied")).toBe(
|
|
"cp: cannot create regular file: Permission denied",
|
|
);
|
|
});
|
|
|
|
it("reads a message out of a shape `String()` would render as [object Object]", () => {
|
|
expect(errorText({ message: "Container not running" })).toBe("Container not running");
|
|
expect(errorText({ kind: "NotRunning" })).toBe("NotRunning");
|
|
expect(errorText(new Error("Failed to upload file to container: no space left"))).toBe(
|
|
"Failed to upload file to container: no space left",
|
|
);
|
|
});
|
|
|
|
it("prefers the written refusal when there is one", () => {
|
|
expect(errorText(new Error("Folder path is outside the folders this panel can change (/workspace): /etc"))).toBe(
|
|
"Folder path is outside the folders this panel can change (/workspace): /etc",
|
|
);
|
|
});
|
|
});
|