Files
InkNote/e2e/editor.mjs
T
marcandClaude Opus 5 4013e959d6 The same page on every screen
The text is laid out on a page of one fixed size and the sheet scaled to
the screen, so a phone breaks it exactly where a desktop does — twenty-one
lines to the page on both. The type size is computed from the page and the
line count rather than chosen.

A PDF's full screen is the browser window, not the screen, and its button
sits beside the zoom. "Recent Notes" is stamped by every write, not only by
a rename. And a PDF that will not open says which of the four reasons it is.

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

535 lines
23 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.
Pasted rather than typed: at the speed a robot types, a long note loses
keystrokes between the editor and the pagination measuring after each one,
and the page count then measures typing throughput instead of pagination.
(At any human speed nothing is lost — checked at 50 characters a second.) */
await p.keyboard.type("Absatz 1: Dichtungen altern durch Wärme.");
await p.keyboard.press("Enter");
await p.evaluate((text) => {
const dt = new DataTransfer();
dt.setData("text/plain", text);
document
.querySelector(".ProseMirror")
.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }));
}, Array.from({ length: 200 }, (_, i) => `Absatz ${i + 2}: Dichtungen altern durch Wärme.`).join("\n"));
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"
);
/* ── twenty-one lines, and the same twenty-one everywhere ──
*
* Two claims. First, a page holds the number of lines a ruled page holds —
* it held eighteen on a phone, which is what started this. Second, and the
* one that matters: the phone breaks the text in exactly the same places the
* desktop does. It did not, because the text used to be set in screen pixels:
* a 340-pixel sheet fitted nothing like what a 760-pixel one did, so the same
* note had different lines on different pages depending on what you read it
* on. The text is now laid out on a page of a fixed size and the whole sheet
* scaled, so the only thing a narrow screen changes is how big it looks.
*
* Measured in page units — every length divided by the sheet's own scale —
* because screen pixels are precisely what must not agree. */
const pageUnits = () =>
p.evaluate(() => {
const scale = Number(document.querySelector("[data-text-page-scale]").dataset.textPageScale);
const pm = document.querySelector(".ProseMirror");
const cs = getComputedStyle(pm);
const sheet = document.querySelector("[data-page-sheet]").getBoundingClientRect();
const top = pm.getBoundingClientRect().top;
const blocks = [...pm.children]
.filter((el) => !el.classList.contains("ne-page-spacer"))
.slice(0, 40)
.map((el) => {
const r = el.getBoundingClientRect();
return { y: Math.round((r.top - top) / scale), h: Math.round(r.height / scale) };
});
return {
lines:
(sheet.height / scale - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)) /
parseFloat(cs.lineHeight),
blocks,
};
});
const onDesktop = await pageUnits();
check(
Math.floor(onDesktop.lines) === 21,
"a page holds twenty-one lines",
`${onDesktop.lines.toFixed(2)} fit`
);
const phone = { width: 390, height: 844 };
const desktop = p.viewportSize();
await p.setViewportSize(phone);
await p.waitForTimeout(2000);
/* A phone shows one pane at a time, so the note may have been left behind in
the list when the window narrowed. Open it again before measuring. */
if ((await p.locator("[data-text-page-scale]").count()) === 0) await reopenNote(p, "Langer Text");
await p.waitForSelector("[data-text-page-scale]", { timeout: 15000 });
await p.waitForTimeout(3000);
const onPhone = await pageUnits();
check(
Math.floor(onPhone.lines) === 21,
"on a phone as well — the same page, not a smaller one",
`${onPhone.lines.toFixed(2)} fit`
);
const moved = onDesktop.blocks
.map((b, i) => ({ i, b, o: onPhone.blocks[i] }))
.filter(({ b, o }) => !o || Math.abs(b.y - o.y) > 1 || Math.abs(b.h - o.h) > 1)
.map(({ i, b, o }) => `#${i}: ${b.y}/${b.h} vs ${o ? `${o.y}/${o.h}` : "missing"}`);
check(
onPhone.blocks.length === onDesktop.blocks.length && moved.length === 0,
"and every paragraph falls in the same place on both, to the page unit",
moved.slice(0, 4).join(" | ")
);
await p.setViewportSize(desktop);
await p.waitForTimeout(2000);
if ((await p.locator(".ProseMirror").count()) === 0) await reopenNote(p, "Langer Text");
/* 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)
);
/* ── the marker pen and the ink ──
* Two marks the schema had no room for until now, so the thing worth checking
* is that they reach the document and come back out of it: a colour that only
* exists on screen is a colour that is lost on the next reload. */
await createNote(p);
await setTitle(p, "Farbe");
await typeInEditor(p, "Wichtig und bunt");
await p.waitForTimeout(1200);
// Select the whole line, the way somebody marks a sentence they just wrote.
await p.locator(".ProseMirror").click();
await p.keyboard.press("Control+a");
await p.locator("[data-toolbar-colour]").click();
await p.waitForTimeout(400);
check((await p.locator("[data-colour-panel]").count()) === 1, "the toolbar offers colour");
const swatches = await p.locator("[data-colour-swatch]").count();
check(swatches >= 8, "with a palette for both kinds", `${swatches} swatches`);
await p.locator('[data-colour-swatch="#fff3a3"]').click();
await p.waitForTimeout(1600);
check((await p.locator(".ProseMirror mark").count()) === 1, "a highlight lands on the selected words");
await p.keyboard.press("Control+a");
await p.locator('[data-colour-swatch="#d1242f"]').click();
await p.waitForTimeout(1800);
const inked = await p.locator(".ProseMirror mark span").first().evaluate((el) => getComputedStyle(el).color);
check(inked === "rgb(209, 36, 47)", "and a text colour on top of it", inked);
await reopenNote(p, "Farbe");
check((await p.locator(".ProseMirror mark").count()) === 1, "the highlight is still there after a reload");
check(
(await p.locator(".ProseMirror [style*=color]").count()) >= 1,
"and so is the colour of the letters"
);
/* Pressing the same colour again takes it off — the button that put it there
is the one people reach for to remove it. */
await p.locator(".ProseMirror").click();
await p.keyboard.press("Control+a");
await p.locator("[data-toolbar-colour]").click();
await p.waitForTimeout(400);
await p.locator('[data-colour-swatch="#fff3a3"]').click();
await p.waitForTimeout(1500);
check((await p.locator(".ProseMirror mark").count()) === 0, "and pressing the same colour again removes it");
/* ── 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);
}