/* Version history: the state before a write is kept, and restoring one brings * the old text back into the open editor without a reload. */ import { OUT, api, check, createNote, createNotebook, launch, report, setTitle, typeInEditor, withFreshUser, } from "./lib.mjs"; const LONG = "Der ausführliche erste Entwurf, der später verloren gehen soll. ".repeat(6); const browser = await launch(); let session; try { session = await withFreshUser(browser, "hist"); const p = session.page; await createNotebook(p, "Verlauf Buch"); await createNote(p); await setTitle(p, "Mit Verlauf"); await typeInEditor(p, LONG); check((await p.locator(".ProseMirror").innerText()).includes("erste Entwurf"), "the long draft is in the note"); /* The note by its name, and not before the server has it. * * This used to take the first row of `/api/notes` the moment the text was * typed, which is two assumptions: that the first save had already landed, and * that the newest note is the one meant. Under load neither holds — the id * belonged to another note, and the version this suite is about was then * looked for in the wrong place. */ const id = await p.evaluate(async () => { const mine = async () => { const r = await fetch("/api/notes", { headers: { Authorization: "Bearer " + localStorage.getItem("inknote_token") }, }); return (await r.json()).find((n) => n.title === "Mit Verlauf"); }; for (let tries = 0; tries < 20; tries++) { const note = await mine(); if (note) return note.id; await new Promise((r) => setTimeout(r, 500)); } return null; }); check(!!id, "the note reached the server", String(id)); /* Replace it with a stub. Shrinking a note by more than half is treated as a * large deletion and bypasses the 5-minute coalescing window, so a version is * kept immediately — exactly the case worth being able to undo. */ await p.locator(".ProseMirror").click(); await p.keyboard.press("Control+A"); await p.keyboard.type("Kurz."); await p.waitForTimeout(3000); check((await p.locator(".ProseMirror").innerText()).trim().startsWith("Kurz"), "the note now shows the stub"); const versions = await api(p, "GET", `/notes/${id}/versions`); check(versions.status === 200 && versions.body.length > 0, "a version was kept", `${versions.body?.length} versions`); check( versions.body.some((v) => v.reason === "large-deletion"), "the large deletion was recorded as such", JSON.stringify(versions.body?.map((v) => v.reason)) ); /* ── restore through the interface ── */ await p.getByTitle("Version history").click(); await p.waitForTimeout(1800); check((await p.locator("text=Restore this version").count()) > 0, "the history dialog opens"); await p.screenshot({ path: `${OUT}/history-open.png` }); const preview = await p.locator("text=erste Entwurf").count(); check(preview > 0, "the preview shows the lost text"); await p.getByRole("button", { name: "Restore this version" }).click(); await p.waitForTimeout(2500); check((await p.locator("text=Restore this version").count()) === 0, "the dialog closed after restoring"); check( (await p.locator(".ProseMirror").innerText()).includes("erste Entwurf"), "the editor shows the restored text without a reload" ); /* ── and it really went to the server ── */ await p.reload({ waitUntil: "networkidle" }); await p.waitForTimeout(2500); await p.locator("button").filter({ hasText: "Mit Verlauf" }).first().click(); await p.waitForSelector(".ProseMirror", { timeout: 15000 }); await p.waitForTimeout(2000); check( (await p.locator(".ProseMirror").innerText()).includes("erste Entwurf"), "the restored text survived a reload" ); /* Restoring is itself a change, so the stub must now be recoverable too. */ const after = await api(p, "GET", `/notes/${id}/versions`); check( after.body.some((v) => v.reason === "before-restore"), "restoring kept a version of what it replaced", JSON.stringify(after.body?.map((v) => v.reason)) ); await p.screenshot({ path: `${OUT}/history-restored.png` }); } catch (e) { check(false, "suite ran to the end", e.message); } finally { await session?.cleanup(); const code = report(); await browser.close(); process.exit(code); }