Files
InkNote/e2e/organise.mjs
T
marcandClaude Opus 5 0a7cf26b28 Group the two visibility switches, and put the drawing tools back where they belong
"Zeichnung" and "Randnotizen" are two halves of one question — how much of your
own work do you want to see right now — and they sat at opposite ends of the
toolbar, each beside the tool that made them. They are now one pair in one shape
at the right-hand end. Each still appears only once there is something of its
kind to hide, so the pair can be lopsided, which is honest.

The drawing tools had drifted past the pin controls as those were added, so
switching to Zeichnen made them appear somewhere other than where the eye
already was. They follow the button that turns them on again.

A separator no longer carries a ×. It sat a few pixels from the label you click
to rename, putting the gesture that tidies the list next to the one that destroys
part of it — so removal is a right-click behind a confirmation that says the
notes are not affected, which is how everything else in that list is acted on.

218 unit tests, 237 browser checks across eleven suites. The toolbar checks
compare measured positions rather than presence, since the complaint was about
where things were, not whether they existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 01:24:25 +02:00

374 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Sorting, importing, and the two dialogs that replaced a guess and a prompt.
*
* The load-bearing claim here is the one about sorting: an arrangement the user
* made by hand has to survive being looked away from. Searching imposes its own
* order, and the danger is that going back to "own order" shows something
* nobody arranged — because the reorder was computed against a filtered list.
* That is checked by arranging, searching, and coming back.
*/
import {
api,
check,
createNote,
createNotebook,
launch,
report,
setTitle,
typeInEditor,
withFreshUser,
} from "./lib.mjs";
const browser = await launch();
let session;
try {
session = await withFreshUser(browser, "organise");
const p = session.page;
const titles = () =>
p.locator("[data-note-row]").evaluateAll((rows) =>
rows.map((r) => r.querySelector("span:nth-child(2)")?.textContent?.trim() || "")
);
const sortBy = async (mode) => {
await p.locator("[data-sort-button]").click();
await p.waitForTimeout(300);
await p.locator(`[data-sort-option="${mode}"]`).click();
await p.waitForTimeout(900);
};
/* ── three notes, made in a known order ── */
await createNotebook(p, "Ordnung");
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(700);
for (const name of ["Alpha", "Beta", "Gamma"]) {
await createNote(p);
await setTitle(p, name);
await typeInEditor(p, `Inhalt von ${name}. `);
await p.waitForTimeout(700);
}
await p.waitForTimeout(1200);
check((await titles()).length === 3, "three notes to arrange", (await titles()).join(", "));
/* ── the built-in orders ── */
await sortBy("updated");
check((await titles())[0] === "Gamma", "newest first by default", (await titles()).join(", "));
await sortBy("oldest");
check((await titles())[0] === "Alpha", "oldest first reverses it", (await titles()).join(", "));
await sortBy("title");
check((await titles()).join(",") === "Alpha,Beta,Gamma", "A–Z sorts by title", (await titles()).join(", "));
/* ── it is remembered, not just applied ── */
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(1500);
check(
(await titles()).join(",") === "Alpha,Beta,Gamma",
"and it survives a reload, because it belongs to the notebook",
(await titles()).join(", ")
);
const notebooks = await api(p, "GET", "/notebooks");
check(notebooks.body[0].sort_mode === "title", "the notebook itself carries the choice", String(notebooks.body[0].sort_mode));
/* ── the user's own order ── */
await sortBy("manual");
const beforeDrag = await titles();
check(beforeDrag.length === 3, "manual order starts from the notes it has", beforeDrag.join(", "));
// Drag the last note onto the first. The moves are stepped: a single jump can
// land before the drag has started, and the drop indicator shifts the rows as
// it appears, so the pointer has to arrive gradually.
const drag = async (fromLoc, toLoc) => {
const from = await fromLoc.boundingBox();
const to = await toLoc.boundingBox();
await p.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await p.mouse.down();
await p.mouse.move(from.x + from.width / 2, from.y + from.height / 2 - 12, { steps: 4 });
await p.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 });
await p.waitForTimeout(300);
await p.mouse.move(to.x + to.width / 2, to.y + to.height / 2 + 2, { steps: 4 });
await p.mouse.up();
await p.waitForTimeout(2000);
};
const rows = p.locator("[data-note-row]");
await drag(rows.nth(2), rows.nth(0));
const arranged = await titles();
check(
arranged[0] === beforeDrag[2],
"dragging a note to the top puts it there",
`${beforeDrag.join(",")} → ${arranged.join(",")}`
);
/* The point of the whole feature. */
await p.getByPlaceholder("Search notes...").fill("Beta");
await p.waitForTimeout(1200);
const searched = await titles();
check(searched.length < 3, "searching narrows the list", searched.join(", "));
await p.getByPlaceholder("Search notes...").fill("");
await p.waitForTimeout(1500);
check(
(await titles()).join(",") === arranged.join(","),
"and clearing the search shows exactly the arrangement again",
`${arranged.join(",")} vs ${(await titles()).join(",")}`
);
/* Reordering must not look like editing: a dragged note keeps its place in
"last changed", or the two orders would fight each other. */
await sortBy("updated");
check(
(await titles())[0] === "Gamma",
"a dragged note did not become the most recently changed one",
(await titles()).join(", ")
);
await sortBy("manual");
await p.waitForTimeout(800);
/* ── the drop indicator ── */
// It used to be a border on the row, set beside a `border` shorthand — which
// left a permanent 2px black line behind once it was cleared.
const strayLines = await p.locator("[data-drop-line]").count();
check(strayLines === 0, "no drop indicator is showing while nothing is being dragged", String(strayLines));
const borders = await p
.locator("[data-note-row]")
.evaluateAll((rows) => rows.map((r) => getComputedStyle(r).borderTopWidth));
check(
borders.every((b) => b === "1px"),
"and no row has grown a stray top border",
borders.join(", ")
);
/* ── separators ── */
await p.locator("[data-add-separator]").click();
await p.waitForTimeout(1800);
check((await p.locator("[data-separator]").count()) === 1, "a separator can be added");
const label = p.getByLabel("Separator label");
await p.locator("[data-separator] button").first().click();
await p.waitForTimeout(300);
await label.fill("Wichtig");
await label.press("Enter");
await p.waitForTimeout(1800);
check(
/wichtig/i.test(await p.locator("[data-separator]").innerText()),
"and named",
await p.locator("[data-separator]").innerText()
);
// It belongs to the notebook, so it has to survive a reload like the order does.
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(1800);
check(
/wichtig/i.test(await p.locator("[data-separator]").innerText()),
"a separator survives a reload, because it belongs to the notebook"
);
const withSeparator = await titles();
check(
withSeparator.join(",") === arranged.join(","),
"and adding one did not disturb the arrangement",
`${arranged.join(",")} vs ${withSeparator.join(",")}`
);
// Separators only mean something in a hand-made order; in "newest first" the
// notes would slide past the line on every edit.
await sortBy("updated");
check((await p.locator("[data-separator]").count()) === 0, "it is hidden in the other orders");
await sortBy("manual");
await p.waitForTimeout(800);
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in");
/* Removing is a right-click behind a confirmation. The × used to sit a few
pixels from the label you click to rename, so tidying the list and
destroying part of it were neighbouring gestures. */
check(
(await p.locator('[data-separator] button[aria-label="Remove separator"]').count()) === 0,
"there is no delete button sitting next to the rename target"
);
await p.locator("[data-separator]").click({ button: "right" });
await p.waitForTimeout(600);
await p.getByText("Trenner entfernen").click();
await p.waitForTimeout(600);
check(
/Notizen bleiben/.test(await p.locator("body").innerText()),
"right-clicking it asks first, and says the notes are not affected"
);
await p.getByRole("button", { name: "Entfernen" }).click();
await p.waitForTimeout(2000);
check((await p.locator("[data-separator]").count()) === 0, "and then it is gone");
check((await titles()).length === 3, "with every note still there", (await titles()).join(", "));
/* ── exporting a note and importing it back ── */
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).first().click();
await p.waitForTimeout(1200);
await p.locator(".ProseMirror").click();
await p.keyboard.press("Control+End");
await p.keyboard.press("Enter");
// Through the toolbar rather than by typing "- [x] ": inside a bullet item
// that sequence stays literal text, which is a quirk of the editor's input
// rules and not what this suite is about.
await p.getByTitle("Task list").click();
await p.waitForTimeout(400);
await p.keyboard.type("erledigt");
await p.waitForTimeout(600);
await p.locator('.ProseMirror input[type="checkbox"]').first().click();
await p.waitForTimeout(2000);
const exported = await p.evaluate(async () => {
const captured = [];
const realCreate = URL.createObjectURL.bind(URL);
URL.createObjectURL = (blob) => {
captured.push(blob);
return realCreate(blob);
};
window.__captured = captured;
HTMLAnchorElement.prototype.click = function () {};
return true;
});
check(exported, "downloads can be captured");
await p.getByTitle("Export").click();
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await p.waitForTimeout(600);
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
await p.waitForTimeout(2500);
const markdown = await p.evaluate(async () => {
const blob = window.__captured?.[0];
return blob ? await blob.text() : "";
});
check(markdown.includes("- [x] erledigt"), "the export carries the ticked box", JSON.stringify(markdown.slice(-60)));
check(markdown.startsWith("# Beta"), "and the title as a heading");
await p.locator("[data-export-dialog] button").filter({ hasText: /Schließen|Fertig|Close/ }).first().click().catch(() => {});
await p.keyboard.press("Escape");
await p.waitForTimeout(600);
/* Import it back as a second note. A real file chooser cannot be driven, so
the file is handed to the input directly — the code under test is what
happens after a file is chosen. */
const before = (await api(p, "GET", "/notes")).body.length;
await p.evaluate((md) => {
const input = document.createElement("input");
input.type = "file";
document.body.appendChild(input);
const dt = new DataTransfer();
dt.items.add(new File([md], "Beta.md", { type: "text/markdown" }));
input.files = dt.files;
window.__importInput = input;
}, markdown);
const chooser = p.waitForEvent("filechooser");
await p.locator("[data-import-button]").click();
const fc = await chooser;
await fc.setFiles({ name: "Beta.md", mimeType: "text/markdown", buffer: Buffer.from(markdown) });
await p.waitForTimeout(4000);
const importedText = await p.locator("body").innerText();
check(/importiert/i.test(importedText), "the import reports what it did", importedText.slice(0, 200).replace(/\n/g, " "));
await p.getByRole("button", { name: /Fertig/ }).click();
await p.waitForTimeout(2500);
const after = (await api(p, "GET", "/notes")).body.length;
check(after === before + 1, "one note more than before", `${before} → ${after}`);
const importedNote = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Beta" && n.id);
check(!!importedNote, "the imported note kept its title from the heading");
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).last().click();
await p.waitForTimeout(1800);
const boxes = await p.locator('.ProseMirror input[type="checkbox"]').count();
check(boxes > 0, "the task box came back as a real checkbox, not as text", String(boxes));
const checked = await p.locator('.ProseMirror input[type="checkbox"]:checked').count();
check(checked > 0, "and it came back ticked", String(checked));
/* ── the link dialog, which used to be a browser prompt ── */
await p.locator(".ProseMirror").click();
await p.keyboard.press("Control+End");
await p.keyboard.type("Zielwort");
await p.keyboard.down("Shift");
for (let i = 0; i < 8; i++) await p.keyboard.press("ArrowLeft");
await p.keyboard.up("Shift");
await p.getByTitle("Web link").click();
await p.waitForTimeout(600);
const field = p.getByLabel("Link address");
check(await field.isVisible(), "the link dialog is a real dialog in the page");
await field.fill("javascript:alert(1)");
await p.getByRole("button", { name: /Einfügen/ }).click();
await p.waitForTimeout(400);
check(
await field.isVisible(),
"a javascript: address is refused rather than inserted"
);
await field.fill("example.com");
await p.getByRole("button", { name: /Einfügen/ }).click();
await p.waitForTimeout(1800);
const href = await p.locator(".ProseMirror a[href]").first().getAttribute("href");
check(href === "https://example.com", "a bare domain is taken as https", String(href));
/* ── which notebook, when there is no notebook ── */
await createNotebook(p, "Zweites Buch");
await p.waitForTimeout(800);
await p.locator("button").filter({ hasText: "All Notes" }).first().click();
await p.waitForTimeout(900);
// "All Notes" is a question asked of every notebook, not a place — there is
// nothing for a hand-made arrangement to belong to.
await p.locator("[data-sort-button]").click();
await p.waitForTimeout(400);
check(
(await p.locator('[data-sort-option="manual"]').count()) === 0,
"no hand-made order is offered outside a notebook"
);
check(
(await p.locator('[data-sort-option="updated"]').count()) === 1,
"but the derived orders still are"
);
await p.keyboard.press("Escape");
await p.locator("h2").first().click();
await p.waitForTimeout(500);
await p.getByTitle("New Note").click();
await p.waitForTimeout(900);
const books0 = (await api(p, "GET", "/notebooks")).body;
const picker = p.locator("[data-notebook-option]");
check((await picker.count()) === 2, "creating a note from All Notes asks where it should go", String(await picker.count()));
// By name, not by position: two notebooks created without dragging share a
// sort order, so "the last one" is not a stable way to mean a particular book.
const target = books0.find((b) => b.name === "Zweites Buch");
await p.locator(`[data-notebook-option="${target.id}"]`).click();
await p.getByRole("button", { name: /Weiter/ }).click();
await p.waitForTimeout(2500);
// By title, not by position: the list is ordered by a timestamp with one
// second of resolution, and several notes here share that second.
const fresh = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Untitled Note");
check(
fresh?.notebook_id === target.id,
"and it lands in the notebook that was chosen, not the first one",
`${fresh?.notebook_id} vs ${target.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);
}