mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-02 19:17:05 +00:00
226 lines
10 KiB
TypeScript
226 lines
10 KiB
TypeScript
// 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);
|
|
});
|