They lived in a scratch directory under /tmp, which was cleared overnight; four of the five were simply gone. Whatever they were worth, they were worth nothing kept there. Rewritten against a shared lib.mjs. The old ones registered their users through the sign-up form, which no longer exists — each suite now creates a throwaway account through the admin API and deletes it afterwards, so a run leaves the database as it found it. The previous generation left nine accounts behind; those are gone too. Three test hooks went into the app, each of which earns its place beyond testing: aria-label on the note title (the sidebar search box is also a text input, and a screen reader could not tell them apart either), data-note-row on list rows, data-user on admin rows. Two suites were flaky in ways worth naming, because both were the test misreading the app rather than the app misbehaving: - ticking a task then inserting a note link put the link inside the task list, since Control+End lands there. Verified against the server that the tick itself persists correctly; the suite now leaves the list first and asserts where the link ended up. - the note-title lookup matched the sidebar's Trash entry, which wears the same emoji as a note row. 56 checks across five suites, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
98 lines
3.5 KiB
JavaScript
98 lines
3.5 KiB
JavaScript
/* 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");
|
|
|
|
const id = await p.evaluate(async () => {
|
|
const r = await fetch("/api/notes", {
|
|
headers: { Authorization: "Bearer " + localStorage.getItem("inknote_token") },
|
|
});
|
|
return (await r.json())[0].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);
|
|
}
|