Files
InkNote/e2e/editor.mjs
T
marcandClaude Opus 5 2ea448f091 A bigger face, and the theme where the settings are
The picture fills the account button now — twenty pixels of a face is a
smudge — and light or dark moved out of the sidebar corner into Account →
Profile, next to the typeface and how a chapter opens, which is the kind of
thing it is.

Moving it made it worth keeping: it was pure screen state, so every reload
landed on dark whatever had been chosen. It is a setting on the account now,
like the typeface, with a copy in the browser so the page does not start in
one theme and turn to the other once the account arrives.

The check I first wrote for the sidebar losing its switch could not fail. It
counts the buttons in that corner instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 00:10:13 +02:00

411 lines
17 KiB
JavaScript

/* Editor: tables, task lists, links between notes, and whether any of it
* survives a reload — the place where a stale server response used to eat
* freshly typed content. */
import {
OUT,
api,
check,
createNote,
createNotebook,
insertTable,
launch,
reopenNote,
report,
setTitle,
typeInEditor,
withFreshUser,
} from "./lib.mjs";
const browser = await launch();
let session;
try {
session = await withFreshUser(browser, "editor");
const p = session.page;
await createNotebook(p, "Editor Book");
await createNote(p);
await typeInEditor(p, "Grundtext");
/* ── tables ── */
await insertTable(p);
check((await p.locator(".ProseMirror table").count()) > 0, "table inserted");
check((await p.locator(".ProseMirror table th").count()) >= 3, "table has a header row");
/* ── task list ── */
await p.locator(".ProseMirror").click();
await p.keyboard.press("Control+End");
await p.keyboard.press("Enter");
await p.getByTitle("Task list").click();
await p.waitForTimeout(400);
await p.keyboard.type("Milch kaufen");
await p.waitForTimeout(1200);
check((await p.locator('.ProseMirror input[type="checkbox"]').count()) > 0, "task list inserted");
await p.locator('.ProseMirror input[type="checkbox"]').first().click();
await p.waitForTimeout(1500);
await p.screenshot({ path: `${OUT}/editor-content.png` });
/* ── right-click menu on the table ── */
const cellCount = () => p.locator(".ProseMirror table td, .ProseMirror table th").count();
const before = await cellCount();
await p.locator(".ProseMirror table td").first().click({ button: "right" });
await p.waitForTimeout(600);
check((await p.getByText("Insert row below").count()) > 0, "right-click in a table opens the table menu");
check((await p.getByText("Delete table").count()) > 0, "and offers deleting the table");
await p.screenshot({ path: `${OUT}/editor-table-menu.png` });
await p.getByText("Insert row below").click();
await p.waitForTimeout(1000);
check((await cellCount()) > before, "the menu really added a row", `${before} → ${await cellCount()}`);
const withRow = await cellCount();
await p.locator(".ProseMirror table td").first().click({ button: "right" });
await p.waitForTimeout(600);
await p.getByText("Delete row").click();
await p.waitForTimeout(1000);
check((await cellCount()) < withRow, "and removing one works too", `${withRow} → ${await cellCount()}`);
// Outside a table the browser's own menu must stay available.
await p.locator(".ProseMirror p").first().click({ button: "right" });
await p.waitForTimeout(500);
check(
(await p.getByText("Insert row below").count()) === 0,
"right-click outside a table shows no table menu"
);
await p.keyboard.press("Escape");
await p.waitForTimeout(400);
/* ── a second note to link to ── */
await setTitle(p, "Quelle");
await createNote(p);
await setTitle(p, "Ziel");
await typeInEditor(p, "Ich bin das Ziel.");
await p.locator("button").filter({ hasText: "Quelle" }).first().click();
await p.waitForSelector(".ProseMirror", { timeout: 15000 });
await p.waitForTimeout(1200);
/* ── note link via the @ menu ── */
// Control+End lands inside the task list, where a plain Enter would only make
// another task. A second Enter on the empty item leaves the list, so the link
// goes into a paragraph of its own and the checked task above stays untouched.
await p.locator(".ProseMirror").click();
await p.keyboard.press("Control+End");
await p.keyboard.press("Enter");
await p.keyboard.press("Enter");
await p.waitForTimeout(300);
await p.keyboard.type("@Ziel");
await p.waitForTimeout(1400);
const menuOpen = (await p.locator("text=Ziel").count()) > 0;
check(menuOpen, "@ opens the link menu");
await p.keyboard.press("Enter");
await p.waitForTimeout(1800);
const linkCount = await p.locator('.ProseMirror [data-note-link], .ProseMirror .note-link').count();
check(linkCount > 0, "note link inserted into the document", `${linkCount} found`);
await p.screenshot({ path: `${OUT}/editor-link.png` });
/* ── the server learned about it ── */
await p.waitForTimeout(1500);
await p.locator("button").filter({ hasText: "Ziel" }).first().click();
await p.waitForSelector(".ProseMirror", { timeout: 15000 });
await p.waitForTimeout(2000);
const backlink = await p.locator("text=Quelle").count();
check(backlink > 0, "the target shows a backlink to the source");
/* ── everything survives a cold reload ── */
await reopenNote(p, "Quelle");
check((await p.locator(".ProseMirror table").count()) > 0, "table survived a reload");
check(
(await p.locator('.ProseMirror [data-note-link], .ProseMirror .note-link').count()) > 0,
"note link survived a reload"
);
check((await p.locator('.ProseMirror input[type="checkbox"]').count()) > 0, "task list survived a reload");
check(
(await p.locator('.ProseMirror input[type="checkbox"]:checked').count()) === 1,
"the ticked task stayed ticked, and only that one"
);
check(
(await p.locator(".ProseMirror li").filter({ hasText: "Ziel" }).count()) === 0,
"the note link went into a paragraph, not into the task list"
);
check((await p.locator(".ProseMirror").innerText()).includes("Grundtext"), "plain text survived a reload");
await p.screenshot({ path: `${OUT}/editor-after-reload.png` });
/* ── the sheet is the right size from the first frame ──
* It was not. The stage starts at the canonical page, 1600px tall, and the
* measurement that pulls it back to the pane ran an ordinary effect — a frame
* too late. Every note opened by flashing a long narrow sheet and then
* righting itself. Sampled every frame, because one frame is all it was. */
const settled = await p.locator("[data-page-sheet]").first().evaluate((el) => el.getBoundingClientRect().height);
await p.evaluate(() => {
window.__tall = 0;
const tick = () => {
const el = document.querySelector("[data-page-sheet]");
if (el) window.__tall = Math.max(window.__tall, el.getBoundingClientRect().height);
window.__raf = requestAnimationFrame(tick);
};
tick();
});
await p.locator("button").filter({ hasText: "Ziel" }).first().click();
await p.waitForSelector(".ProseMirror", { timeout: 15000 });
await p.waitForTimeout(1500);
const tallest = await p.evaluate(() => {
cancelAnimationFrame(window.__raf);
return window.__tall;
});
check(
tallest <= settled * 1.15,
"switching notes never draws the sheet at the wrong size, not even for a frame",
`settled ${Math.round(settled)}, tallest ${Math.round(tallest)}`
);
await reopenNote(p, "Quelle");
/* ── how many pages the writing fills ── */
// Pages are a reading aid, not a change to the document: the note stays one
// body, and Markdown has no page. The height of one is the same 3:4 the canvas
// uses, so "page 2" means the same thing writing or drawing.
await createNote(p);
await setTitle(p, "Langer Text");
check(
(await p.locator("[data-note-position]").innerText()).includes("of 1"),
"a fresh note is one page",
await p.locator("[data-note-position]").innerText()
);
await p.locator(".ProseMirror").click();
// Enough for several pages. With only two, the middle of the window never
// leaves the first one — a window is shorter than a page — so the check would
// be testing arithmetic rather than behaviour.
for (let i = 0; i < 220; i++) {
await p.keyboard.type(`Absatz ${i + 1}: Dichtungen altern durch Wärme.`);
await p.keyboard.press("Enter");
}
await p.waitForTimeout(3500);
const position = () => p.locator("[data-note-position]").innerText();
const scrollTo = async (fraction) => {
await p.evaluate((f) => {
const pm = document.querySelector(".ProseMirror");
let el = pm.parentElement;
while (el && getComputedStyle(el).overflowY !== "auto") el = el.parentElement;
el.scrollTop = (el.scrollHeight - el.clientHeight) * f;
}, fraction);
await p.waitForTimeout(900);
return position();
};
const total = Number((await position()).match(/of (\d+)/)?.[1] || 0);
check(total > 2, "writing past the end of a page makes more of them", await position());
check(
(await p.locator("[data-note-position]").count()) === 1,
"and the count is shown the way the PDF reader shows it"
);
check((await scrollTo(0)).startsWith("Page 1"), "at the top it says page one", await position());
const nearEnd = await scrollTo(0.9);
check(
Number(nearEnd.match(/Page (\d+)/)[1]) > 1,
"scrolling down moves the count on",
nearEnd
);
/* ── the paper ──
* Each page is a sheet of its own with a gap after it, the way the PDF reader
* draws its pages. The gap is the part that has to be got right: the browser
* knows nothing about our sheets and would lay a line of text straight across
* it, so the text flow holds it open — see editor/pagination.js. */
check(
(await p.locator("[data-page-sheet]").count()) === Number((await position()).match(/of (\d+)/)[1]),
"there is a sheet of paper for every page",
`${await p.locator("[data-page-sheet]").count()} sheets, ${await position()}`
);
const straddling = await p.evaluate(() => {
const pm = document.querySelector(".ProseMirror");
const top = pm.getBoundingClientRect().top;
const sheets = [...document.querySelectorAll("[data-page-sheet]")].map((el) => el.getBoundingClientRect());
const gaps = sheets.slice(0, -1).map((r, i) => ({ from: r.bottom - top, to: sheets[i + 1].top - top }));
const bad = [];
for (const el of pm.children) {
if (el.classList.contains("ne-page-spacer")) continue;
const r = el.getBoundingClientRect();
if (r.height === 0) continue;
for (const g of gaps) {
if (r.bottom - top > g.from + 0.5 && r.top - top < g.to - 0.5) bad.push((el.textContent || "").slice(0, 30));
}
}
return bad;
});
check(straddling.length === 0, "and no line of text is cut in half by a gap", straddling.join(" | "));
check(
(await p.locator(".ne-page-spacer").count()) > 0,
"which takes holding the gap open inside the text itself"
);
/* The placeholder extension styles `[contenteditable]:empty::before`, and a
* spacer is exactly that. "Start writing..." was printed at the foot of every
* page that had a break after it. */
check(
await p.evaluate(() =>
[...document.querySelectorAll(".ne-page-spacer")].every(
(el) => getComputedStyle(el, "::before").content === "none"
)
),
"and the gap stays blank — no placeholder printed at the foot of the page"
);
/* The note is still one document — pages are drawn, not stored. */
const stored = (await api(p, "GET", "/notes")).body[0];
const body = (await api(p, "GET", `/notes/${stored.id}`)).body.content;
check(!/"type"\s*:\s*"page"/.test(body), "no page ever reaches the stored document");
check(!/page-spacer/.test(body), "and neither does anything holding a gap open");
/* ── alignment ── */
// Left is the default and is never written into the document, so an untouched
// paragraph reads as left without carrying an attribute for it.
await createNote(p);
await setTitle(p, "Ausrichtung");
await typeInEditor(p, "Diese Zeile wandert.");
check(
(await p.locator('[data-align="left"]').count()) === 1,
"the toolbar offers alignment"
);
await p.locator('[data-align="center"]').click();
await p.waitForTimeout(1800);
check(
(await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)) === "center",
"choosing centre centres the paragraph"
);
await reopenNote(p, "Ausrichtung");
check(
(await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)) === "center",
"and it survives a reload, so it is in the document and not on the screen"
);
const alignedBody = (await api(p, "GET", "/notes")).body;
const alignedNote = alignedBody.find((n) => n.title === "Ausrichtung");
const alignedContent = (await api(p, "GET", `/notes/${alignedNote.id}`)).body.content;
check(/"textAlign"\s*:\s*"center"/.test(alignedContent), "stored on the paragraph itself");
await p.locator('[data-align="left"]').click();
await p.waitForTimeout(1800);
check(
(await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)) === "start",
"and going back to left leaves no alignment behind",
await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)
);
/* ── a quote that holds more than a sentence ──
* Markdown lets a quote hold whole blocks, and so does the editor. What it did
* not do was give them any room: inside a quote nothing had a margin, so a
* heading, a list and a closing line stood jammed together while the items of
* the list were further apart than the blocks around them. */
await createNote(p);
await setTitle(p, "Zitat");
await p.locator(".ProseMirror").click();
await p.keyboard.type("> ### Quartalszahlen");
await p.keyboard.press("Enter");
await p.keyboard.type("- Umsatz");
await p.keyboard.press("Enter");
await p.keyboard.type("Gewinn");
await p.keyboard.press("Enter");
await p.keyboard.press("Enter");
await p.keyboard.type("Alles nach Plan.");
await p.waitForTimeout(1500);
const quoted = await p.evaluate(() =>
[...(document.querySelector(".ProseMirror blockquote")?.children || [])].map((el) => {
const r = el.getBoundingClientRect();
return { tag: el.tagName, top: r.top, bottom: r.bottom };
})
);
check(
quoted.map((c) => c.tag).join(",") === "H3,UL,P",
"a quote holds a heading, a list and a paragraph",
quoted.map((c) => c.tag).join(",")
);
const gaps = quoted.slice(1).map((c, i) => Math.round(c.top - quoted[i].bottom));
check(gaps.every((g) => g >= 6), "with room between them, as outside a quote", gaps.join(" | "));
/* And the same divide when it leaves as Markdown: written on consecutive
lines, every other reader folds that closing line into the last bullet. */
const quoteNote = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Zitat");
const quoteDoc = (await api(p, "GET", `/notes/${quoteNote.id}`)).body.content;
check(/blockquote/.test(quoteDoc), "and it is stored as a quote");
/* ── the typeface follows the account ── */
await p.getByTitle("Account").click();
await p.waitForTimeout(800);
await p.locator('[data-note-font="mono"]').click();
await p.waitForTimeout(1500);
await p.keyboard.press("Escape");
await p.waitForTimeout(1200);
check(
/mono/i.test(await p.locator(".ProseMirror").evaluate((e) => getComputedStyle(e).fontFamily)),
"choosing a typeface changes the note text",
await p.locator(".ProseMirror").evaluate((e) => getComputedStyle(e).fontFamily)
);
// On the account, not in this browser — so it is there on the next device.
const me = await api(p, "GET", "/auth/me");
check(me.body?.preferences?.noteFont === "mono", "and is stored on the account", JSON.stringify(me.body?.preferences));
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(3000);
// A reload lands on the dashboard, so the notebook has to be opened again.
await p.locator("button").filter({ hasText: "Editor Book" }).first().click();
await p.waitForTimeout(1500);
await p.locator("[data-note-row]").first().click();
await p.waitForTimeout(2500);
check(
/mono/i.test(await p.locator(".ProseMirror").evaluate((e) => getComputedStyle(e).fontFamily)),
"and survives a reload"
);
/* ── light or dark is a setting, and it belongs to the account ──
* It used to be a sun in the corner of the sidebar: reached for by accident,
* forgotten by the next device, and gone on every reload. */
const shade = () =>
p.evaluate(() => getComputedStyle(document.querySelector("#root > div") || document.body).backgroundColor);
const wasDark = await shade();
await p.getByTitle("Account").click();
await p.waitForTimeout(900);
check(
(await p.locator("[data-theme-option]").count()) === 2,
"the account panel offers light and dark"
);
await p.locator('[data-theme-option="light"]').click();
await p.waitForTimeout(1500);
await p.keyboard.press("Escape");
await p.waitForTimeout(900);
check((await shade()) !== wasDark, "choosing one repaints the app", `${wasDark} → ${await shade()}`);
const themed = await api(p, "GET", "/auth/me");
check(
themed.body?.preferences?.theme === "light",
"and it is stored on the account, not in this browser",
JSON.stringify(themed.body?.preferences)
);
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(3000);
check((await shade()) !== wasDark, "and it is still there after a reload", await shade());
/* The sun in the sidebar is gone with it — two switches for one setting is one
too many, and that corner is for places to go. Counted where it stood: the
account button's own row, which used to hold two. */
const cornerButtons = await p
.locator('[title="Account"]')
.evaluate((el) => el.parentElement.querySelectorAll("button").length);
check(cornerButtons === 1, "and the sidebar corner holds nothing but the account", `${cornerButtons} buttons`);
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
await session?.cleanup();
const code = report();
await browser.close();
process.exit(code);
}