Files
margin-docs/tests/external-changes.spec.ts
T
2026-08-28 09:22:58 +05:30

360 lines
16 KiB
TypeScript

// The wire between the watcher and the open document, driven end to end.
//
// The two halves of external change reconciliation were each proved on their own and never
// together: src-tauri/tests/watch.rs drives a real watcher over a real folder, and
// src/store/useDocument.test.ts calls `documentChangedOnDisk` directly. Between them sits
// `startWorkspaceEvents`, which subscribes to the `watch-event` the backend emits and routes it to
// the open document, and which nothing exercised, because it returns a no-op the moment `isTauri`
// is false and the dev mock could not emit an event to it anyway.
//
// So this file gives it a Tauri to be inside. The init script below installs
// `window.__TAURI_INTERNALS__` before any app module is evaluated, which is the exact object
// `src/ipc.ts` looks for and the exact one `@tauri-apps/api` drives, so `isTauri` is true and the
// app takes every branch it takes in the real binary: `call()` goes through `invoke`,
// `startWorkspaceEvents` really registers a listener through the real `listen()`, and a
// `watch-event` really travels the Tauri event bus into `onWatchEvent`.
//
// What is behind the shim is the dev fixture, and what is emitted over it are the payloads
// src/dev/mockIpc.ts's `external` builds. Those payloads are pinned to the ones the real backend
// serialises by src-tauri/tests/watch_payload.rs, and the event name is a bare string here rather
// than an import of `WATCH_EVENT`, so a rename of that constant breaks these tests rather than
// travelling silently through both sides.
//
// What this does not prove: that macOS delivers the FSEvents these payloads describe (watch.rs
// does that), and that a packaged binary wires the same two modules together (nothing does yet,
// and that is worth knowing).
//
// Every change to a watched folder is made behind the app's back, never through `file_write`, so
// nothing has registered a self-write and the app can only find out the way it finds out in
// production: from the event.
import { expect, test, type Page } from "@playwright/test";
import { caretIsIn, putCaret, settle } from "./caret";
import { ask, change, installTauriShim } from "./disk";
import { dirtyWasShown, watchDirty } from "./saving";
const HANDBOOK = "/Users/you/Documents/Handbook";
const README = `${HANDBOOK}/README.md`;
const NOTES = `${HANDBOOK}/notes.txt`;
const row = (path: string) => `.tree-row[data-path="${path}"]`;
/** Where the caret is, described by the block it is sitting in rather than by an offset. */
function caret(page: Page) {
return page.evaluate(() => {
const selection = window.getSelection();
const node = selection?.anchorNode ?? null;
const element = node instanceof Element ? node : (node?.parentElement ?? null);
const block = element?.closest(".prose p, .prose h1, .prose h2, .prose li") ?? null;
return {
inEditor: document.activeElement?.classList.contains("prose") ?? false,
tag: block?.tagName ?? null,
text: block?.textContent ?? null,
};
});
}
/**
* A first launch with the Handbook opened out of recents, and a list of any uncaught exception the
* page throws along the way. "Handled without crashing" is a claim about that list.
*/
async function launch(page: Page): Promise<string[]> {
const crashes: string[] = [];
page.on("pageerror", (error) => crashes.push(String(error)));
await page.addInitScript(installTauriShim);
await page.addInitScript(() => {
localStorage.clear();
localStorage.setItem("margindocs-dev-empty", "1");
localStorage.setItem("margindocs-recents", JSON.stringify(["/Users/you/Documents/Handbook"]));
});
await page.goto("/");
await page.locator(".start-row").first().click();
await expect(page.locator(row(HANDBOOK))).toBeVisible();
return crashes;
}
async function openReadme(page: Page): Promise<void> {
await page.locator(row(README)).click();
await expect(page.locator(".prose h1")).toHaveText("Handbook");
await expect(page.locator(".prose")).toContainText("Everything the team needs");
}
test("an external edit to a clean document reloads it silently and shows the new text", async ({
page,
}) => {
const crashes = await launch(page);
await openReadme(page);
const rewritten = [
"---",
"title: Handbook",
"---",
"",
"# Handbook",
"",
"Another program rewrote this file while the app had it open.",
"",
].join("\n");
const delivered = await change(page, "write", README, rewritten);
// The wire is connected. A no-op `startWorkspaceEvents` gives [0], and the assertions below
// would then be failing for a reason nobody could see from them.
expect(delivered).toEqual([1]);
await expect(page.locator(".prose")).toContainText(
"Another program rewrote this file while the app had it open.",
);
await expect(page.locator(".prose")).not.toContainText("Everything the team needs");
// Silently: nothing was asked, nothing is flagged, nothing became unsaved.
await expect(page.locator(".panel-conflict")).toHaveCount(0);
await expect(page.locator(".dirty-dot")).toHaveCount(0);
await expect(page.locator(".toolbar-status")).toHaveAttribute("data-phase", "idle");
expect(crashes).toEqual([]);
});
test("an external edit does not throw the cursor to the top of the document", async ({ page }) => {
const crashes = await launch(page);
await openReadme(page);
const paragraph = page
.locator(".prose p")
.filter({ hasText: "Guides are the things you read once" });
await paragraph.click();
// Placed and read back rather than moved with End, which is a key nobody presses to move a caret
// on this platform and which answers by line: see tests/caret.ts.
await putCaret(paragraph, "end");
const before = await caret(page);
expect(before.tag).toBe("P");
expect(before.text).toContain("Guides are the things you read once");
// Appended at the end of the file, so every byte before the caret is exactly where it was and a
// preserved caret is a caret in the same words rather than at a coincidentally similar offset.
const original = await ask<string>(page, "read", README);
const delivered = await change(
page,
"write",
README,
`${original}\nA closing line another program appended.\n`,
);
expect(delivered).toEqual([1]);
await expect(page.locator(".prose")).toContainText("A closing line another program appended.");
const after = await caret(page);
expect(after.inEditor).toBe(true);
expect(after.tag).toBe("P");
expect(after.text).toContain("Guides are the things you read once");
// What the caret is for. Typing lands where it was left, not in the heading at the top. The two
// frames are the view's: the reload put a selection back, and a key sent before it has read that
// selection is answered about the position the caret used to be in.
await settle(page);
await page.keyboard.type("!");
await expect(paragraph).toContainText("!");
await expect(page.locator(".prose h1")).toHaveText("Handbook");
expect(crashes).toEqual([]);
});
test("an external edit while the buffer is dirty leaves the buffer alone and surfaces the clash", async ({
page,
}) => {
const crashes = await launch(page);
await openReadme(page);
// Held, so the buffer is reliably still dirty when the event lands rather than in a race with
// the 500ms autosave.
await ask(page, "pauseWrites");
const paragraph = page.locator(".prose p").first();
await paragraph.click();
await putCaret(paragraph, "start");
await page.keyboard.type("MY UNSAVED EDIT. ");
await expect(page.locator(".dirty-dot")).toBeVisible();
const theirs = "---\ntitle: Handbook\n---\n\n# Handbook\n\nTHEIR EDIT, made on disk.\n";
const delivered = await change(page, "write", README, theirs);
expect(delivered).toEqual([1]);
await expect(page.locator(".panel-conflict")).toBeVisible();
await expect(page.locator(".prose")).toContainText("MY UNSAVED EDIT.");
await expect(page.locator(".prose")).not.toContainText("THEIR EDIT, made on disk.");
await expect(page.locator(".dirty-dot")).toBeVisible();
// Dismissing picks neither copy and leaves the warning where it can be reopened.
await page.getByRole("button", { name: "Decide later" }).click();
await expect(page.locator(".panel-conflict")).toHaveCount(0);
await expect(page.locator(".toolbar-status")).toHaveAttribute("data-phase", "conflict");
// The held save is let go. It is refused, because the file moved on from the timestamp the
// buffer was read at, so their copy is still the one on disk and the edit is still unsaved.
await ask(page, "resumeWrites");
await page.waitForTimeout(1200);
expect(await ask<string>(page, "read", README)).toBe(theirs);
await expect(page.locator(".dirty-dot")).toBeVisible();
expect(crashes).toEqual([]);
});
test("the app's own save does not come back as an external change", async ({ page }) => {
const crashes = await launch(page);
await openReadme(page);
const paragraph = page.locator(".prose p").first();
await paragraph.click();
await putCaret(paragraph, "end");
// Watched from before the typing rather than polled for after it, because the dot is on screen
// only until the debounce runs out: see tests/saving.ts.
await watchDirty(page);
await page.keyboard.type(" MARKER.");
await dirtyWasShown(page);
await expect(page.locator(".dirty-dot")).toHaveCount(0, { timeout: 5000 });
expect(await ask<string>(page, "read", README)).toContain("MARKER.");
// The backend suppresses the echo of the app's own write, so in the real app nothing arrives at
// all: src-tauri/tests/watch.rs, `a_real_save_is_reported_as_nothing`. Send one anyway. The
// frontend is the second line and has to hold on its own, which is precisely what nobody
// noticed had stopped being true the last time this went dead.
expect(await change(page, "signal", README, "modified")).toEqual([1]);
// And the harder one: the same bytes with a newer timestamp, which is what a touch, a backup
// tool or a git checkout that restores what was already there looks like from the watcher.
expect(await change(page, "touch", README)).toEqual([1]);
await expect(page.locator(".panel-conflict")).toHaveCount(0);
await expect(page.locator(".toolbar-status")).toHaveAttribute("data-phase", "idle");
await expect(page.locator(".dirty-dot")).toHaveCount(0);
await expect(page.locator(".prose")).toContainText("MARKER.");
// A reload rebuilds the ProseMirror state and takes the undo history with it, and matching text
// would hide that. The history is still there, so nothing was reloaded.
await paragraph.click();
// Undo is answered by the editor's own keymap, so it has to be the editor holding the keyboard
// when the key arrives rather than a moment afterwards.
await caretIsIn(paragraph);
await page.keyboard.press("Meta+z");
await expect(page.locator(".prose")).not.toContainText("MARKER.");
expect(crashes).toEqual([]);
});
test("a file deleted from outside is handled without a crash and is not written back", async ({
page,
}) => {
const crashes = await launch(page);
await openReadme(page);
const delivered = await change(page, "remove", README);
expect(delivered).toEqual([1]);
// The buffer is the only copy left, so it stays on screen whole and the user is told.
await expect(page.locator(".panel-conflict")).toBeVisible();
await expect(page.locator(".prose h1")).toHaveText("Handbook");
await expect(page.locator(".prose")).toContainText("Everything the team needs");
// The tree caught up off the same event, and only the row that went is gone.
await expect(page.locator(row(README))).toHaveCount(0);
await expect(page.locator(row(NOTES))).toBeVisible();
await page.getByRole("button", { name: "Decide later" }).click();
await expect(page.locator(".toolbar-status")).toHaveAttribute("data-phase", "conflict");
// Well past the 500ms autosave: a deleted file is not quietly recreated behind the user.
await page.waitForTimeout(1200);
expect(await ask<boolean>(page, "exists", README)).toBe(false);
expect(crashes).toEqual([]);
});
test("an external rename of the open file is handled", async ({ page }) => {
const crashes = await launch(page);
await openReadme(page);
const original = await ask<string>(page, "read", README);
const renamed = `${HANDBOOK}/Handbook.md`;
// Two events and not one. macOS reports the two ends of a rename as unrelated changes and
// src-tauri/src/watch.rs passes on what it is told: see `a_rename_is_reported_at_both_ends`.
const delivered = await change(page, "rename", README, renamed);
expect(delivered).toEqual([1, 1]);
await expect(page.locator(row(renamed))).toBeVisible();
await expect(page.locator(row(README))).toHaveCount(0);
// The open document's file went, which reaches it as the same "changed on disk" as a delete.
await expect(page.locator(".panel-conflict")).toBeVisible();
await expect(page.locator(".prose h1")).toHaveText("Handbook");
await page.getByRole("button", { name: "Decide later" }).click();
await page.waitForTimeout(1200);
expect(await ask<boolean>(page, "exists", README)).toBe(false);
expect(await ask<string>(page, "read", renamed)).toBe(original);
// The document does not follow the rename: the title bar still names the file that went, and
// the buffer is still pointed at a path that is no longer there. Nothing is lost and nothing is
// written, which is the claim here, but see the notes: `file_rename` follows and this does not.
await expect(page.locator(".titlebar .doc-title")).toHaveText("README");
expect(crashes).toEqual([]);
});
test("an external edit to an open .txt reloads it too", async ({ page }) => {
const crashes = await launch(page);
await page.locator(row(NOTES)).click();
const field = page.locator("textarea.plain-text");
await expect(field).toHaveValue(/Scratch notes, not markdown, still editable\./);
const original = await ask<string>(page, "read", NOTES);
const delivered = await change(
page,
"write",
NOTES,
`${original}A line another program appended.\n`,
);
expect(delivered).toEqual([1]);
await expect(field).toHaveValue(/A line another program appended\./);
await expect(page.locator(".panel-conflict")).toHaveCount(0);
await expect(page.locator(".dirty-dot")).toHaveCount(0);
expect(crashes).toEqual([]);
});
/**
* This was a known defect, kept as `test.fail` so it would turn red the day it was fixed rather
* than sit in a paragraph nobody reads. It has been fixed, so it is an ordinary test now.
*
* The caret already survived an external reload on the markdown surface, where Editor.tsx stashes
* the selection before installing the new document and restores it after. A textarea has no
* selection that survives its value being replaced, so PlainTextEditor.tsx now carries it by hand
* across a reload of the same path, clamped in case the file shrank. It used to be measured at
* offset 46 of 216 coming back as 249 of 249.
*/
test(
"an external edit to a .txt does not throw the caret to the end of the document",
async ({ page }) => {
await launch(page);
await page.locator(row(NOTES)).click();
const field = page.locator("textarea.plain-text");
await expect(field).toBeVisible();
await field.click();
// A textarea takes its own keys, so the arrows below go nowhere until it has the focus. That
// would leave the caret at the top of the file, which is a place this test cannot tell a
// preserved caret from a reset one at.
await expect(field).toBeFocused();
await page.keyboard.press("Meta+ArrowUp");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
const before = await field.evaluate((el: HTMLTextAreaElement) => el.selectionStart);
// Somewhere in the middle, or the assertion at the bottom is one a caret thrown to the top
// would pass as happily as a caret that was kept.
expect(before).toBeGreaterThan(0);
const original = await ask<string>(page, "read", NOTES);
expect(
await change(page, "write", NOTES, `${original}A line another program appended.\n`),
).toEqual([1]);
await expect(field).toHaveValue(/A line another program appended\./);
expect(await field.evaluate((el: HTMLTextAreaElement) => el.selectionStart)).toBe(before);
},
);