/** * 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); }