This commit is contained in:
pj committed 2026-08-28 09:22:58 +05:30
commit 852cba1840
252 files changed
+55050

No files matched your search

+255
View File
@@ -0,0 +1,255 @@
// The four blocks M2 added, driven from the pill the way a person drives them.
//
// These exist because of the failure they are shaped to catch. Every one of these blocks is a
// toolbar button, a method on the editor handle, and a command in a file of its own, written by
// somebody who could see only their own end of it. Every piece can be present, typechecked and
// unit tested while the wire between two of them is missing, and nothing but a click in a real
// browser notices. So each test here starts at a button and asserts something only the far end
// could have produced: a real table in the document, KaTeX's markup on the page, highlight.js's
// class names on a span.
//
// The mermaid diagram is deliberately not here. It loads a chunk and renders asynchronously, which
// is a timeout in a test suite rather than an assertion; src/editor/blocks/mermaid.test.ts covers
// the node view and the insert command instead.
import { expect, test, type Page } from "@playwright/test";
import { caretIsIn, putCaret } from "./caret";
const HANDBOOK = "/Users/you/Documents/Handbook";
const README = `${HANDBOOK}/README.md`;
async function openReadme(page: Page): Promise<void> {
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 page.locator(`.tree-row[data-path="${README}"]`).click();
await expect(page.locator(".prose h1")).toHaveText("Handbook");
// Every test below starts by pressing a tool, and a tool runs its command against the selection
// the view is holding rather than against the one the browser has. Waiting for the click to
// arrive there is the difference between inserting into this paragraph and inserting wherever
// the caret was before the file was opened.
const paragraph = page.locator(".prose p").first();
await paragraph.click();
await caretIsIn(paragraph);
}
test("the table tool inserts a table, puts the caret in it and keeps the header first", async ({
page,
}) => {
await openReadme(page);
await page.locator('.editor-toolbar .tool[title="Insert table"]').click();
await page.locator('.table-pop .size-cell[title="3 by 3 table"]').click();
const table = page.locator(".prose table").first();
await expect(table.locator("tr")).toHaveCount(3);
await expect(table.locator("tr").first().locator("th")).toHaveCount(3);
// The caret went into the new table, which is the only reason the tool can read "Table" rather
// than still offering to insert one.
const tool = page.locator('.editor-toolbar .tool[title="Table"]');
await expect(tool).toBeVisible();
// A row added while the caret is in the header row: GFM has no row above a header, so the new
// row is the header and the old one becomes the first body row.
await tool.click();
await page.locator(".table-pop button", { hasText: "Above" }).first().click();
await expect(table.locator("tr")).toHaveCount(4);
await expect(table.locator("tr").first().locator("th")).toHaveCount(3);
await expect(table.locator("tr").nth(1).locator("td")).toHaveCount(3);
// The op that cannot round trip is not offered at all.
await expect(page.locator(".table-pop button", { hasText: "Header row" })).toHaveCount(0);
});
test("the code block tool sets a language and the fence is coloured by it", async ({ page }) => {
await openReadme(page);
// The paragraph's own words are what says this fence is the one the tool just made out of it,
// rather than any block the document grows later on.
const paragraph = page.locator(".prose p").first();
const words = (await paragraph.innerText()).trim();
await paragraph.click();
await caretIsIn(paragraph);
await page.locator('.editor-toolbar .tool[title="Code block"]').click();
await expect(page.locator(".prose pre").filter({ hasText: words })).toHaveCount(1);
// Pinned by position from here on. A locator that filters on the text is the right way to
// identify a block and the wrong way to hold one while it is being typed into: the moment a
// keystroke lands somewhere unexpected it matches nothing, and a wrong caret is then reported as
// a thirty second timeout waiting for a fence that has gone rather than as a diff.
const fence = page.locator(".prose pre").first();
const code = fence.locator("code");
await page.locator('.editor-toolbar .tool[title="Code block: no language"]').click();
await page.locator(".lang-pop button", { hasText: "typescript" }).click();
await expect(page.locator('.editor-toolbar .tool[title="Code block: typescript"]')).toBeVisible();
// The caret is put at the end of the fence and read back before a key is pressed, because the
// two gestures this used to be made of both answer differently from run to run. A click is a hit
// test, and this fence is a horizontal scroller two lines tall whose middle is a hair from the
// boundary between them; End then moves the caret by line, so the pair of them landed the
// keystroke below in the middle of the block on some runs and at the end of it on others, and
// the last assertion here is about the text of the whole block. tests/caret.ts has the numbers.
await code.click();
await putCaret(code, "end");
await page.keyboard.type(" const x = 1;");
// Decorations, from a grammar, over text the highlighter never rewrote. Asked for the one word
// rather than the first span, because the paragraph this fence was made out of has words in it
// that TypeScript reserves too.
await expect(code.locator(".hljs-keyword").filter({ hasText: /^const$/ })).toHaveCount(1);
await expect(code).toHaveText(`${words} const x = 1;`);
});
// The same block, with the caret on the first of its two lines, which is the state the test above
// was a coin flip in.
//
// A fence does not wrap. Its `code` is a horizontal scroller of whole lines, so this one is two
// lines tall, and a click aimed at the middle of the element is aimed within a pixel or two of the
// boundary between them. Which line it lands on decides what End does next, because what answers
// End moves the caret to the end of a line and not to the end of a block: from the first line it
// stops at "and nothing", eleven words short of where the next keystroke was supposed to go. That
// was measured landing both ways on the same machine on the same afternoon.
//
// The click below is therefore aimed at a point read off the first line itself rather than at the
// middle of the element, so the caret starts somewhere known, and the gesture under test is the
// one that has to survive it. Driven the old way, with End in place of the placement, this fails
// three runs out of three with " TAIL" sitting in the middle of the block.
test("typing at the end of a two line fence appends to the block, not to the line", async ({
page,
}) => {
await openReadme(page);
const paragraph = page.locator(".prose p").first();
const words = (await paragraph.innerText()).trim();
await paragraph.click();
await caretIsIn(paragraph);
await page.locator('.editor-toolbar .tool[title="Code block"]').click();
await expect(page.locator(".prose pre").filter({ hasText: words })).toHaveCount(1);
const code = page.locator(".prose pre").first().locator("code");
// Two lines, from the break the paragraph had in it, which is what makes the difference between
// the end of a line and the end of a block something a test can see.
expect((await code.innerText()).split("\n")).toHaveLength(2);
// Scrolled back to the beginning of the line first, which is where somebody who wants to read it
// puts it: a fence arrives scrolled to wherever the caret was when it was made, and the start of
// the text is off the left edge of the box until it is scrolled back.
const box = await code.boundingBox();
if (!box) throw new Error("the fence is not on screen");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(-600, 0);
await expect.poll(() => code.evaluate((element) => element.scrollLeft)).toBe(0);
const line = await code.evaluate((element) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const first = walker.nextNode() as Text | null;
if (!first) throw new Error("the fence has no text in it");
const range = document.createRange();
range.setStart(first, 0);
range.setEnd(first, Math.min(4, first.data.length));
const rect = range.getBoundingClientRect();
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
});
await page.mouse.click(line.x, line.y);
await caretIsIn(code);
await putCaret(code, "end");
await page.keyboard.type(" TAIL");
await expect(code).toHaveText(`${words} TAIL`);
});
test("the insert tool adds a formula and KaTeX draws it", async ({ page }) => {
await openReadme(page);
await page.locator('.editor-toolbar .tool[title="Insert"]').click();
await page.locator(".insert-pop button", { hasText: "Display formula" }).click();
const block = page.locator(".prose .math-block").first();
await expect(block).toBeVisible();
// An empty formula opens on its field, which is the whole reason the insert selects the node.
const field = block.locator(".math-source");
await expect(field).toBeVisible();
await field.fill("\\frac{a}{b}");
await page.keyboard.press("Escape");
await expect(block.locator(".katex")).toBeVisible();
// The attribute is the source of truth and the render is a copy of it, never the other way round.
await expect(block).toHaveAttribute("data-latex", "\\frac{a}{b}");
});
// The toggle, which is the block with the most of itself outside ProseMirror's reach: a native
// <details>, a summary row that is not the document's content, and an arrow that turns on a CSS
// rule keyed off an attribute a command writes. None of that runs in the node environment the unit
// suite uses, so this is the half of it that only a browser can answer. Element.checkVisibility()
// rather than a box measurement, because a closed <details> keeps the intrinsic height of content
// it is not painting.
test("the toggle opens, closes and takes an edit to its title", async ({ page }) => {
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();
// guides/ is a folder, so the row for the file inside it is not there until it is opened.
await page.locator(`.tree-row[data-path="${HANDBOOK}/guides"]`).click();
await page.locator(`.tree-row[data-path="${HANDBOOK}/guides/writing.md"]`).click();
await expect(page.locator(".prose h1")).toHaveText("Writing");
const toggle = page.locator(".prose details.toggle").first();
const row = toggle.locator("summary");
const title = toggle.locator("[data-toggle-summary]");
const body = toggle.locator("[data-toggle-body]");
const visible = () => body.evaluate((node) => node.checkVisibility());
// The file says <details> with no open attribute, so it arrives closed and its body is not on
// screen. Nothing has been written to it: opening a document writes nothing.
await expect(toggle).not.toHaveAttribute("open", /.*/);
expect(await visible()).toBe(false);
await expect(title).toHaveText("House style, the short version");
// The arrow, which is the summary row's own ::before and so a press on the chrome rather than on
// the title. Native disclosure is cancelled and the attribute is written by a command, so this
// asserts the command ran and not that a browser did what browsers do.
await row.click({ position: { x: 8, y: 12 } });
await expect(toggle).toHaveAttribute("open", "");
expect(await visible()).toBe(true);
await row.click({ position: { x: 8, y: 12 } });
await expect(toggle).not.toHaveAttribute("open", /.*/);
expect(await visible()).toBe(false);
// The title is editable text inside chrome that is not, so a click has to land the caret in it
// and the keystrokes after it have to reach it rather than the document underneath. Typing here
// put the characters at the end of the toggle's last hidden paragraph before this block existed.
await title.click({ position: { x: 4, y: 8 } });
await caretIsIn(title);
// And then put at the front on purpose. Where in the first glyph a click at x=4 falls is a hit
// test against half a character, which decides between ZZZHouse and HZZZouse and is no part of
// what this test is about.
await putCaret(title, "start");
await page.keyboard.type("ZZZ");
await expect(title).toHaveText("ZZZHouse style, the short version");
await expect(page.locator(".prose", { hasText: "ZZZ" })).toHaveCount(1);
await expect(body).not.toContainText("ZZZ");
// And the pill is drawn dead while the caret is in there. ProseMirror's selection is still
// wherever it was in the document, so a tool pressed now would edit a paragraph nobody is
// looking at; the editor refuses that outright, and a button that looks live and does nothing is
// the same lie one layer up. It comes back as soon as the caret is in the document again.
const bold = page.locator('.editor-toolbar .tool[title="Bold"]');
await expect(bold).toBeDisabled();
await page.locator(".prose h1").click();
await expect(bold).toBeEnabled();
});
+217
View File
@@ -0,0 +1,217 @@
// What one keystroke does to the file, read off the disk rather than off the screen.
//
// Every other suite here asks the app what it thinks. This one asks the fixture what was written,
// because the promise this project actually makes is about bytes: saving must not change a byte the
// user did not edit. Four rounds of adversarial review found nine, five, three and three data loss
// bugs behind a green gate, and the ones that hurt most were all the same shape. A unit test built
// a document by hand, asked the serializer to write it, got the right answer, and shipped. The
// document a keystroke really produces was different, because between the key and the serializer
// sits prosemirror-view reparsing its own DOM, and nothing in the unit suite has a DOM.
//
// So these tests type into a running editor and then read the file. They are slow and there are
// few of them on purpose: one per way the app has been caught rewriting bytes nobody touched.
import { expect, test, type Page } from "@playwright/test";
import { putCaret } from "./caret";
import { ask, change, installTauriShim } from "./disk";
const HANDBOOK = "/Users/you/Documents/Handbook";
const README = `${HANDBOOK}/README.md`;
const row = (path: string) => `.tree-row[data-path="${path}"]`;
/** Longer than the 500ms autosave debounce in src/document.ts, with room for the write itself. */
const SAVED = 1500;
/** Opens the README with the given bytes in it and waits for them to be on screen. */
async function open(page: Page, source: string): Promise<void> {
await page.addInitScript(installTauriShim);
await page.addInitScript(() => {
localStorage.clear();
localStorage.setItem("margindocs-recents", JSON.stringify(["/Users/you/Documents/Handbook"]));
});
await page.goto("/");
await expect(page.locator(row(HANDBOOK))).toBeVisible();
await page.locator(row(README)).click();
await expect(page.locator(".prose")).toBeVisible();
// Written from outside rather than typed, so the app has not registered a self-write and this is
// a file it has only ever read.
await change(page, "write", README, source);
await expect.poll(() => ask<string>(page, "read", README)).toBe(source);
}
/** What is on disk now. */
function disk(page: Page): Promise<string> {
return ask<string>(page, "read", README);
}
test("a vite server serving somebody else's copy cannot pass this suite", async ({ page }) => {
// playwright.config.ts sets `reuseExistingServer`, so the suite talks to whatever already answers
// on the port. A stale server left behind by another checkout serves that checkout, and every
// assertion below would then be about a file this repository does not contain. Cheap to check and
// it has already caught one: the paragraph's whitespace contract is the newest thing in the
// schema, so a server without it is a server not serving this tree.
await open(page, "hello\n");
const paragraph = await page.evaluate(() => {
const view = (document.querySelector(".prose") as HTMLElement & { pmViewDesc?: { node: { type: { schema: { nodes: Record<string, { spec: Record<string, unknown> }> } } } } }).pmViewDesc;
if (!view) return null;
return view.node.type.schema.nodes.paragraph.spec;
});
expect(paragraph).not.toBeNull();
expect(paragraph?.whitespace).toBe("pre");
expect(JSON.stringify(paragraph?.parseDOM)).toContain("preserveWhitespace");
});
test("one character into a hand wrapped paragraph is a one character diff", async ({ page }) => {
// The bug a user meets first, because a hand wrapped file is what a text editor leaves behind.
// prosemirror-view reparses the paragraph's own DOM after a keystroke and asks the schema how to
// read its whitespace; with the default answer every soft wrap in the paragraph came back a
// `hardBreak`, and the save put a backslash at the end of every line the user had wrapped. The
// whole file, from one character. src/model/schema.ts carries the field that stops it, and only
// a running editor proves the field is reaching the parse: the serializer never saw the fault.
const source = "# Title\n\none wrapped\nline here\n\nsecond one\nalso wrapped\n";
await open(page, source);
const paragraph = page.locator(".prose p").first();
await putCaret(paragraph, 3);
await page.keyboard.type("Z");
await page.waitForTimeout(SAVED);
expect(await disk(page)).toBe("# Title\n\noneZ wrapped\nline here\n\nsecond one\nalso wrapped\n");
});
test("joining two hand wrapped paragraphs keeps every wrap in both of them", async ({ page }) => {
// The same field, read by prosemirror-transform's `join` rather than by the parser, which is why
// it is worth its own test: a fix that only taught the parser would leave this one rewriting both
// paragraphs on a single Backspace.
await open(page, "one\ntwo\n\nthree\nfour\n");
await putCaret(page.locator(".prose p").nth(1), "start");
await page.keyboard.press("Backspace");
await page.waitForTimeout(SAVED);
const after = await disk(page);
expect(after).not.toContain("\\");
expect(after).toBe("one\ntwothree\nfour\n");
});
test("a line break the user asks for mid paragraph reaches the file", async ({ page }) => {
// The control for the two tests below. A trailing break is written as nothing, and a change that
// reached this one instead would be taking away the gesture rather than tidying up after it.
await open(page, "alpha beta\n");
await putCaret(page.locator(".prose p").first(), 5);
await page.keyboard.press("Shift+Enter");
await page.waitForTimeout(SAVED);
// The escaped space is old, documented and stable on reparse: mdast will not let a hard break be
// followed by a literal space, and this test is about the break, not about the spelling.
expect(await disk(page)).toBe("alpha\\\n&#x20;beta\n");
});
test("a break with nothing under it is written as nothing, and the next character makes it real", async ({
page,
}) => {
// There is no markdown for a paragraph that ends on a blank line, so the honest spelling of a
// trailing break is no spelling. It used to be written `\` on a line of its own, which reads back
// as a literal backslash in the user's words: a character they never typed, in their file, from a
// key they pressed and had not finished using yet.
await open(page, "hello there\n");
await putCaret(page.locator(".prose p").first(), "end");
await page.keyboard.press("Shift+Enter");
await page.waitForTimeout(SAVED);
expect(await disk(page)).toBe("hello there\n");
await page.keyboard.type("q");
await page.waitForTimeout(SAVED);
expect(await disk(page)).toBe("hello there\\\nq\n");
});
test("a break left trailing by a deletion is written as nothing too", async ({ page }) => {
// The route no keystroke guard covers: the break was legal where it was put, and a Delete took
// the text out from under it. The answer lives in the serializer for exactly this reason.
await open(page, "para\n");
await putCaret(page.locator(".prose p").first(), "end");
await page.keyboard.type("xy");
await page.keyboard.press("ArrowLeft");
await page.keyboard.press("Shift+Enter");
await page.keyboard.press("Delete");
await page.waitForTimeout(SAVED);
expect(await disk(page)).toBe("paraxy\n");
});
test("typing into a raw block does not swallow the list above it", async ({ page }) => {
// An edited raw block has to be written back somewhere the reparse will find it again, and a
// list directly above one leaves it nowhere: every indent that keeps the list readable puts the
// block in a fence or in the list. The save gives up the edit rather than the file.
const source = "- one\n- two\n\n<div>\nraw\n</div>\n\nafter\n";
await open(page, source);
await putCaret(page.locator(".prose pre").first(), "start");
await page.keyboard.type(" ");
await page.waitForTimeout(SAVED);
expect(await disk(page)).toBe(source);
await expect(page.locator(".prose ul li")).toHaveText(["one", "two"]);
});
test("an edit in the prose does not eat bytes off the end of a raw block", async ({ page }) => {
// Preserved source running to the end of the file was trimmed of its trailing blank lines on the
// way out, so a keystroke somewhere else deleted characters inside somebody's html.
await open(page, "para\n\n<pre>\nkeep\n\n\n");
await putCaret(page.locator(".prose p").first(), "end");
await page.keyboard.type("Z");
await page.waitForTimeout(SAVED);
expect(await disk(page)).toBe("paraZ\n\n<pre>\nkeep\n\n\n");
});
test("a tight list inside a quote is not blown apart by an edit", async ({ page }) => {
// remark calls a list spread when the blank line is after the last item; CommonMark does not, and
// inside a quote that line is a `>` the list token runs over. Believing remark put a blank line
// between every item on the save after next.
await open(page, "> - one\n> - two\n>\n> > nested\n");
await putCaret(page.locator(".prose blockquote li p").first(), "end");
await page.keyboard.type("Z");
await page.waitForTimeout(SAVED);
expect(await disk(page)).toBe("> - oneZ\n> - two\n>\n> > nested\n");
});
test("a printable character over a rectangle of cells writes nothing", async ({ page }) => {
// A rectangle is a selection of containers, not of text. ProseMirror offered the character to
// every cell range in it and `insertText` replaced all four, so one keypress emptied cells the
// user could see they had not aimed at.
const source = "| a | b |\n| - | - |\n| c | d |\n";
await open(page, source);
const cells = page.locator(".prose table td, .prose table th");
const first = await cells.nth(0).boundingBox();
const last = await cells.nth(3).boundingBox();
await page.mouse.move(first!.x + first!.width / 2, first!.y + first!.height / 2);
await page.mouse.down();
await page.mouse.move(last!.x + last!.width / 2, last!.y + last!.height / 2, { steps: 8 });
await page.mouse.up();
// The gesture really made a rectangle. Without this the test passes on a suite where the drag
// silently did nothing, which is the shape of guard this project has shipped four times.
await expect(page.locator(".prose .selectedCell")).toHaveCount(4);
await page.keyboard.type("Z");
await page.waitForTimeout(SAVED);
expect(await disk(page)).toBe(source);
await expect(page.locator(".prose table")).toContainText("d");
});
test("opening a document and looking at it writes nothing", async ({ page }) => {
const source = "# Title\n\none wrapped\nline here\n\n<div>\nraw\n</div>\n\n- a\n- b\n";
await open(page, source);
await page.waitForTimeout(SAVED * 2);
expect(await disk(page)).toBe(source);
});
+134
View File
@@ -0,0 +1,134 @@
// Putting a caret somewhere on purpose, for the specs that press a key afterwards.
//
// Every test in here that types has the same two steps underneath, and both of them are less
// certain than they look.
//
// A click is a hit test. Which character it lands on is decided by the pixel it happened to be
// aimed at, by the element's scroll position at that instant, and by whichever line box the point
// fell in. A fence is the worst case of all three at once: `.prose pre > code` is `white-space:
// pre` with `overflow-x: auto`, so a block wider than the column is scrolled by whatever the caret
// was doing a moment ago, and Playwright aims at the middle of the box, which for a two line block
// is a hair from the boundary between the two lines. The same gesture, repeated at the same
// coordinates, was measured landing at three different offsets in three different text nodes.
//
// End is worse again, and this project has now been bitten by it twice. It is a different key on
// every platform, on macOS it is not a key anyone presses to move a caret, and what answers it
// here moves by line: a block of two lines answers it two ways depending on which of them the
// click chose. One of those two answers puts the next keystroke in the middle of the block, and
// tests/blocks.spec.ts was asserting the text of the whole block afterwards. So nothing in this
// suite presses End to move a caret. It places one and reads it back.
//
// Read back as a character offset rather than as a DOM node and an offset in it, because
// prosemirror-view rewrites the nodes under a code block on every keystroke, and the same caret in
// a differently split span is still the same caret.
//
// The two frames are the other half. prosemirror-view takes the browser's selection into its own
// state on a selectionchange, and a key sent before it has done so is answered about the position
// the caret used to be in. A person cannot press two keys inside one frame; Playwright can, and
// did, about a third of the time.
import { expect, type Locator, type Page } from "@playwright/test";
/** A character offset into an element's text, or either end of it. */
export type Where = "start" | "end" | number;
/** What the browser says about the caret, in the terms this file checks it in. */
interface CaretState {
/** How many characters of the element's text are before the caret, or null if it is elsewhere. */
offset: number | null;
/** Whether the editable this element belongs to is the one holding the keyboard. */
focused: boolean;
}
/**
* Two animation frames, which is what it takes for prosemirror-view to have read a selection back.
*/
export function settle(page: Page): Promise<void> {
return page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
/** Where the browser has the caret, measured against this element. */
function caretState(target: Locator): Promise<CaretState> {
return target.evaluate((element) => {
const editable = element.closest("[contenteditable='true']");
const focused = editable !== null && document.activeElement === editable;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) {
return { offset: null, focused };
}
const range = selection.getRangeAt(0);
if (!element.contains(range.startContainer)) return { offset: null, focused };
const measure = document.createRange();
measure.selectNodeContents(element);
measure.setEnd(range.startContainer, range.startOffset);
return { offset: measure.toString().length, focused };
});
}
/**
* Puts the caret at a named place in an editable element and does not return until the browser
* agrees it is there and the view has had its two frames to notice.
*
* The element has to be holding the keyboard already, which in these specs means a real click went
* in first: this places a caret, it does not decide who has focus, and a selection written into an
* editable nobody is in is read by nobody.
*/
export async function putCaret(target: Locator, where: Where): Promise<void> {
const wanted = await target.evaluate((element, at) => {
const text = element.textContent ?? "";
const offset =
at === "start" ? 0 : at === "end" ? text.length : Math.max(0, Math.min(at, text.length));
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(true);
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let seen = 0;
while (walker.nextNode()) {
const node = walker.currentNode as Text;
if (offset <= seen + node.data.length) {
range.setStart(node, offset - seen);
range.collapse(true);
break;
}
seen += node.data.length;
}
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
return offset;
}, where);
await settle(target.page());
// Still there after the frames, rather than only just after it was written: if the view had a
// different idea of where the caret was it would have put its own back by now, and a test that
// typed anyway would be typing somewhere nobody can see.
await expect
.poll(() => caretState(target), { message: `the caret did not settle at ${wanted}` })
.toEqual({ offset: wanted, focused: true });
}
/**
* Waits until the caret is somewhere inside this element, wherever a click put it, and gives the
* view its two frames. For the gestures where the point is that a click reaches the caret at all
* and the offset it lands on is nobody's business.
*/
export async function caretIsIn(target: Locator): Promise<void> {
await expect
.poll(
async () => {
const state = await caretState(target);
return { inside: state.offset !== null, focused: state.focused };
},
{ message: "the caret never landed in the element that was clicked" },
)
.toEqual({ inside: true, focused: true });
await settle(target.page());
}
+153
View File
@@ -0,0 +1,153 @@
// The guards that stand between somebody's document and a key they pressed, driven through a real
// EditorView with a real mouse and a real Cmd+V.
//
// This file exists because of what the unit suite cannot see. src/editor/fits.test.ts builds the
// real extension list, collects the real plugin list and walks it with prosemirror-view's own
// someProp, and that is as close as node gets: vitest runs with no DOM, so there is no view to
// construct and no event to send to one. Four times this project has shipped a guard that was
// written, documented, tested and never reached, and the last of them was reached by nothing
// because a library plugin claimed the paste nine places ahead of it. No handler-level test can
// catch that. A browser can, and this is the browser.
//
// So nothing here calls anything. A drag is `page.mouse`, a paste is `Meta+v` off the real system
// clipboard, and every assertion is about what is on screen afterwards. If a guard stops being
// asked, the bytes move and these fail.
import { expect, test, type Page } from "@playwright/test";
import { caretIsIn, putCaret } from "./caret";
const HANDBOOK = "/Users/you/Documents/Handbook";
const README = `${HANDBOOK}/README.md`;
async function openReadme(page: Page): Promise<void> {
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 page.locator(`.tree-row[data-path="${README}"]`).click();
await expect(page.locator(".prose h1")).toHaveText("Handbook");
}
test("a paste over a dragged rectangle of cells leaves every cell alone", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await openReadme(page);
const paragraph = page.locator(".prose p").first();
await paragraph.click();
await caretIsIn(paragraph);
await page.locator('.editor-toolbar .tool[title="Insert table"]').click();
await page.locator('.table-pop .size-cell[title="3 by 3 table"]').click();
const cell = (row: number, column: number) =>
page.locator(".prose table tr").nth(row).locator("th, td").nth(column);
for (const [row, column, text] of [
[0, 0, "aa"],
[0, 1, "bb"],
[1, 0, "cc"],
[1, 1, "dd"],
] as Array<[number, number, string]>) {
await cell(row, column).click();
// Typed only once the click has reached the view. A cell is small enough that the hit test
// cannot land anywhere else, but the keystrokes still go wherever the view thinks the caret
// is, and that is the cell before this one until it has read the click.
await caretIsIn(cell(row, column));
await page.keyboard.type(text);
}
// A real drag across the rectangle. Nothing else makes a CellSelection, which is the selection
// the whole question is about: prosemirror-tables answers a paste made over one by replacing the
// content of every cell in it, so four cells of somebody's table went for one word on the
// clipboard, and an image-only clipboard, which carries an empty slice, emptied all four.
const first = await cell(0, 0).boundingBox();
const last = await cell(1, 1).boundingBox();
if (!first || !last) throw new Error("the table is not on screen");
await page.mouse.move(first.x + first.width / 2, first.y + first.height / 2);
await page.mouse.down();
await page.mouse.move(last.x + last.width / 2, last.y + last.height / 2, { steps: 12 });
await page.mouse.up();
await expect(page.locator(".prose table .selectedCell")).toHaveCount(4);
await page.evaluate(() => navigator.clipboard.writeText("zzz"));
await page.keyboard.press("Meta+v");
// The one wait in this suite that has to be a wait. What is asserted below is that nothing
// happened, and nothing happening has no event to listen for: the only way to be sure a paste
// was refused rather than still in flight is to give it time to arrive and then look.
await page.waitForTimeout(300);
const table = await page.locator(".prose table").first().innerText();
expect(table).not.toContain("zzz");
expect(table).toContain("aa");
expect(table).toContain("bb");
expect(table).toContain("cc");
expect(table).toContain("dd");
});
test("a paste of two paragraphs into a fence does not split the fence", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await openReadme(page);
const paragraph = page.locator(".prose p").first();
const words = (await paragraph.innerText()).trim();
await paragraph.click();
await caretIsIn(paragraph);
await page.locator('.editor-toolbar .tool[title="Code block"]').click();
const fence = page.locator(".prose pre").filter({ hasText: words });
await expect(fence).toHaveCount(1);
// Counted rather than matched on its text, because the paste is about to change that text and a
// filter that stops matching would read as a fence that has gone.
const fencesBefore = await page.locator(".prose pre").count();
await fence.click();
// Which character of the fence the caret is on does not matter here and is deliberately not
// pinned: a paste in the middle of a block is the one that used to split it. That it is in the
// fence at all does matter, and that is what this waits for.
await caretIsIn(fence);
// ProseMirror does not refuse a paste that does not fit. It splits whatever the caret was in and
// puts the pieces either side, so before the guard was reached this left two fences with the
// pasted prose sitting between them.
await page.evaluate(() => navigator.clipboard.writeText("para one\n\npara two"));
await page.keyboard.press("Meta+v");
// The paste has landed when its last word is on screen, wherever the guard put it. A fixed wait
// here is either longer than the run needs or shorter than a slow one does, and the assertions
// below are the ones that say where it went.
await expect(page.locator(".prose")).toContainText("para two");
expect(await page.locator(".prose pre").count()).toBe(fencesBefore);
const code = await page.locator(".prose pre code").first().innerText();
expect(code).toContain("para one");
expect(code).toContain("para two");
// And the words the fence was made out of are still all in one fence, rather than half of them
// sitting in a second one under the pasted prose.
expect(code.replace(/para one|para two|\n/g, "")).toBe(words.replace(/\n/g, ""));
});
test("Shift+Enter at the end of a heading does not put a break in it", async ({ page }) => {
await openReadme(page);
// The caret is placed and then checked, because clicking a heading and pressing a key straight
// afterwards lands at offset zero often enough to make this test lie, and a break in the MIDDLE
// of a level one heading is one the file can hold.
const heading = page.locator(".prose h1").first();
await heading.click();
await putCaret(heading, "end");
// A break with nothing after it has no last line for the underline to go under, so mdast writes
// no underline and the heading goes to disk as `Handbook\` and comes back as a paragraph. The
// key is claimed and does nothing, rather than declined, because a browser handed Shift+Enter in
// a contenteditable puts a <br> in by itself.
await page.keyboard.press("Shift+Enter");
await expect(page.locator(".prose h1")).toHaveCount(1);
expect(await page.locator(".prose h1").first().innerHTML()).toBe("Handbook");
// And the same key one character in, which markdown spells as a setext heading and which
// therefore still works. Without this a guard that refused every break would pass the assertion
// above, which is the shape of test this round was sent to get rid of.
await putCaret(heading, 7);
await page.keyboard.press("Shift+Enter");
// Polled rather than slept on. The break arrives in the frame the key is answered in, and a
// fixed wait is only ever either longer than the run needs or shorter than a slow one does.
await expect.poll(() => heading.innerHTML()).toBe("Handboo<br>k");
});
+144
View File
@@ -0,0 +1,144 @@
// The fixture behind the app, reachable from a test.
//
// Two specs need it now, so it lives here rather than in one of them: tests/external-changes.spec.ts
// drives the watcher over this wire, and tests/bytes.spec.ts reads what the autosave actually put on
// disk. Both of them are claims about the file, and a claim about the file needs the file.
//
// This is a shim, not a mock of the app: what is behind it is src/dev/mockIpc.ts, the same fixture
// `pnpm dev` runs against, so a test here exercises every branch the packaged binary takes.
import { type Page } from "@playwright/test";
declare global {
interface Window {
__disk: {
/**
* Changes the folder from outside the app and puts the resulting `watch-event`s on the bus.
* Returns, per event, how many listeners actually received it, so a test can tell a working
* subscription apart from a payload that fell on the floor.
*/
change(name: string, args: unknown[]): Promise<number[]>;
/** Asks the fixture something. Changes nothing and emits nothing. */
ask(name: string, args: unknown[]): Promise<unknown>;
};
}
}
/**
* Enough of Tauri's JavaScript side for the real `@tauri-apps/api` to run against: an `invoke` that
* answers commands, the synchronous callback registry `transformCallback` needs, and the event
* plugin's listen and unlisten. Deliberately not `@tauri-apps/api/mocks`, which cannot be reached
* from an init script, and deliberately hand-rolled to the same contract rather than to a
* convenient one.
*
* Runs at document start on every navigation, before any module of the app is evaluated, which is
* what matters: `isTauri` in src/ipc.ts is computed once at import time.
*/
export function installTauriShim(): void {
interface Backend {
mockCall(command: string, args?: Record<string, unknown>): Promise<unknown>;
external: Record<string, (...args: unknown[]) => unknown>;
}
const listeners = new Map<string, Set<number>>();
const callbacks = new Map<number, (data: unknown) => void>();
let nextId = 1;
const registerCallback = (fn?: (data: unknown) => void, once = false): number => {
const id = nextId;
nextId += 1;
callbacks.set(id, (data) => {
if (once) callbacks.delete(id);
fn?.(data);
});
return id;
};
const unregisterCallback = (id: number): void => {
callbacks.delete(id);
};
const forget = (event: string, id: number): void => {
listeners.get(event)?.delete(id);
unregisterCallback(id);
};
const emit = (event: string, payload: unknown): number => {
let delivered = 0;
for (const id of [...(listeners.get(event) ?? [])]) {
const fn = callbacks.get(id);
if (!fn) continue;
fn({ event, id, payload });
delivered += 1;
}
return delivered;
};
// A variable specifier so this stays a runtime import of a Vite-served source file rather than
// something the test's own compiler tries to resolve.
const specifier = "/src/dev/mockIpc.ts";
let loading: Promise<Backend> | null = null;
const backend = (): Promise<Backend> => (loading ??= import(specifier) as Promise<Backend>);
const invoke = async (command: string, args?: Record<string, unknown>): Promise<unknown> => {
const a = (args ?? {}) as Record<string, unknown>;
if (command === "plugin:event|listen") {
const event = a.event as string;
const handler = a.handler as number;
const set = listeners.get(event) ?? new Set<number>();
set.add(handler);
listeners.set(event, set);
return handler;
}
if (command === "plugin:event|unlisten") {
forget(a.event as string, a.eventId as number);
return null;
}
// The window, dialog and opener commands have nothing to act on in a browser tab, and nothing
// in this suite emits from the frontend. Answering rather than throwing keeps the app's own
// startup (the close-requested handler, chiefly) from filling the console with rejections.
if (command.startsWith("plugin:") || command.startsWith("core:")) return null;
return (await backend()).mockCall(command, args);
};
const internals = {
invoke,
transformCallback: registerCallback,
unregisterCallback,
runCallback: (id: number, data: unknown) => callbacks.get(id)?.(data),
callbacks,
metadata: {
currentWindow: { label: "main" },
currentWebview: { windowLabel: "main", label: "main" },
},
convertFileSrc: (path: string) => path,
plugins: {},
};
const global = window as unknown as Record<string, unknown>;
global.__TAURI_INTERNALS__ = internals;
global.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: forget };
window.__disk = {
async change(name, args) {
const events = (await backend()).external[name](...args) as unknown[];
// The literal, not the constant: this is one of the two ends being tied together.
return events.map((event) => emit("watch-event", event));
},
async ask(name, args) {
const answer = (await backend()).external[name](...args);
return answer === undefined ? null : answer;
},
};
}
export function change(page: Page, name: string, ...args: unknown[]): Promise<number[]> {
return page.evaluate((call) => window.__disk.change(call.name, call.args), { name, args });
}
export function ask<T>(page: Page, name: string, ...args: unknown[]): Promise<T> {
return page.evaluate((call) => window.__disk.ask(call.name, call.args), {
name,
args,
}) as Promise<T>;
}
+359
View File
@@ -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);
},
);
+52
View File
@@ -0,0 +1,52 @@
// Watching the unsaved dot without racing the thing that clears it.
//
// The dot is on screen from the first keystroke until the autosave lands, which is 500ms after the
// last one plus however long the write takes. Two specs assert it appears, and both did it by
// looking for it after the typing had finished, which is a poll against a deadline: the dot is
// there for half a second, and a run that stalls for half a second between the last keystroke and
// the first look reads a document that has already been saved and calls it a document that was
// never dirty. Nothing is wrong with the app in that run. The test just arrived late.
//
// So the answer is recorded as it happens rather than asked for afterwards. A MutationObserver
// installed before the typing starts sees the dot go in, and the assertion is about what was seen.
// It is also the stronger claim of the two: a poll that happens to land inside the window cannot
// tell "the dot appeared" from "the dot was already there", and this can.
import { expect, type Page } from "@playwright/test";
declare global {
interface Window {
/** What the observer below has seen since it was installed. */
__dirtyDot: { seen: boolean; visible: boolean };
}
}
/** Starts watching. Anything the dot does before this call is not recorded, so call it first. */
export function watchDirty(page: Page): Promise<void> {
return page.evaluate(() => {
const state = { seen: false, visible: false };
window.__dirtyDot = state;
// Painted as well as present: the dot is a span with nothing in it, so a rule that stopped
// drawing it would leave every assertion about it passing.
const look = (): void => {
if (state.seen) return;
const dot = document.querySelector(".dirty-dot");
if (!dot) return;
state.seen = true;
state.visible = dot.checkVisibility();
};
new MutationObserver(look).observe(document.body, { subtree: true, childList: true });
look();
});
}
/** That the document was marked unsaved at some point since `watchDirty`. */
export async function dirtyWasShown(page: Page): Promise<void> {
await expect
.poll(() => page.evaluate(() => window.__dirtyDot), {
message: "the unsaved dot was never on screen",
})
.toEqual({ seen: true, visible: true });
}
+225
View File
@@ -0,0 +1,225 @@
// The app, driven in a real browser against the dev IPC mock in src/dev/mockIpc.ts. No Tauri and
// no Rust: the fixture answers every command, so this exercises the actual UI without going
// anywhere near anybody's documents.
//
// What is asserted here is the product's promises rather than the implementation's details. A
// folder opens and shows a tree. A markdown file opens as formatted prose with no markdown syntax
// anywhere on screen, which is the whole point of the editor. Typing marks the document unsaved and
// the autosave clears it again. A .txt file opens as plain text with no formatting toolbar, because
// there is nothing to format.
import { expect, test, type Page } from "@playwright/test";
import { caretIsIn } from "./caret";
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}"]`;
/**
* A first launch: the fixture is seeded with two folders already open, which is the one state the
* start screen can never be seen in. `margindocs-dev-empty` is the mock's own switch for that, and
* the recents list is seeded beside it so a folder can be opened without a native picker there is
* no browser equivalent of.
*/
async function firstLaunch(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.clear();
localStorage.setItem("margindocs-dev-empty", "1");
localStorage.setItem("margindocs-recents", JSON.stringify(["/Users/you/Documents/Handbook"]));
});
await page.goto("/");
}
async function openHandbook(page: Page): Promise<void> {
await firstLaunch(page);
await page.locator(".start-row").first().click();
await expect(page.locator(row(HANDBOOK))).toBeVisible();
}
test("the app renders and opens on the start screen", async ({ page }) => {
await firstLaunch(page);
await expect(page.locator(".app")).toBeVisible();
await expect(page.locator(".titlebar")).toBeVisible();
await expect(page.locator(".start-title")).toHaveText("Margin Docs");
await expect(page.locator(".start-open")).toBeVisible();
await expect(page.locator(".start-row")).toHaveCount(1);
await expect(page.locator(".start-name")).toHaveText("Handbook");
// Nothing is open, so there is no tree and no document.
await expect(page.locator(".sidebar")).toHaveCount(0);
await expect(page.locator(".prose")).toHaveCount(0);
});
test("opening a folder shows its tree", async ({ page }) => {
await openHandbook(page);
await expect(page.locator(".start")).toHaveCount(0);
await expect(page.locator(".sidebar")).toBeVisible();
// A freshly opened root expands itself, so its contents are on screen without a second click.
await expect(page.locator(row(README))).toBeVisible();
await expect(page.locator(row(`${HANDBOOK}/guides`))).toBeVisible();
await expect(page.locator(row(NOTES))).toBeVisible();
// Markdown hides its own extension and nothing else does.
await expect(page.locator(`${row(README)} .tree-name`)).toHaveText("README.md");
await expect(page.locator(`${row(README)} .tree-ext`)).toHaveText(".md");
await expect(page.locator(`${row(NOTES)} .tree-name`)).toHaveText("notes.txt");
await expect(page.locator(`${row(NOTES)} .tree-ext`)).toHaveCount(0);
// A file the editor will not open is greyed rather than hidden.
await page.locator(row(`${HANDBOOK}/reference`)).click();
await page.locator(row(`${HANDBOOK}/reference/assets`)).click();
const png = page.locator(row(`${HANDBOOK}/reference/assets/diagram.png`));
await expect(png).toBeVisible();
await expect(png).toHaveAttribute("data-foreign", "true");
await expect(page.locator(row(`${HANDBOOK}/reference/keyboard.md`))).toHaveAttribute(
"data-foreign",
"false",
);
});
test("a markdown file opens as formatted prose with no markdown syntax on screen", async ({
page,
}) => {
await openHandbook(page);
await page.locator(row(README)).click();
const prose = page.locator(".prose");
await expect(prose).toBeVisible();
// Rendered, not source: a real h1, a real h2, a real link, a real callout further down.
await expect(prose.locator("h1")).toHaveText("Handbook");
await expect(prose.locator("h2")).toHaveText("What lives where");
await expect(prose.locator('a[href="guides/getting-started.md"]')).toHaveText("Getting started");
await expect(prose.locator("code").first()).toHaveText("archive/");
const text = (await prose.innerText()).trim();
expect(text).toContain("Handbook");
// The four things that would be on screen if this were a source view.
expect(text).not.toContain("# Handbook");
expect(text).not.toContain("](guides/getting-started.md)");
expect(text).not.toContain("`archive/`");
// Frontmatter is carried opaque and never shown.
expect(text).not.toContain("title: Handbook");
expect(text).not.toContain("tags: [team, reference]");
// The file's name is what the title bar shows, and the H1 has nothing to do with it.
await expect(page.locator(".titlebar .doc-title")).toHaveText("README");
// A markdown document gets the one formatting surface there is.
await expect(page.locator(".editor-toolbar")).toBeVisible();
await expect(page.locator('.editor-toolbar .tool[title="Bold"]')).toBeEnabled();
});
test("typing marks the document unsaved and the autosave clears it", async ({ page }) => {
await openHandbook(page);
await page.locator(row(README)).click();
await expect(page.locator(".prose h1")).toHaveText("Handbook");
await expect(page.locator(".dirty-dot")).toHaveCount(0);
const paragraph = page.locator(".prose p").first();
await paragraph.click();
await caretIsIn(paragraph);
// Recorded from before the first keystroke rather than looked for after the last one. The dot is
// on screen for the 500ms of the debounce and no longer, so a run that stalls for half a second
// between the typing and the poll used to read a saved document and call it one that was never
// marked unsaved. See tests/saving.ts.
await watchDirty(page);
await page.keyboard.type("Margin");
await dirtyWasShown(page);
// The debounce is 500ms and the mock accepts the write, so the dot goes again on its own.
await expect(page.locator(".dirty-dot")).toHaveCount(0, { timeout: 5000 });
});
test("a .txt file opens as plain text with no toolbar", async ({ page }) => {
await openHandbook(page);
await page.locator(row(NOTES)).click();
const field = page.locator("textarea.plain-text");
await expect(field).toBeVisible();
await expect(field).toHaveValue(/Scratch notes, not markdown, still editable\./);
// Not markdown, so nothing parses it and nothing offers to format it.
await expect(page.locator(".prose")).toHaveCount(0);
await expect(page.locator(".editor-toolbar")).toHaveCount(0);
await expect(page.locator(".titlebar .doc-title")).toHaveText("notes.txt");
});
test("Cmd+F finds matches in a plain text document", async ({ page }) => {
await openHandbook(page);
await page.locator(row(NOTES)).click();
const field = page.locator("textarea.plain-text");
await expect(field).toBeVisible();
const text = await field.inputValue();
const selectionOf = () =>
field.evaluate((el: HTMLTextAreaElement) => [el.selectionStart, el.selectionEnd]);
await field.click();
// The shortcut is answered by the document and its target is whatever holds the keyboard, so a
// press sent before the click has landed opens the find bar over the markdown surface instead.
await expect(field).toBeFocused();
await page.keyboard.press("Meta+f");
const bar = page.locator(".find-bar");
const findInput = bar.locator(".find-input").first();
const count = page.locator(".find-count");
await expect(bar).toBeVisible();
await expect(findInput).toBeFocused();
// "folder" is in the fixture twice: the archive folder, and the big folder. There is no
// ProseMirror decoration to paint over a textarea, so the match is the textarea's own selection.
await findInput.fill("folder");
await expect(count).toHaveText("1 of 2");
const first = text.indexOf("folder");
const second = text.indexOf("folder", first + 1);
expect(await selectionOf()).toEqual([first, first + "folder".length]);
await page.locator('.find-btn[title^="Next"]').click();
await expect(count).toHaveText("2 of 2");
expect(await selectionOf()).toEqual([second, second + "folder".length]);
// Wraps back around rather than stopping at the last match.
await page.locator('.find-btn[title^="Next"]').click();
await expect(count).toHaveText("1 of 2");
await page.locator('.find-btn[title^="Previous"]').click();
await expect(count).toHaveText("2 of 2");
// Case sensitivity is the one rule shared with the markdown surface, not reimplemented for a
// textarea: "the" matches four times loosely and three once "The index rebuild" stops counting.
await findInput.fill("the");
await expect(count).toHaveText("1 of 4");
await page.locator('.find-toggle[title="Match case"]').click();
await expect(count).toHaveText("1 of 3");
// A query that matches nothing says so, in the same words, and next/prev have nothing to do.
await findInput.fill("nothing to find here");
await expect(count).toHaveText("No results");
await expect(page.locator('.find-btn[title^="Next"]')).toBeDisabled();
// Replace is offered here exactly as it is for markdown: the same expand arrow, the same two
// buttons, driven by the same handle.
await findInput.fill("folder");
await expect(count).toHaveText("1 of 2");
await page.locator(".find-expand").click();
await bar.locator(".find-input").nth(1).fill("room");
await page.locator('.find-action[title="Replace the current match"]').click();
await expect(count).toHaveText("1 of 1");
await expect(field).toHaveValue(/the archive room\. Nobody/);
await page.locator('.find-action[title="Replace every match in this document"]').click();
await expect(count).toHaveText("No results");
await expect(field).toHaveValue(/the archive room\..*the big room\./s);
await page.keyboard.press("Escape");
await expect(bar).toHaveCount(0);
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [".", "../playwright.config.ts"]
}