* fix(app): distinguish changed and deleted files Successful reads now reconcile transient missing events without letting stale cached data mask a deletion. File notices expose only actions valid for the current buffer state. * fix(app): preserve file state ordering * fix(app): allow retrying file checks * fix(app): clear unchanged file conflicts * fix(app): keep file conflict recovery consistent Own disk observation and read ordering in one model so stale reads and failed subscriptions cannot produce contradictory actions. * fix(app): allow retrying file preview errors Preserve daemon read errors and expose retry for initial and non-editable file loads.
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { expect, test, type Page } from "./fixtures";
|
|
import { expectFileTabOpen, openFileExplorer, openFileFromExplorer } from "./helpers/file-explorer";
|
|
import type { WithWorkspace } from "./helpers/with-workspace";
|
|
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
|
|
|
function visibleEditor(page: Page) {
|
|
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
|
}
|
|
|
|
function filePane(page: Page) {
|
|
return page.getByTestId("workspace-file-pane").filter({ visible: true });
|
|
}
|
|
|
|
function fileCallout(page: Page) {
|
|
return filePane(page).getByRole("alert");
|
|
}
|
|
|
|
async function openFile(page: Page, filename: string): Promise<void> {
|
|
await openFileExplorer(page);
|
|
await openFileFromExplorer(page, filename);
|
|
await expectFileTabOpen(page, filename);
|
|
}
|
|
|
|
async function openTrackedFile(
|
|
page: Page,
|
|
withWorkspace: WithWorkspace,
|
|
input: { prefix: string; relativePath: string; content: string | Uint8Array },
|
|
): Promise<string> {
|
|
const workspace = await withWorkspace({ prefix: input.prefix });
|
|
const filePath = path.join(workspace.repoPath, input.relativePath);
|
|
if (typeof input.content === "string") {
|
|
await writeFile(filePath, input.content, "utf8");
|
|
} else {
|
|
await writeFile(filePath, input.content);
|
|
}
|
|
await workspace.navigateTo();
|
|
await openFile(page, input.relativePath);
|
|
return filePath;
|
|
}
|
|
|
|
async function replaceEditorText(page: Page, content: string): Promise<void> {
|
|
const editor = visibleEditor(page);
|
|
await editor.click();
|
|
await editor.press("Control+A");
|
|
await editor.type(content);
|
|
}
|
|
|
|
async function replaceFileAfterDeletionWasObserved(input: {
|
|
page: Page;
|
|
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
|
|
filePath: string;
|
|
relativePath: string;
|
|
content: string;
|
|
}): Promise<void> {
|
|
const { page, gate, filePath, relativePath, content } = input;
|
|
// Recreate the observed race without faking file state: the daemon publishes the
|
|
// deletion, then a real read succeeds after the replacement while its ready event waits.
|
|
await gate.waitForFileSubscription(relativePath);
|
|
gate.holdFileReads(relativePath);
|
|
await unlink(filePath);
|
|
await gate.waitForHeldFileRead();
|
|
await expectOnlyFileCallout(page, "File deleted on disk");
|
|
gate.holdNextReadyFileUpdate(relativePath);
|
|
await writeFile(filePath, content, "utf8");
|
|
gate.releaseHeldFileRead();
|
|
await gate.waitForHeldReadyFileUpdate();
|
|
await expect.poll(() => readFile(filePath, "utf8")).toBe(content);
|
|
}
|
|
|
|
async function expectOnlyFileCallout(page: Page, title: string): Promise<void> {
|
|
await expect(fileCallout(page)).toHaveCount(1);
|
|
await expect(fileCallout(page)).toContainText(title);
|
|
await expect(filePane(page).getByText(title, { exact: true })).toHaveCount(1);
|
|
}
|
|
|
|
async function expectCleanReplacementCanReload(page: Page): Promise<void> {
|
|
await expectOnlyFileCallout(page, "Changed on disk");
|
|
await expect(filePane(page).getByText("File deleted on disk", { exact: true })).toHaveCount(0);
|
|
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
|
|
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled();
|
|
await fileCallout(page).getByRole("button", { name: "Reload" }).click();
|
|
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
|
|
await expect(fileCallout(page)).toHaveCount(0);
|
|
}
|
|
|
|
async function expectDeletedFileNotice(page: Page): Promise<void> {
|
|
await expectOnlyFileCallout(page, "File deleted on disk");
|
|
await expect(fileCallout(page)).toContainText("The open copy is preserved.");
|
|
await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0);
|
|
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
|
|
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0);
|
|
}
|
|
|
|
async function expectDirtyConflictCanReload(page: Page): Promise<void> {
|
|
await expectOnlyFileCallout(page, "Changed on disk");
|
|
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toBeEnabled();
|
|
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled();
|
|
page.once("dialog", (dialog) => dialog.accept());
|
|
await fileCallout(page).getByRole("button", { name: "Reload" }).click();
|
|
await expect(visibleEditor(page)).toContainText("const external = true;");
|
|
await expect(fileCallout(page)).toHaveCount(0);
|
|
}
|
|
|
|
test.describe("Workspace file change conflicts", () => {
|
|
test("a clean file replaced on disk offers one working reload action", async ({
|
|
page,
|
|
withWorkspace,
|
|
}) => {
|
|
const gate = await installDaemonWebSocketGate(page);
|
|
const filePath = await openTrackedFile(page, withWorkspace, {
|
|
prefix: "file-clean-replacement-",
|
|
relativePath: "inventory.md",
|
|
content: "# Before\n",
|
|
});
|
|
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
|
|
await replaceFileAfterDeletionWasObserved({
|
|
page,
|
|
gate,
|
|
filePath,
|
|
relativePath: "inventory.md",
|
|
content: "# After\n",
|
|
});
|
|
await expectCleanReplacementCanReload(page);
|
|
gate.releaseHeldReadyFileUpdate();
|
|
});
|
|
|
|
test("a deleted file shows one explanatory notice and no resolution actions", async ({
|
|
page,
|
|
withWorkspace,
|
|
}) => {
|
|
const filePath = await openTrackedFile(page, withWorkspace, {
|
|
prefix: "file-deleted-",
|
|
relativePath: "deleted.md",
|
|
content: "# Present\n",
|
|
});
|
|
await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible();
|
|
await unlink(filePath);
|
|
await expectDeletedFileNotice(page);
|
|
});
|
|
|
|
test("a file check error offers a working retry", async ({ page, withWorkspace }) => {
|
|
const gate = await installDaemonWebSocketGate(page);
|
|
const relativePath = "retry.md";
|
|
const filePath = await openTrackedFile(page, withWorkspace, {
|
|
prefix: "file-check-retry-",
|
|
relativePath,
|
|
content: "# Before\n",
|
|
});
|
|
await gate.waitForFileSubscription(relativePath);
|
|
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
|
|
|
|
await unlink(filePath);
|
|
await mkdir(filePath);
|
|
await expectOnlyFileCallout(page, "Couldn't check file on disk");
|
|
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeEnabled();
|
|
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0);
|
|
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
|
|
|
|
gate.holdNextReadyFileUpdate(relativePath);
|
|
await rm(filePath, { recursive: true });
|
|
await writeFile(filePath, "# After\n", "utf8");
|
|
await gate.waitForHeldReadyFileUpdate();
|
|
gate.holdFileReads(relativePath);
|
|
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
|
|
await gate.waitForHeldFileRead();
|
|
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeDisabled();
|
|
gate.releaseHeldFileRead();
|
|
await expectCleanReplacementCanReload(page);
|
|
gate.releaseHeldReadyFileUpdate();
|
|
});
|
|
|
|
test("retry clears a transient error when the file is unchanged", async ({
|
|
page,
|
|
withWorkspace,
|
|
}) => {
|
|
const gate = await installDaemonWebSocketGate(page);
|
|
const relativePath = "unchanged.md";
|
|
const filePath = await openTrackedFile(page, withWorkspace, {
|
|
prefix: "file-check-unchanged-",
|
|
relativePath,
|
|
content: "# Unchanged\n",
|
|
});
|
|
const parkedPath = `${filePath}.parked`;
|
|
await gate.waitForFileSubscription(relativePath);
|
|
await expect(filePane(page).getByText("Unchanged", { exact: true })).toBeVisible();
|
|
|
|
await rename(filePath, parkedPath);
|
|
await mkdir(filePath);
|
|
await expectOnlyFileCallout(page, "Couldn't check file on disk");
|
|
|
|
gate.holdNextReadyFileUpdate(relativePath);
|
|
await rm(filePath, { recursive: true });
|
|
await rename(parkedPath, filePath);
|
|
await gate.waitForHeldReadyFileUpdate();
|
|
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
|
|
|
|
await expect(fileCallout(page)).toHaveCount(0);
|
|
await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0);
|
|
gate.releaseHeldReadyFileUpdate();
|
|
});
|
|
|
|
test("a read-only file error preserves its message and offers retry", async ({
|
|
page,
|
|
withWorkspace,
|
|
}) => {
|
|
const gate = await installDaemonWebSocketGate(page);
|
|
const relativePath = "artifact.bin";
|
|
const binaryContent = new Uint8Array([0, 1, 2, 3, 255]);
|
|
const filePath = await openTrackedFile(page, withWorkspace, {
|
|
prefix: "file-read-only-retry-",
|
|
relativePath,
|
|
content: binaryContent,
|
|
});
|
|
await gate.waitForFileSubscription(relativePath);
|
|
await expect(
|
|
filePane(page).getByText("Binary preview unavailable", { exact: true }),
|
|
).toBeVisible();
|
|
|
|
await unlink(filePath);
|
|
await mkdir(filePath);
|
|
await expect(
|
|
filePane(page).getByText("Requested path is not a file", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(filePane(page).getByRole("button", { name: "Retry" })).toBeEnabled();
|
|
|
|
gate.holdNextReadyFileUpdate(relativePath);
|
|
await rm(filePath, { recursive: true });
|
|
await writeFile(filePath, binaryContent);
|
|
await gate.waitForHeldReadyFileUpdate();
|
|
gate.holdFileReads(relativePath);
|
|
await filePane(page).getByRole("button", { name: "Retry" }).click();
|
|
await gate.waitForHeldFileRead();
|
|
await expect(
|
|
filePane(page).getByText("Requested path is not a file", { exact: true }),
|
|
).toHaveCount(0);
|
|
gate.releaseHeldFileRead();
|
|
|
|
await expect(
|
|
filePane(page).getByText("Binary preview unavailable", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
filePane(page).getByText("Requested path is not a file", { exact: true }),
|
|
).toHaveCount(0);
|
|
gate.releaseHeldReadyFileUpdate();
|
|
});
|
|
|
|
test("a changed file with local edits offers overwrite and a working reload", async ({
|
|
page,
|
|
withWorkspace,
|
|
}) => {
|
|
const filePath = await openTrackedFile(page, withWorkspace, {
|
|
prefix: "file-dirty-conflict-",
|
|
relativePath: "source.ts",
|
|
content: "const before = true;\n",
|
|
});
|
|
await replaceEditorText(page, "const local = true;\n");
|
|
await writeFile(filePath, "const external = true;\n", "utf8");
|
|
await expectDirtyConflictCanReload(page);
|
|
});
|
|
});
|