Files
InkNote/e2e/lib.mjs
T
marcandClaude Opus 5 3acb8b66a4 Free unused attachments, and keep versions by count rather than by age
Two rules governed version retention: the newest 50 per note, and a sweep
of anything past 90 days. The second was wrong. The count already bounds
what a note can accumulate, so age only ever deleted history nothing had
replaced — and it bit hardest exactly where history is most wanted, a
note left alone for a quarter of a year losing its whole past for having
done nothing. It is off unless VERSION_RETENTION_DAYS is set.

How many versions to keep is now a notebook's own setting, falling back
to the server default when left empty. A reference notebook can keep
fifty of everything while a scratch one keeps three.

That makes freeing attachments answerable. A picture taken out of a note
cannot be deleted straight away: the versions from before still show it,
and restoring one would find a hole. It is released once no current note
and no kept version refers to it — which, with retention by count, is
when the last version holding it is pushed out.

The server cannot look for itself, bodies being encrypted, so the client
reports a note's attachments on every save as it already does for links,
and a version inherits what the note held when it was snapshotted — by
definition what that version contains.

Two things make it safe rather than merely plausible. Silence is not
"uses nothing": a note written before this existed has no rows, which
looks identical to one with no pictures, so nothing is released for a
user while any of their notes has yet to report — the prefetch that fills
the search index reports for them on its first pass. And an upload has a
row before the note using it does, so recent attachments are left alone.

POST /api/admin/housekeeping runs the sweep on demand.

Caught while testing: the same block of markup had been pasted into the
create-notebook dialog as well, where the state it referenced did not
exist — the dialog crashed on open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:22:46 +02:00

209 lines
7.0 KiB
JavaScript

/**
* Shared plumbing for the browser suites.
*
* Each suite runs against a throwaway account created through the admin API and
* deletes it again at the end, so repeated runs leave the database as they found
* it. (An earlier generation of these tests registered users through the sign-up
* form and left a trail of them behind; sign-up is gone now, and this is both
* the replacement and the tidier arrangement.)
*/
import { chromium } from "playwright";
export const BASE = process.env.INKNOTE_URL || "http://localhost:3000";
export const OUT = process.env.INKNOTE_SHOTS || "/out";
const ADMIN = {
user: process.env.INKNOTE_ADMIN || "marc",
pass: process.env.INKNOTE_ADMIN_PASS || "geheim123",
};
/* ── reporting ── */
const state = { failures: 0, checks: 0, errors: [] };
export function check(ok, label, extra = "") {
state.checks++;
if (!ok) state.failures++;
console.log(`${ok ? "PASS" : "FAIL"}: ${label}${extra ? ` (${extra})` : ""}`);
return ok;
}
/** Console noise that says nothing about the app under test. */
const IGNORED = /401|403|404|Failed to load resource|favicon/;
export function report() {
const errs = state.errors.filter((e) => !IGNORED.test(e));
console.log("\nERRORS=" + JSON.stringify(errs));
const bad = state.failures > 0 || errs.length > 0;
console.log(
bad
? `\n*** ${state.failures} of ${state.checks} CHECK(S) FAILED${errs.length ? `, ${errs.length} PAGE ERROR(S)` : ""} ***`
: `\n*** ALL ${state.checks} CHECKS PASSED ***`
);
return bad ? 1 : 0;
}
/* ── browser ── */
export async function launch() {
return chromium.launch();
}
export async function newPage(browser, width = 1400, height = 950) {
const ctx = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 2 });
const page = await ctx.newPage();
page.on("pageerror", (e) => state.errors.push(`[err] ${e.message}`));
page.on("console", (m) => {
if (m.type() === "error") state.errors.push(`[console] ${m.text()}`);
});
return page;
}
export async function signIn(page, user, pass) {
await page.goto(BASE, { waitUntil: "networkidle" });
await page.getByPlaceholder("Username").fill(user);
await page.getByPlaceholder("Password").fill(pass);
await page.getByRole("button", { name: "Sign In" }).click();
await page.waitForSelector("text=Dashboard", { timeout: 20000 });
await page.waitForTimeout(1200);
}
/** Runs a fetch inside the page, carrying that page's own token. */
export const api = (page, method, path, body) =>
page.evaluate(
async ([m, u, b]) => {
const r = await fetch("/api" + u, {
method: m,
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + localStorage.getItem("inknote_token"),
},
body: b ? JSON.stringify(b) : undefined,
});
const text = await r.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
/* some routes answer with no body */
}
return { status: r.status, body: json };
},
[method, path, body]
);
/**
* Signs in as the admin, creates a fresh account, and signs into it on a second
* page. Call the returned `cleanup` when done — it deletes the account and with
* it every note the test made.
*/
export async function withFreshUser(browser, prefix = "e2e") {
const user = `${prefix}${Math.floor(Math.random() * 1e6)}`;
const pass = "e2epass123";
const adminPage = await newPage(browser);
await signIn(adminPage, ADMIN.user, ADMIN.pass);
const created = await api(adminPage, "POST", "/admin/users", {
username: user,
password: pass,
displayName: "E2E",
});
if (created.status !== 201) {
throw new Error(`could not create test user (${created.status}): ${JSON.stringify(created.body)}`);
}
const page = await newPage(browser);
await signIn(page, user, pass);
return {
page,
user,
pass,
/** Signed in as the administrator — for anything the test user may not do. */
adminPage,
async cleanup() {
await api(adminPage, "DELETE", `/admin/users/${created.body.id}`);
await adminPage.context().close();
},
};
}
/**
* Intercepts downloads so their bytes stay in the page and can be inspected.
*
* A real download would either navigate away or land somewhere the suite cannot
* read; this keeps the blob and reports it as base64.
*/
export async function captureDownloads(page) {
await page.evaluate(() => {
window.__downloads = [];
const realCreate = URL.createObjectURL.bind(URL);
URL.createObjectURL = (blob) => {
const url = realCreate(blob);
const reader = new FileReader();
reader.onload = () =>
window.__downloads.push({
type: blob.type,
size: blob.size,
base64: String(reader.result).split(",")[1] || "",
});
reader.readAsDataURL(blob);
return url;
};
HTMLAnchorElement.prototype.click = function () {
window.__lastDownloadName = this.download;
};
});
}
export const downloads = (page) => page.evaluate(() => window.__downloads || []);
export const lastDownloadName = (page) => page.evaluate(() => window.__lastDownloadName || "");
export const clearDownloads = (page) => page.evaluate(() => (window.__downloads = []));
/* ── app actions the suites share ── */
export async function createNotebook(page, name) {
await page.locator("button").filter({ hasText: "Dashboard" }).first().click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Add Notebook" }).click();
await page.waitForTimeout(400);
await page.getByPlaceholder("Notebook name...").fill(name);
await page.getByRole("button", { name: "Create", exact: true }).click();
await page.waitForTimeout(1200);
}
export async function createNote(page) {
await page.getByTitle("New Note").click();
await page.waitForSelector(".ProseMirror", { timeout: 15000 });
await page.waitForTimeout(1200);
}
/** The sidebar search box is also a text input, so the title needs its own handle. */
export async function setTitle(page, title) {
await page.getByLabel("Note title").fill(title);
await page.waitForTimeout(1200);
}
/** The toolbar's table button opens a menu; the table comes from the entry inside. */
export async function insertTable(page) {
await page.getByTitle("Table").click();
await page.waitForTimeout(400);
await page.getByRole("button", { name: /Insert 3 . 3 table/ }).click();
await page.waitForTimeout(900);
}
export async function typeInEditor(page, text) {
await page.locator(".ProseMirror").click();
await page.keyboard.type(text);
// Longer than the editor's save debounce, so the write is on its way out.
await page.waitForTimeout(1500);
}
/** Reopens the note from a cold page load — the check that a write really landed. */
export async function reopenNote(page, title) {
await page.reload({ waitUntil: "networkidle" });
await page.waitForTimeout(2500);
await page.locator("button").filter({ hasText: title }).first().click();
await page.waitForSelector(".ProseMirror", { timeout: 15000 });
await page.waitForTimeout(1500);
}