Stop transient file conflict callouts (#2694)
* fix(app): prevent transient file conflict callouts Watcher versions are candidates until a matching disk read settles them. The editor now classifies the settled bytes once, preserving dirty buffers and keeping reload content paired with its revision. * fix(app): preserve external BOM changes
This commit is contained in:
@@ -4,6 +4,11 @@ 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";
|
||||
import {
|
||||
expectFileCalloutWasRendered,
|
||||
expectNoFileCalloutWasRendered,
|
||||
recordFileCallouts,
|
||||
} from "./helpers/file-callouts";
|
||||
|
||||
function visibleEditor(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
@@ -61,10 +66,11 @@ async function replaceFileAfterDeletionWasObserved(input: {
|
||||
gate.holdFileReads(relativePath);
|
||||
await unlink(filePath);
|
||||
await gate.waitForHeldFileRead();
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
gate.releaseHeldFileRead();
|
||||
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);
|
||||
}
|
||||
@@ -103,7 +109,96 @@ async function expectDirtyConflictCanReload(page: Page): Promise<void> {
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function restoreFileAfterWatcherObservedTemporaryAbsence(input: {
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
}): Promise<void> {
|
||||
const parkedPath = `${input.filePath}.saving`;
|
||||
input.gate.holdFileReads(input.relativePath);
|
||||
await rename(input.filePath, parkedPath);
|
||||
await input.gate.waitForFileUpdate(input.relativePath, "missing");
|
||||
await input.gate.waitForHeldFileRead();
|
||||
await writeFile(input.filePath, await readFile(parkedPath));
|
||||
await unlink(parkedPath);
|
||||
input.gate.releaseHeldFileRead();
|
||||
}
|
||||
|
||||
test.describe("Workspace file change conflicts", () => {
|
||||
test("a temporary write gap never renders a disk-change callout", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const relativePath = "temporary-gap.ts";
|
||||
const filePath = await openTrackedFile(page, withWorkspace, {
|
||||
prefix: "file-temporary-gap-",
|
||||
relativePath,
|
||||
content: "const preserved = true;\n",
|
||||
});
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
await recordFileCallouts(page);
|
||||
|
||||
await restoreFileAfterWatcherObservedTemporaryAbsence({ gate, filePath, relativePath });
|
||||
await replaceEditorText(page, "const local = true;\n");
|
||||
|
||||
await expect.poll(() => readFile(filePath, "utf8")).toContain("const local = true;");
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
await expectNoFileCalloutWasRendered(page);
|
||||
});
|
||||
|
||||
test("an identical atomic rewrite never renders a disk-change callout", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const relativePath = "identical-rewrite.ts";
|
||||
const content = "const preserved = true;\n";
|
||||
const filePath = await openTrackedFile(page, withWorkspace, {
|
||||
prefix: "file-identical-rewrite-",
|
||||
relativePath,
|
||||
content,
|
||||
});
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
await recordFileCallouts(page);
|
||||
|
||||
gate.holdNextReadyFileUpdate(relativePath);
|
||||
await writeFile(`${filePath}.tmp`, content, "utf8");
|
||||
await rename(`${filePath}.tmp`, filePath);
|
||||
await gate.waitForHeldReadyFileUpdate();
|
||||
await replaceEditorText(page, "const local = true;\n");
|
||||
gate.releaseHeldReadyFileUpdate();
|
||||
|
||||
await expect.poll(() => readFile(filePath, "utf8")).toContain("const local = true;");
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
await expectNoFileCalloutWasRendered(page);
|
||||
});
|
||||
|
||||
test("a save watcher refresh never replays the pre-save buffer", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const relativePath = "save-refresh.ts";
|
||||
const filePath = await openTrackedFile(page, withWorkspace, {
|
||||
prefix: "file-save-refresh-",
|
||||
relativePath,
|
||||
content: "const before = true;\n",
|
||||
});
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
await recordFileCallouts(page);
|
||||
gate.holdFileReads(relativePath);
|
||||
|
||||
await replaceEditorText(page, "const saved = true;\n");
|
||||
await expect.poll(() => readFile(filePath, "utf8")).toContain("const saved = true;");
|
||||
await gate.waitForHeldFileRead();
|
||||
|
||||
await expect(visibleEditor(page)).toContainText("const saved = true;");
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
await expectNoFileCalloutWasRendered(page);
|
||||
gate.releaseHeldFileRead();
|
||||
});
|
||||
|
||||
test("a clean file replaced on disk offers one working reload action", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
@@ -122,8 +217,8 @@ test.describe("Workspace file change conflicts", () => {
|
||||
relativePath: "inventory.md",
|
||||
content: "# After\n",
|
||||
});
|
||||
await expectCleanReplacementCanReload(page);
|
||||
gate.releaseHeldReadyFileUpdate();
|
||||
await expectCleanReplacementCanReload(page);
|
||||
});
|
||||
|
||||
test("a deleted file shows one explanatory notice and no resolution actions", async ({
|
||||
@@ -136,8 +231,10 @@ test.describe("Workspace file change conflicts", () => {
|
||||
content: "# Present\n",
|
||||
});
|
||||
await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible();
|
||||
await recordFileCallouts(page);
|
||||
await unlink(filePath);
|
||||
await expectDeletedFileNotice(page);
|
||||
await expectFileCalloutWasRendered(page, "File deleted on disk");
|
||||
});
|
||||
|
||||
test("a file check error offers a working retry", async ({ page, withWorkspace }) => {
|
||||
|
||||
@@ -76,6 +76,8 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
const clientRequestCounts = new Map<string, number>();
|
||||
const subscribedFilePaths = new Set<string>();
|
||||
const fileSubscriptionWaiters = new Map<string, () => void>();
|
||||
const observedFileUpdates = new Set<string>();
|
||||
const fileUpdateWaiters = new Map<string, () => void>();
|
||||
let fileReadPathToHold: string | null = null;
|
||||
let heldFileReads: Array<() => void> = [];
|
||||
let resolveHeldFileRead: (() => void) | null = null;
|
||||
@@ -84,6 +86,19 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
let heldReadyFileUpdate: (() => void) | null = null;
|
||||
let resolveHeldReadyFileUpdate: (() => void) | null = null;
|
||||
let heldReadyFileUpdatePromise = Promise.resolve();
|
||||
const recordFileUpdate = (message: ServerMessage): void => {
|
||||
if (
|
||||
message.type !== "fs.file.update" ||
|
||||
typeof message.payload?.version?.status !== "string" ||
|
||||
typeof message.payload.version.path !== "string"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const key = `${message.payload.version.path}:${message.payload.version.status}`;
|
||||
observedFileUpdates.add(key);
|
||||
fileUpdateWaiters.get(key)?.();
|
||||
fileUpdateWaiters.delete(key);
|
||||
};
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
@@ -134,6 +149,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
fileSubscriptionWaiters.get(path)?.();
|
||||
fileSubscriptionWaiters.delete(path);
|
||||
}
|
||||
if (serverMessage) recordFileUpdate(serverMessage);
|
||||
if (
|
||||
serverMessage?.type === "fs.file.update" &&
|
||||
serverMessage.payload?.version?.status === "ready" &&
|
||||
@@ -157,6 +173,11 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
if (subscribedFilePaths.has(path)) return Promise.resolve();
|
||||
return new Promise((resolve) => fileSubscriptionWaiters.set(path, resolve));
|
||||
},
|
||||
waitForFileUpdate(path: string, status: "ready" | "missing" | "error"): Promise<void> {
|
||||
const key = `${path}:${status}`;
|
||||
if (observedFileUpdates.has(key)) return Promise.resolve();
|
||||
return new Promise((resolve) => fileUpdateWaiters.set(key, resolve));
|
||||
},
|
||||
holdFileReads(path: string): void {
|
||||
fileReadPathToHold = path;
|
||||
heldFileReads = [];
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "../fixtures";
|
||||
|
||||
export async function recordFileCallouts(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const callouts: string[] = [];
|
||||
const recordAlert = (alert: Element) => {
|
||||
const text = alert.textContent?.trim();
|
||||
if (text && !callouts.includes(text)) callouts.push(text);
|
||||
};
|
||||
const record = (records: MutationRecord[]) => {
|
||||
for (const mutation of records) {
|
||||
if (
|
||||
!(mutation.target instanceof Element) ||
|
||||
!mutation.target.closest('[data-testid="workspace-file-pane"]')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
if (node.matches('[role="alert"]')) recordAlert(node);
|
||||
for (const alert of node.querySelectorAll('[role="alert"]')) recordAlert(alert);
|
||||
}
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(record);
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||
Object.assign(globalThis, { __PASEO_RECORDED_FILE_CALLOUTS__: callouts });
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectFileCalloutWasRendered(page: Page, title: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() => recordedFileCallouts(page))
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining(title)]));
|
||||
}
|
||||
|
||||
export async function expectNoFileCalloutWasRendered(page: Page): Promise<void> {
|
||||
await expect.poll(() => recordedFileCallouts(page)).toEqual([]);
|
||||
}
|
||||
|
||||
function recordedFileCallouts(page: Page): Promise<string[]> {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(globalThis as typeof globalThis & { __PASEO_RECORDED_FILE_CALLOUTS__?: string[] })
|
||||
.__PASEO_RECORDED_FILE_CALLOUTS__ ?? [],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user