fix(viewer): byte-faithful saves, poll/save races, clearer errors
- Keep CRLF (or CR) line endings and a UTF-8 BOM through the editor: textFormat.ts records the dominant separator and the BOM on load and restores both on save, so a save changes only the user's edits. - A clean document whose reload failed retries on the next poll. - A poll that overlaps a save, or was issued before one settled, is ignored instead of reading the pre-save hash as a change. - A conflict whose follow-up poll has no hash shows an error with a Reload button rather than an Overwrite that could only conflict again. - Match write.rs's exact read-only message; show the read-only reason as visible text; error banners are role="alert". - vite/client types move to src/vite-env.d.ts. - Tests: CRLF and BOM saves, reload retry, poll/save race, null-hash conflict, Save and close success and failure, and CodeEditor.setDoc keeping cursor and scroll. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, fireEvent } from "@testing-library/react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import EditorPane from "./EditorPane";
|
||||
import { encodeBase64 } from "../components/projects/home/filePreview";
|
||||
import type { ViewerState } from "../lib/types";
|
||||
|
||||
const H1 = "1".repeat(64);
|
||||
@@ -37,6 +39,15 @@ const textFile = (text: string, hash: string, extra: Partial<{ truncated: boolea
|
||||
/** Mark the buffer dirty through the pane's test hook (jsdom cannot drive CodeMirror's contenteditable). */
|
||||
const edit = () => fireEvent(document, new CustomEvent("triple-c-test-edit"));
|
||||
const clickSave = async () => { await act(async () => { fireEvent.click(screen.getByRole("button", { name: /^save$/i })); }); };
|
||||
/** A real edit through CodeMirror, so the saved bytes carry it. */
|
||||
const typeInto = (from: number, to: number, insert: string) => {
|
||||
const view = EditorView.findFromDOM(document.querySelector(".cm-editor") as HTMLElement);
|
||||
if (!view) throw new Error("no editor");
|
||||
act(() => { view.dispatch({ changes: { from, to, insert } }); });
|
||||
};
|
||||
const bytesB64 = (bytes: number[]) => encodeBase64(new Uint8Array(bytes));
|
||||
const utf8 = (s: string) => Array.from(new TextEncoder().encode(s));
|
||||
const READ_ONLY = "Could not save the file: The file is read-only for the container user.";
|
||||
const poll = async (ms = 2100) => { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); };
|
||||
|
||||
describe("EditorPane", () => {
|
||||
@@ -163,7 +174,7 @@ describe("EditorPane", () => {
|
||||
});
|
||||
|
||||
it("a save refused because the file is read-only says so and keeps the buffer", async () => {
|
||||
commands.viewerWriteFile.mockRejectedValue("Could not save the file: The file is read-only.");
|
||||
commands.viewerWriteFile.mockRejectedValue(READ_ONLY);
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("/workspace/demo/notes.md");
|
||||
edit();
|
||||
@@ -213,4 +224,103 @@ describe("EditorPane", () => {
|
||||
expect(prevent).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/Unsaved changes/)).toBeNull();
|
||||
});
|
||||
|
||||
it("a one-character edit to a CRLF file saves with every CRLF intact", async () => {
|
||||
commands.viewerReadFile.mockResolvedValue(textFile("a\r\nb\r\nc\r\n", H1));
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("Saved");
|
||||
typeInto(2, 3, "B"); // the editor holds "a\nb\nc\n"
|
||||
expect(screen.getByText("Unsaved")).toBeInTheDocument();
|
||||
await clickSave();
|
||||
expect(commands.viewerWriteFile).toHaveBeenCalledWith(b64("a\r\nB\r\nc\r\n"), H1);
|
||||
});
|
||||
|
||||
it("a file with a UTF-8 BOM keeps its BOM on save", async () => {
|
||||
const BOM = [0xef, 0xbb, 0xbf];
|
||||
commands.viewerReadFile.mockResolvedValue({ ...textFile("", H1), contents_base64: bytesB64([...BOM, ...utf8("hi\n")]) });
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("Saved");
|
||||
typeInto(2, 2, "!");
|
||||
await clickSave();
|
||||
expect(commands.viewerWriteFile).toHaveBeenCalledWith(bytesB64([...BOM, ...utf8("hi!\n")]), H1);
|
||||
});
|
||||
|
||||
it("a reload that fails is retried on the next poll", async () => {
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("Saved");
|
||||
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 8 });
|
||||
commands.viewerReadFile.mockRejectedValueOnce("Container is not running.").mockResolvedValue(textFile("changed\n", H2));
|
||||
await poll();
|
||||
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
|
||||
await poll(2000);
|
||||
expect(screen.getByTestId("code-editor")).toHaveTextContent("changed");
|
||||
expect(screen.getByText("Reloaded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores a poll issued before a save completed", async () => {
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("Saved");
|
||||
edit();
|
||||
let answer: (p: { exists: boolean; hash: string; size: number }) => void = () => {};
|
||||
commands.viewerPollFile.mockImplementationOnce(() => new Promise((r) => { answer = r; }));
|
||||
await poll(); // this poll is now in flight, carrying the pre-save hash
|
||||
await clickSave(); // lands as H2
|
||||
edit();
|
||||
await act(async () => { answer({ exists: true, hash: H1, size: 6 }); });
|
||||
expect(screen.queryByText(/while you were editing/)).toBeNull();
|
||||
expect(screen.getByText("Unsaved")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("a conflict whose follow-up poll has no hash shows an error instead of offering an overwrite", async () => {
|
||||
commands.viewerWriteFile.mockRejectedValue("conflict: the file changed on disk since it was loaded.");
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("Saved");
|
||||
edit();
|
||||
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: null, size: 7 });
|
||||
await clickSave();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/could not be checked/);
|
||||
expect(screen.queryByRole("button", { name: /Overwrite on save/ })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: /Reload/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("states why a file is read-only as visible text", async () => {
|
||||
commands.viewerReadFile.mockResolvedValue(textFile("big", H1, { truncated: true }));
|
||||
render(<EditorPane state={state} />);
|
||||
expect(await screen.findByText("Read-only")).toBeInTheDocument();
|
||||
expect(screen.getByText("Files over 1 MiB are read-only.")).toBeVisible();
|
||||
});
|
||||
|
||||
it("a save error is announced as an alert", async () => {
|
||||
commands.viewerWriteFile.mockRejectedValue("Could not save the file: disk full");
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("Saved");
|
||||
edit();
|
||||
await clickSave();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Could not save the file: disk full");
|
||||
});
|
||||
|
||||
it("Save and close saves, then closes the window", async () => {
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("Saved");
|
||||
edit();
|
||||
await act(async () => { await windowApi.closeRequested?.({ preventDefault: () => {} }); });
|
||||
await act(async () => { fireEvent.click(await screen.findByRole("button", { name: "Save and close" })); });
|
||||
expect(commands.viewerWriteFile).toHaveBeenCalledWith(b64("hello\n"), H1);
|
||||
expect(windowApi.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a save that fails while closing keeps the window open and the buffer", async () => {
|
||||
commands.viewerWriteFile.mockRejectedValue(READ_ONLY);
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("Saved");
|
||||
edit();
|
||||
await act(async () => { await windowApi.closeRequested?.({ preventDefault: () => {} }); });
|
||||
await act(async () => { fireEvent.click(await screen.findByRole("button", { name: "Save and close" })); });
|
||||
expect(windowApi.destroy).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/Unsaved changes/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/read-only for the container user/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
|
||||
expect(screen.getByText("Unsaved")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user