The interface had drifted into two languages — the frame in English, everything added later in German — which is worse than either, since the same idea appeared under two names depending on when that part was written. Sorting by title still uses the German collation, so Übung 2 comes before Übung 10: that is about the notes, not the chrome. Two archive folders were renamed with it (bilder/ → images/, zeichnungen/ → drawings/). The importer reads both spellings, and both the old and new marker line for a drawing — archives exported before this are on disk, and an import that quietly dropped their drawings would be the worst kind of tidy-up. Making a note, uploading a PDF and importing Markdown were three buttons side by side, two of them icons you had to hover to identify. They are the same act — putting something new in this notebook — so they are now one + menu. The search box moves out of the sidebar and into the note list, labelled with the notebook it narrows. Nothing changed about what it does: it has always searched only the notebook you are in, but from the sidebar it read as searching everything. A separator no longer carries a sentence explaining the order the sort menu already names. Renaming joins removing on the right-click menu — a left-click on the label started an edit, so the row could not be touched without falling into one. 218 unit tests, 238 browser checks across eleven suites. Two of those caught real breakage from this change rather than needing a translation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
227 lines
7.6 KiB
JavaScript
227 lines
7.6 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);
|
|
}
|
|
|
|
/** Opens the search box in the note list and types into it. */
|
|
export async function search(page, query) {
|
|
const box = page.locator('input[aria-label="Search in this notebook"]');
|
|
if ((await box.count()) === 0) {
|
|
await page.locator("[data-search-toggle]").click();
|
|
await page.waitForTimeout(400);
|
|
}
|
|
await box.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);
|
|
}
|