Files
InkNote/e2e/chapters.mjs
T
marcandClaude Opus 5 b2a5f051fe A filed note stays filed, and the handles are asked for
Three things that all came from chapters being thought through too late.

A note put into a chapter came back out of it a few seconds later. The
server had it right the whole time; every sync rebuilds the local copy
from the server's answer, and the function doing that never carried
chapter_id — so each sync handed back a note belonging to no chapter and
the notebook took it in again. The bin's query left the column out too,
which would have unfiled anything restored from it.

A separator drawn in a notebook vanished when a chapter was opened.
"Which chapter is open" and "which list is on screen" were one value.
Opened beside or below, the list still shows the notebook — but
everything around it had switched to the chapter, so the notebook's own
separators were filtered away and a drag among its rows was measured
against the chapter's notes, found nothing, and did nothing.

And where a new note lands is now visible and yours to choose: pressing
in either place makes it the one being worked in, marked with a line
along its top and the word "here", and the + button names it.

Rearranging becomes a mode, switched on beside the sort menu — until
then no row wears a grip and none can be dragged. Filing a note is back
in the row's own menu, where starring and exporting already are; the
button that had appeared in every row is gone with its dialog. The
account panel closes with Escape.

Thirteen suites green: 335 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:59:29 +02:00

278 lines
12 KiB
JavaScript

/* Chapters — folders inside a notebook.
*
* The claim the feature rests on is the one under test: a note filed into a
* chapter is *in* it and not also loose in the notebook, and removing a chapter
* never removes what it held. Both look fine on screen while being wrong on the
* server, so the server is asked directly.
*/
import {
api,
check,
createNote,
createNotebook,
launch,
report,
setTitle,
withFreshUser,
} from "./lib.mjs";
const browser = await launch();
let session;
try {
session = await withFreshUser(browser, "chapters");
const p = session.page;
const titles = () =>
p.locator("[data-note-row] [data-note-title]").evaluateAll((els) => els.map((e) => e.textContent.trim()));
const addChapter = async (name) => {
await p.locator("[data-add-chapter]").click();
await p.waitForTimeout(600);
await p.getByLabel("Chapter name").fill(name);
await p.getByRole("button", { name: "Create", exact: true }).click();
await p.waitForTimeout(2000);
};
/* Filing a note is one of the things a row can have done to it, and they all
live in the row's own menu. A button for this one on every row put a second
icon beside every title, for something done once in a note's life. */
const moveTo = async (noteTitle, target) => {
const row = p.locator("[data-note-row]").filter({ hasText: noteTitle }).first();
await row.click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.locator("[data-submenu-toggle]").filter({ hasText: "Move to" }).first().click();
await p.waitForTimeout(500);
await p.locator("[data-submenu] button").filter({ hasText: target }).first().click();
await p.waitForTimeout(2500);
};
const openNotebook = async () => {
await p.locator("button").filter({ hasText: "Haus" }).first().click();
await p.waitForTimeout(1200);
};
await createNotebook(p, "Haus");
await p.locator("button").filter({ hasText: "Haus" }).first().click();
await p.waitForTimeout(700);
for (const name of ["Zählerstand", "Stromrechnung"]) {
await createNote(p);
await setTitle(p, name);
await p.waitForTimeout(600);
}
await p.waitForTimeout(1000);
/* ── making one ── */
check((await p.locator("[data-add-chapter]").count()) === 1, "a notebook can be given a chapter");
await addChapter("Rechnungen");
check((await p.locator("[data-chapter-row]").count()) === 1, "and it appears in the list");
const server = (await api(p, "GET", "/chapters")).body;
check(server.length === 1 && server[0].name === "Rechnungen", "the server has it", JSON.stringify(server));
/* ── filing a note ── */
check(
(await p.locator("[data-move-note]").count()) === 0,
"filing is in the row's menu, not a button standing in every row",
String(await p.locator("[data-move-note]").count())
);
await moveTo("Stromrechnung", "Rechnungen");
check(
(await titles()).join(",") === "Zählerstand",
"a note filed into a chapter leaves the notebook's own list",
(await titles()).join(", ")
);
/* ── and stays filed ──
* It did not. The server had it right, but the sync that follows replaced
* every local note with one belonging to no chapter — so about five seconds
* after being filed, the note walked back out into the notebook by itself.
* A reload is the same journey: the mirror is rebuilt from the server. */
await p.reload();
await p.waitForTimeout(4000);
await openNotebook();
check(
(await titles()).join(",") === "Zählerstand",
"and is still in it after the next sync, not back in the notebook",
(await titles()).join(", ")
);
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
const inChapter = () =>
p.locator("[data-chapter-note]").evaluateAll((els) => els.map((e) => e.textContent.trim()));
check((await inChapter()).join(",").includes("Stromrechnung"), "and is inside the chapter", (await inChapter()).join(", "));
check((await p.locator("[data-chapter-close]").count()) === 1, "with a way to close it again");
const filed = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Stromrechnung");
check(!!filed?.chapter_id, "the server filed it too", String(filed?.chapter_id));
/* A note made while a chapter is open belongs to it, without being moved. */
await createNote(p);
await setTitle(p, "Wasserrechnung");
await p.waitForTimeout(1500);
const made = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Wasserrechnung");
check(made?.chapter_id === filed?.chapter_id, "a note made while it is open is filed there", String(made?.chapter_id));
/* ── which of the two am I in ──
* Beside the list, a chapter does not replace the notebook: both are on
* screen at once. A new note used to go into the chapter however far away the
* user had since clicked, and nothing said which of the two was meant. */
check(
(await p.locator('[data-chapter-current="on"]').count()) === 1,
"opening a chapter makes it the place being written into, and shows it"
);
// Into the empty space under the notebook's own notes.
await p.locator("[data-note-list-body]").click({ position: { x: 140, y: 330 } });
await p.waitForTimeout(700);
check(
(await p.locator('[data-list-current="on"]').count()) === 1 &&
(await p.locator('[data-chapter-current="off"]').count()) === 1,
"clicking into the notebook puts you back in the notebook, visibly"
);
await createNote(p);
await setTitle(p, "Gartenzaun");
await p.waitForTimeout(1500);
const loose = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Gartenzaun");
check(!loose?.chapter_id, "and a note made then is the notebook's, not the chapter's", String(loose?.chapter_id));
check(
(await titles()).sort().join(",") === "Gartenzaun,Zählerstand",
"so it appears in the notebook's own list",
(await titles()).join(", ")
);
await p.locator("[data-chapter-close]").click();
await p.waitForTimeout(1200);
check(
(await titles()).sort().join(",") === "Gartenzaun,Zählerstand",
"and the notebook's own list never held the chapter's notes",
(await titles()).join(", ")
);
/* ── the two ways a chapter opens ──
* A further column beside the list, the way OneNote lays out its pages, or
* unfolded under its own row. Chosen on the account, so the answer follows to
* the phone — where there is room for neither and the chapter fills the
* screen instead. */
await addChapter("Verträge");
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
check((await p.locator("[data-chapter-column]").count()) === 1, "a chapter opens beside the list by default");
check(
(await p.locator("[data-chapter-row]").count()) === 2,
"and the notebook's own list keeps every chapter row",
String(await p.locator("[data-chapter-row]").count())
);
await p.getByTitle("Account").click();
await p.waitForTimeout(800);
await p.locator('[data-chapter-view="inline"]').click();
await p.waitForTimeout(1500);
/* Escape closes it. Only a click beside the panel did, which on one this wide
means aiming at a sliver of page. */
await p.keyboard.press("Escape");
await p.waitForTimeout(1200);
check(
(await p.locator("[data-chapter-view]").count()) === 0,
"the account panel closes with Escape, not only by clicking beside it"
);
// No second click: the chapter that was open stays open, and changing the
// setting changes only where it is shown.
check((await p.locator("[data-chapter-notes]").count()) === 1, "the other setting unfolds it under its row");
check((await p.locator("[data-chapter-column]").count()) === 0, "and there is no column then");
const me = await api(p, "GET", "/auth/me");
check(me.body?.preferences?.chapterView === "inline", "the choice is on the account, not in this browser");
/* ── a chapter is a row like any other ──
* Same level as notes and separators, so it can be dragged into place in the
* arrangement rather than being stuck above everything. */
await p.locator("[data-sort-button]").click();
await p.waitForTimeout(300);
await p.locator('[data-sort-option="manual"]').click();
await p.waitForTimeout(1200);
/* Rearranging is asked for, and until it is, no row wears a handle.
* Six dots stood beside every title at all times for something done once in a
* while, and a press meant to open a note could carry it off instead. */
check(
(await p.locator("[data-reorder-grip]").count()) === 0,
"no row wears a handle until rearranging is switched on",
String(await p.locator("[data-reorder-grip]").count())
);
await p.locator("[data-reorder-toggle]").click();
await p.waitForTimeout(600);
check(
(await p.locator("[data-reorder-grip]").count()) >= 2,
"switched on, a chapter has a grip like a note does",
String(await p.locator("[data-reorder-grip]").count())
);
await p.locator("[data-reorder-toggle]").click();
await p.waitForTimeout(600);
check((await p.locator("[data-reorder-grip]").count()) === 0, "and they go away again when it is done");
/* ── a separator drawn in the notebook belongs to the notebook ──
* Opening a chapter used to take the notebook's own separators off the screen
* with it: the list still showed the notebook, but everything around it had
* quietly switched to the chapter. A line vanished for no reason anyone could
* see, and dragging in that list wrote nothing at all. */
await p.locator("[data-add-separator]").click();
await p.waitForTimeout(2000);
check((await p.locator("[data-separator]").count()) === 1, "a separator can be drawn in the notebook");
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1500);
check(
(await p.locator("[data-separator]").count()) === 1,
"and it is still there once a chapter is opened",
String(await p.locator("[data-separator]").count())
);
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
/* ── its own colour ── */
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.locator("[data-submenu-toggle]").filter({ hasText: "Colour" }).first().click();
await p.waitForTimeout(500);
await p.locator("[data-submenu] button").filter({ hasText: "Green" }).first().click();
await p.waitForTimeout(2000);
const coloured = (await api(p, "GET", "/chapters")).body;
check(
coloured.some((c) => c.color === "#2da44e"),
"a chapter can be given its own colour",
JSON.stringify(coloured.map((c) => [c.name, c.color]))
);
/* ── a chapter is not a wastebasket ──
* Removing one must never remove what it held: the notes come back to the
* notebook, where they can be seen and filed again. */
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.getByText("Remove chapter", { exact: true }).click();
await p.waitForTimeout(700);
check(/stay/i.test(await p.locator("body").innerText()), "removing one says out loud that the notes stay");
await p.getByRole("button", { name: "Remove", exact: true }).click();
await p.waitForTimeout(2500);
check(
(await p.locator("[data-chapter-row]").count()) === 1,
"the chapter is gone, and only that one",
String(await p.locator("[data-chapter-row]").count())
);
const after = (await titles()).sort().join(",");
check(
after === "Gartenzaun,Stromrechnung,Wasserrechnung,Zählerstand",
"and every note it held is back in the notebook",
after
);
const freed = (await api(p, "GET", "/notes")).body;
check(
freed.every((n) => !n.chapter_id),
"with nothing on the server still pointing at a chapter that is gone",
JSON.stringify(freed.map((n) => [n.title, n.chapter_id]))
);
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
await session?.cleanup();
const code = report();
await browser.close();
process.exit(code);
}