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,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();
|
||||
});
|
||||
@@ -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 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
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user