/* 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. const rows = p.locator("[data-note-row]"); const last = rows.nth(2); const first = rows.nth(0); await last.hover(); await p.mouse.down(); await first.hover(); await first.hover(); // a second move, so the drop target registers await p.mouse.up(); await p.waitForTimeout(1500); 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); /* ── 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); 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); }