mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-02 11:07:05 +00:00
features
This commit is contained in:
commit
852cba1840
252 files changed
+55050
No files matched your search
@@ -0,0 +1,359 @@
|
||||
// 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);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user