Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) Successful in 37s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / build-linux (pull_request) Successful in 5m11s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Two independent reviews of 2c9482a, one for correctness and one against the
threat model. Between them they found two ways to lose a file, one way for a
container to choose a Windows save destination, and a rule that made every
dotfile unsavable. Every finding below was demonstrated against a real
container before being fixed, and the fixes are demonstrated the same way.
## The download was accepting truncated reads and refusing whole ones
The `[ -f ]` bracket around the read was wrong in both directions.
It missed the case that loses data. Truncation *in place* — `> file`, log
rotation, `tar -x`, most build tools — leaves a regular file behind, so `dd`
stopped at the new EOF and exited 0. Measured: a 600 MB source truncated
mid-read delivered 34 MB, which was then renamed over the user's own earlier
copy and toasted as `Saved (34.1 MB)`. Truncate-to-zero did the same and needs
no adversary at all.
And it failed *good* downloads. `dd` already holds the fd, and neither `rm` nor
`mv` can touch an open one — the bytes are complete. But `rm` makes `[ -f ]`
false, so a finished 600 MB transfer of a file a bundler happened to unlink was
deleted, reporting "nothing was saved". The comment claiming the bracket caught
a "mix of two files" was simply wrong; an open fd cannot be a mix.
So the script now measures the file before reading it and puts the answer on
stderr, and Rust checks that at least that many bytes arrived. One rule,
subsuming everything the bracket was for. Verified against a real container:
truncation mid-read and the FIFO race are refused; deletion, rename-over, a
growing file, an empty file and an untouched 600 MB read all pass.
## On Windows the container, not the user, was naming the save destination
`suggested_save_name` split on `/` only, and it feeds the save dialog's
pre-filled name. Backslash is a legal Linux filename character and
`validate_container_path` has no reason to object — `..\..\Users\…` is one
POSIX segment. The Windows common file dialog parses its name box as a path on
Save, so a container-created file called
`..\..\..\Users\vic\AppData\Roaming\Microsoft\Word\STARTUP\x.dotm` put its own
bytes in an auto-loading Office directory on one un-read click. `resolve_host_path`
did not stop it: Word's `STARTUP` and Excel's `XLSTART` are not in the autorun
denylist, which this file's own docs already concede is "losing by
construction" and was never meant to be the boundary here.
The name is now sanitized of every separator, the drive colon and the rest of
what NTFS refuses, so it cannot be a path on any platform this ships to.
## No dotfile could be saved, and the app pre-filled the name that guaranteed it
The write policy judged the leaf for hiddenness, so `/workspace/.env` was
refused *after* the modal and the overwrite prompt — quoting the name the app
itself had suggested. `.gitignore`, `.dockerignore`, `.eslintrc.json`, `.nvmrc`:
all unsavable, while uploading them worked, so a dotfile could go in and never
come out.
`HostPathUse` gains a third mode. `WriteChosenName` drops the leaf check and
keeps every directory rule, and only `download_container_file` uses it — the
dialog is a real boundary for that caller and only that caller.
`download_container_backup` still takes its path over IPC as a string and keeps
the strict rule.
## Smaller, all found by the reviews
* A download had no ceiling. `dd` resolves through the container's `PATH`,
which its agent owns with passwordless sudo; a replacement writing forever
was measured at ~6 GB/s, so one click on a file listed as 2 KB filled the
host disk with no progress shown and no cancel. Bounded now by what the
file measured, with slack that is absolute for small files and
proportional for large ones, so an honest growing log is unaffected.
* "Framed rather than verbatim" did not stop container text reaching the
toast headline: `readableRefusal` matches with `includes`, and it has to,
because the app's own refusals carry those markers mid-sentence. Anchoring
would break them. The fix is at the injection point — container text is
clipped to one 200-character line with control characters stripped — plus a
`max-h-40` on the toast message, which the `detail` block always had and
this half did not. 8 KB of prose in a `z-[60]` card pushed its own dismiss
button off-screen.
* `savingPath` was a scalar while the design deliberately allows concurrent
saves. Starting a second freed the first's row mid-transfer, and whichever
finished first cleared both; dismissing the second dialog was enough. It is
a `Set` now.
* `setUploading(false)` fired when the command settled, not when the refresh
finished, so a second click landed mid-relisting.
* `upload_files_to_container` checked the container directory before the
picker and then used the unresolved path. A modal has no time limit. It
re-checks after, which is what `docker::exec`'s doc comment already claimed.
* `wait_for_exec_exit` flattened a missing exit code to `Some(0)`, which made
the new `!= Some(0)` check unreachable by construction.
* `normalize_host_path` did not collapse repeated separators, so the lexical
system-root rule was silently absent for `C:\\Windows\…`. Not exploitable —
the resolved pass catches it — but a documented layer that does nothing is
a trap for the next caller.
* README still carried the "no host path crosses IPC in either direction"
claim the previous commit narrowed everywhere else, and HOW-TO-USE
described the hidden rule without saying it applies to folders only.
480 Rust tests, 602 frontend, no new clippy warnings. Eleven mutations against
the new tests, all killed — two of the first round survived and were rewritten:
one because `split_whitespace` already handled the case I thought I was
testing, one because I had deleted a comment rather than the behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
562 lines
22 KiB
TypeScript
562 lines
22 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import { useFileManager } from "./useFileManager";
|
|
import type { FileEntry } from "../lib/types";
|
|
|
|
const listContainerFiles = vi.fn();
|
|
const renameContainerPath = vi.fn();
|
|
const createContainerDirectory = vi.fn();
|
|
const uploadFilesToContainer = vi.fn();
|
|
const downloadContainerFile = vi.fn();
|
|
|
|
vi.mock("../lib/tauri-commands", () => ({
|
|
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
|
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
|
createContainerDirectory: (p: string, parent: string, n: string) =>
|
|
createContainerDirectory(p, parent, n),
|
|
uploadFilesToContainer: (p: string, dir: string) => uploadFilesToContainer(p, dir),
|
|
downloadContainerFile: (p: string, path: string) => downloadContainerFile(p, path),
|
|
readContainerFile: vi.fn(),
|
|
}));
|
|
|
|
/**
|
|
* Transient failures go to `ToastHost` rather than an inline string — see the
|
|
* comment at the top of `useFileManager`. The store is mocked down to the one
|
|
* method the hook reaches for.
|
|
*/
|
|
const pushToast = vi.fn();
|
|
vi.mock("../store/appState", () => ({
|
|
useAppState: { getState: () => ({ pushToast }) },
|
|
}));
|
|
|
|
/** Everything the hook has said through the toast host, message and detail. */
|
|
const toastText = () =>
|
|
pushToast.mock.calls
|
|
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
|
.join("\n");
|
|
|
|
const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
|
name,
|
|
path: `/workspace/${name}`,
|
|
is_directory: false,
|
|
is_symlink: false,
|
|
size: 10,
|
|
modified: "2024-01-01 00:00:00",
|
|
permissions: "644",
|
|
...extra,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
listContainerFiles.mockResolvedValue([file("a.txt")]);
|
|
uploadFilesToContainer.mockResolvedValue({ uploaded: [], failures: [] });
|
|
downloadContainerFile.mockResolvedValue(0);
|
|
});
|
|
|
|
describe("useFileManager navigation", () => {
|
|
it("lists a directory and remembers where it is", async () => {
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.navigate("/workspace/app");
|
|
});
|
|
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/app");
|
|
expect(result.current.currentPath).toBe("/workspace/app");
|
|
expect(result.current.entries).toHaveLength(1);
|
|
});
|
|
|
|
it("surfaces a listing failure rather than showing a stale directory", async () => {
|
|
listContainerFiles.mockRejectedValueOnce("Permission denied");
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.navigate("/root");
|
|
});
|
|
expect(result.current.error).toContain("Permission denied");
|
|
expect(result.current.currentPath).toBe("/workspace");
|
|
});
|
|
|
|
it("goes up one level, and stops at the root", async () => {
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.navigate("/workspace/app/src");
|
|
});
|
|
await act(async () => {
|
|
result.current.goUp();
|
|
});
|
|
await waitFor(() => expect(result.current.currentPath).toBe("/workspace/app"));
|
|
|
|
await act(async () => {
|
|
await result.current.navigate("/");
|
|
});
|
|
listContainerFiles.mockClear();
|
|
await act(async () => {
|
|
result.current.goUp();
|
|
});
|
|
expect(listContainerFiles).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("useFileManager rename and mkdir", () => {
|
|
it("sends the bare new name, never a path, and re-lists on success", async () => {
|
|
renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.navigate("/workspace");
|
|
});
|
|
listContainerFiles.mockClear();
|
|
|
|
let ok: boolean | undefined;
|
|
await act(async () => {
|
|
ok = await result.current.renameEntry(file("a.txt"), " renamed.txt ");
|
|
});
|
|
expect(ok).toBe(true);
|
|
expect(renameContainerPath).toHaveBeenCalledWith("p1", "/workspace/a.txt", "renamed.txt");
|
|
expect(listContainerFiles).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps the editor open and shows what the container said when a rename fails", async () => {
|
|
// Renames outside /workspace legitimately fail; the user needs mv's words.
|
|
renameContainerPath.mockRejectedValue("mv: cannot move '/etc/hosts': Permission denied");
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
let ok: boolean | undefined;
|
|
await act(async () => {
|
|
ok = await result.current.renameEntry(file("hosts"), "hosts.bak");
|
|
});
|
|
expect(ok).toBe(false);
|
|
expect(toastText()).toContain("Permission denied");
|
|
});
|
|
|
|
it("treats an unchanged name as a no-op rather than a round trip", async () => {
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.renameEntry(file("a.txt"), "a.txt");
|
|
});
|
|
expect(renameContainerPath).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates a folder under the current directory", async () => {
|
|
createContainerDirectory.mockResolvedValue("/workspace/app/new");
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.navigate("/workspace/app");
|
|
});
|
|
await act(async () => {
|
|
await result.current.createFolder(" new ");
|
|
});
|
|
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace/app", "new");
|
|
});
|
|
|
|
it("surfaces a clash instead of silently doing nothing", async () => {
|
|
createContainerDirectory.mockRejectedValue("mkdir: cannot create directory 'src': File exists");
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
let ok: boolean | undefined;
|
|
await act(async () => {
|
|
ok = await result.current.createFolder("src");
|
|
});
|
|
expect(ok).toBe(false);
|
|
expect(toastText()).toContain("File exists");
|
|
});
|
|
});
|
|
|
|
describe("useFileManager stays where the user is", () => {
|
|
it("does not drag the pane back when the user navigates away mid-operation", async () => {
|
|
// The closure captured `/workspace`; the user is in `/workspace/src` by the
|
|
// time the rename finishes. Re-listing the *captured* path is what used to
|
|
// yank them out of the directory they had walked into.
|
|
let failRename: (reason: unknown) => void = () => {};
|
|
// `Once`, deliberately: `clearAllMocks` clears calls but not
|
|
// implementations, so a never-settling one would hang every test after it.
|
|
renameContainerPath.mockImplementationOnce(
|
|
() => new Promise((_resolve, reject) => { failRename = reject; }),
|
|
);
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
let rename!: Promise<boolean>;
|
|
await act(async () => {
|
|
rename = result.current.renameEntry(file("big.bin"), "bigger.bin");
|
|
await Promise.resolve();
|
|
});
|
|
|
|
listContainerFiles.mockResolvedValue([file("index.ts", { path: "/workspace/src/index.ts" })]);
|
|
await act(async () => {
|
|
await result.current.navigate("/workspace/src");
|
|
});
|
|
listContainerFiles.mockClear();
|
|
|
|
await act(async () => {
|
|
failRename("mv: no space left on device");
|
|
await rename;
|
|
});
|
|
|
|
expect(result.current.currentPath).toBe("/workspace/src");
|
|
expect(result.current.entries.map((e) => e.name)).toEqual(["index.ts"]);
|
|
// No re-list of the directory the rename targeted…
|
|
expect(listContainerFiles).not.toHaveBeenCalled();
|
|
// …and no failure text painted over the listing that replaced it.
|
|
expect(result.current.error).toBeNull();
|
|
expect(toastText()).toContain("no space left");
|
|
});
|
|
|
|
it("re-lists when the user stayed put, which is the ordinary case", async () => {
|
|
renameContainerPath.mockResolvedValue("/workspace/b.txt");
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.navigate("/workspace");
|
|
});
|
|
listContainerFiles.mockClear();
|
|
await act(async () => {
|
|
await result.current.renameEntry(file("a.txt"), "b.txt");
|
|
});
|
|
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
|
|
});
|
|
|
|
it("lets the newest listing win when a slow one lands last", async () => {
|
|
// Two listings in flight, and the slower one is not necessarily the older
|
|
// one. Landing last used to set both the rows and the breadcrumb back.
|
|
let landSlow: (entries: FileEntry[]) => void = () => {};
|
|
listContainerFiles.mockImplementationOnce(
|
|
() => new Promise((resolve) => { landSlow = resolve; }),
|
|
);
|
|
listContainerFiles.mockResolvedValueOnce([file("new.txt")]);
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
let slow!: Promise<void>;
|
|
await act(async () => {
|
|
slow = result.current.navigate("/workspace/slow");
|
|
await Promise.resolve();
|
|
});
|
|
await act(async () => {
|
|
await result.current.navigate("/workspace/fast");
|
|
});
|
|
expect(result.current.currentPath).toBe("/workspace/fast");
|
|
|
|
await act(async () => {
|
|
landSlow([file("stale.txt")]);
|
|
await slow;
|
|
});
|
|
|
|
expect(result.current.currentPath).toBe("/workspace/fast");
|
|
expect(result.current.entries.map((e) => e.name)).toEqual(["new.txt"]);
|
|
});
|
|
|
|
it("keeps a failed navigation from claiming the directory it never reached", async () => {
|
|
listContainerFiles.mockRejectedValueOnce("Permission denied");
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.navigate("/root");
|
|
});
|
|
listContainerFiles.mockClear();
|
|
// The pane never left /workspace, so a new folder made now lands there.
|
|
await act(async () => {
|
|
await result.current.createFolder("new");
|
|
});
|
|
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace", "new");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Refusals the backend already phrased for a person. `ToastHost` renders a
|
|
* `detail` as collapsed monospace behind a "Details" button, so a sentence
|
|
* reported that way is a sentence nobody reads.
|
|
*/
|
|
describe("useFileManager surfaces written refusals as prose", () => {
|
|
const outsideRoots =
|
|
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
|
|
|
|
/** The toast this operation pushed. */
|
|
const lastToast = () => pushToast.mock.calls.at(-1)?.[0];
|
|
|
|
it("puts the write-root refusal in the headline, not behind Details", async () => {
|
|
createContainerDirectory.mockRejectedValueOnce(outsideRoots);
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.createFolder("new");
|
|
});
|
|
|
|
expect(lastToast().message).toBe(outsideRoots);
|
|
expect(lastToast().detail).toBeUndefined();
|
|
expect(lastToast().message).not.toMatch(/^Error:/);
|
|
});
|
|
|
|
it("unwraps an `Error` rather than stamping \"Error:\" on prose", async () => {
|
|
renameContainerPath.mockRejectedValueOnce(new Error(outsideRoots));
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.renameEntry(file("a.txt"), "b.txt");
|
|
});
|
|
|
|
expect(lastToast().message).toBe(outsideRoots);
|
|
});
|
|
|
|
it("keeps the hook's own headline when the failure is not a written refusal", async () => {
|
|
createContainerDirectory.mockRejectedValueOnce("no space left on device");
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.createFolder("new");
|
|
});
|
|
|
|
expect(lastToast().message).toBe('Could not create "new"');
|
|
expect(lastToast().detail).toBe("no space left on device");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Both of these actions are *dialog-driven from Rust* — the hook passes a
|
|
* project and a directory and gets back an answer, and there is deliberately no
|
|
* host path anywhere in this file. What is worth pinning is the vocabulary of
|
|
* that answer, because two of its values look like failure and are not: `null`
|
|
* means the user dismissed the picker, and `0` bytes means an empty file was
|
|
* saved successfully.
|
|
*/
|
|
describe("useFileManager saving to the host", () => {
|
|
it("treats a zero-byte save as a success", async () => {
|
|
// The bug this exists for: `if (!bytes) return` reads a genuine
|
|
// zero-length file — an empty `.gitkeep`, a truncated log — as a
|
|
// dismissal, so the file lands on the host and the app says nothing at
|
|
// all. The sentinel is `null`, and only `null`.
|
|
downloadContainerFile.mockResolvedValueOnce(0);
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.saveToHost(file("empty.txt"));
|
|
});
|
|
expect(result.current.completed).toContain("empty.txt");
|
|
expect(pushToast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("says nothing at all when the dialog is dismissed", async () => {
|
|
downloadContainerFile.mockResolvedValueOnce(null);
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.saveToHost(file("a.txt"));
|
|
});
|
|
expect(result.current.completed).toBeNull();
|
|
expect(pushToast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("names the file in a refusal", async () => {
|
|
downloadContainerFile.mockRejectedValueOnce("/etc/shadow is not readable");
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.saveToHost(file("secret.txt"));
|
|
});
|
|
expect(toastText()).toContain("secret.txt");
|
|
});
|
|
});
|
|
|
|
describe("useFileManager uploading from the host", () => {
|
|
it("uploads into the directory on screen and shows the result", async () => {
|
|
uploadFilesToContainer.mockResolvedValueOnce({
|
|
uploaded: ["/workspace/app/one.txt", "/workspace/app/two.txt"],
|
|
failures: [],
|
|
});
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.navigate("/workspace/app");
|
|
});
|
|
listContainerFiles.mockClear();
|
|
await act(async () => {
|
|
await result.current.uploadFiles();
|
|
});
|
|
expect(uploadFilesToContainer).toHaveBeenCalledWith("p1", "/workspace/app");
|
|
expect(result.current.completed).toContain("2 files");
|
|
// The directory is named. `target` is captured at click time and the
|
|
// picker is a modal dialog, so "Uploaded 2 files." on its own can be shown
|
|
// in front of a grid those files are not in.
|
|
expect(result.current.completed).toContain("/workspace/app");
|
|
// The new files are only on screen if the listing was asked for again.
|
|
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/app");
|
|
});
|
|
|
|
it("reports every file that failed, not just a count", async () => {
|
|
// "3 of 5 uploaded" without naming the two is not a report — the user
|
|
// cannot tell which ones to retry, or why.
|
|
uploadFilesToContainer.mockResolvedValueOnce({
|
|
uploaded: ["/workspace/ok.txt"],
|
|
failures: [
|
|
"/home/j/Pictures is a folder — upload its files individually.",
|
|
"/home/j/vm.img is too large to upload (900 MB; limit 256 MB).",
|
|
],
|
|
});
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.uploadFiles();
|
|
});
|
|
expect(pushToast).toHaveBeenCalledTimes(2);
|
|
expect(toastText()).toContain("is a folder");
|
|
expect(toastText()).toContain("too large");
|
|
// A partial batch still succeeded partially, and the pane must show it.
|
|
expect(result.current.completed).toContain("1 file");
|
|
});
|
|
|
|
it("does not refresh when the picker was dismissed", async () => {
|
|
uploadFilesToContainer.mockResolvedValueOnce(null);
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.navigate("/workspace/app");
|
|
});
|
|
listContainerFiles.mockClear();
|
|
await act(async () => {
|
|
await result.current.uploadFiles();
|
|
});
|
|
expect(listContainerFiles).not.toHaveBeenCalled();
|
|
expect(pushToast).not.toHaveBeenCalled();
|
|
expect(result.current.completed).toBeNull();
|
|
});
|
|
|
|
it("reports a refusal that happened before the picker once, not per file", async () => {
|
|
// No container, not running, or a directory this pane may not write to.
|
|
// There is no selection yet, so there is nothing to enumerate.
|
|
uploadFilesToContainer.mockRejectedValueOnce(
|
|
"Start the project before uploading files — it runs inside the running container.",
|
|
);
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.uploadFiles();
|
|
});
|
|
expect(pushToast).toHaveBeenCalledTimes(1);
|
|
expect(toastText()).toContain("Start the project");
|
|
});
|
|
|
|
it("does not drag the pane back when the user navigated during the upload", async () => {
|
|
// The same rule rename and new-folder follow: a slow operation must not
|
|
// relist a directory the user has already left.
|
|
let release: (v: unknown) => void = () => {};
|
|
uploadFilesToContainer.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
release = resolve;
|
|
}),
|
|
);
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.navigate("/workspace/app");
|
|
});
|
|
let uploading: Promise<void>;
|
|
act(() => {
|
|
uploading = result.current.uploadFiles();
|
|
});
|
|
await act(async () => {
|
|
await result.current.navigate("/workspace/other");
|
|
});
|
|
listContainerFiles.mockClear();
|
|
await act(async () => {
|
|
release({ uploaded: ["/workspace/app/one.txt"], failures: [] });
|
|
await uploading;
|
|
});
|
|
expect(listContainerFiles).not.toHaveBeenCalled();
|
|
expect(result.current.currentPath).toBe("/workspace/other");
|
|
});
|
|
});
|
|
|
|
describe("useFileManager transfer state", () => {
|
|
it("marks an upload in flight for as long as it runs", async () => {
|
|
// Without this the button stays live: a second click opens a second OS
|
|
// dialog and runs a second concurrent exec, and a slow transfer looks
|
|
// exactly like a click that did nothing.
|
|
let release: (v: unknown) => void = () => {};
|
|
uploadFilesToContainer.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
release = resolve;
|
|
}),
|
|
);
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
expect(result.current.uploading).toBe(false);
|
|
let uploading: Promise<void>;
|
|
act(() => {
|
|
uploading = result.current.uploadFiles();
|
|
});
|
|
expect(result.current.uploading).toBe(true);
|
|
await act(async () => {
|
|
release({ uploaded: [], failures: [] });
|
|
await uploading;
|
|
});
|
|
expect(result.current.uploading).toBe(false);
|
|
});
|
|
|
|
it("stays in flight through the refresh, not just the transfer", async () => {
|
|
// Clearing the flag the moment the command settled put the button back
|
|
// while the re-listing was still running, so a second click landed
|
|
// mid-refresh on a grid that was still showing the old contents.
|
|
uploadFilesToContainer.mockResolvedValueOnce({
|
|
uploaded: ["/workspace/a.txt"],
|
|
failures: [],
|
|
});
|
|
let finishListing: (v: unknown) => void = () => {};
|
|
listContainerFiles.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
finishListing = resolve;
|
|
}),
|
|
);
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
let uploading: Promise<void>;
|
|
act(() => {
|
|
uploading = result.current.uploadFiles();
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
// The transfer is done; the listing it triggered is not.
|
|
expect(result.current.uploading).toBe(true);
|
|
await act(async () => {
|
|
finishListing([file("a.txt")]);
|
|
await uploading;
|
|
});
|
|
expect(result.current.uploading).toBe(false);
|
|
});
|
|
|
|
it("clears the upload flag when the transfer fails", async () => {
|
|
// The `catch` returns early, so without a `finally` the button is disabled
|
|
// for the rest of the session — the failure mode is a pane that can never
|
|
// upload again, with no error left on screen to explain it.
|
|
uploadFilesToContainer.mockRejectedValueOnce("Start the project first");
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.uploadFiles();
|
|
});
|
|
expect(result.current.uploading).toBe(false);
|
|
});
|
|
|
|
it("tracks each save separately, so one finishing does not free another", async () => {
|
|
// The bug this exists for: `savingPath` was a single string. Starting a
|
|
// second save overwrote it, so the first row went live again mid-transfer,
|
|
// and whichever save settled first cleared the flag for both — dismissing
|
|
// the second dialog was enough. A set is what the design needs, because
|
|
// "only the row being saved is disabled" is exactly what makes a second
|
|
// save startable.
|
|
let releaseBig: (v: unknown) => void = () => {};
|
|
let releaseSmall: (v: unknown) => void = () => {};
|
|
downloadContainerFile
|
|
.mockReturnValueOnce(new Promise((r) => { releaseBig = r; }))
|
|
.mockReturnValueOnce(new Promise((r) => { releaseSmall = r; }));
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
let big: Promise<void>;
|
|
let small: Promise<void>;
|
|
act(() => { big = result.current.saveToHost(file("big.bin")); });
|
|
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
|
|
|
|
act(() => { small = result.current.saveToHost(file("notes.txt")); });
|
|
// Both, at once — a scalar could only hold the second.
|
|
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
|
|
expect(result.current.savingPaths.has("/workspace/notes.txt")).toBe(true);
|
|
|
|
// The second one finishing must not re-enable the first, which is still
|
|
// streaming. `null` is the dismissal path, which is how this was cheapest
|
|
// to trigger in practice.
|
|
await act(async () => { releaseSmall(null); await small; });
|
|
expect(result.current.savingPaths.has("/workspace/notes.txt")).toBe(false);
|
|
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
|
|
|
|
await act(async () => { releaseBig(10); await big; });
|
|
expect(result.current.savingPaths.size).toBe(0);
|
|
});
|
|
|
|
it("clears a row's saving flag when its save fails", async () => {
|
|
downloadContainerFile.mockRejectedValueOnce("Permission denied");
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
await act(async () => {
|
|
await result.current.saveToHost(file("b.txt"));
|
|
});
|
|
expect(result.current.savingPaths.size).toBe(0);
|
|
});
|
|
});
|