Files
marcandClaude Opus 5 9e4677c70b Patterns on the covers, a search box that is simply there, roomier margin notes
Four small things about the shape of the app.

Covers can be patterns as well as colours — ruled paper, graph paper,
pinstripe, bands, dots, cross-hatch, linen, washes, a ledger — drawn
entirely with CSS gradient layers. No image files, nothing to upload,
crisp at any size, and offline like everything else. A cover is stored as
an index into that list, so the new ones are appended and never inserted:
renumbering would silently repaint every notebook anyone already has.

Two things the patterns taught. One idea per cover: two crossed diagonals
meant as a chevron came out as noisy plaid at the size a cover is
actually read, and were replaced with plain bands. And the grid has to
scroll — with 27 covers the dialog ran past the bottom of a laptop screen
and took the Create button with it, which a check now holds at 1440x800.

The search box in a notebook is simply there now, and the button that
used to open it is gone. A magnifying glass cost a click and having to
remember that searching was possible at all; a search box is one line and
says for itself what it does.

Margin notes went from 208x74 to 288x112 — from three short lines to
about five. Three lines is a caption, not a remark on a passage, and the
space beside the page was sitting mostly empty.

And the notebook edit dialog was still part German, and would not say
what the server's default number of versions actually is. It names it
now, read from the server rather than written into the interface: it is
set with MAX_VERSIONS_PER_NOTE, and a hardcoded "50" would quietly start
lying the day that changed.

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

222 lines
7.5 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);
}
/** Making a note, importing a PDF and importing Markdown share one + menu. */
export async function openAddMenu(page) {
await page.locator("[data-add-menu]").click();
await page.waitForTimeout(400);
}
export async function createNote(page) {
await openAddMenu(page);
await page.locator('[data-add-option="New note"]').click();
await page.waitForSelector(".ProseMirror", { timeout: 15000 });
await page.waitForTimeout(1200);
}
/** Types into the note list's search box, which is always there. */
export async function search(page, query) {
await page.locator('input[aria-label="Search in this notebook"]').fill(query);
await page.waitForTimeout(1300);
}
/** 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);
}