Move the browser suites into the repository
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>
This commit is contained in:
1 parent
41ba99c017
commit
311e992496
13 files changed
+915
No files matched your search
+174
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 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,
|
||||
async cleanup() {
|
||||
await api(adminPage, "DELETE", `/admin/users/${created.body.id}`);
|
||||
await adminPage.context().close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 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);
|
||||
}
|
||||
Reference in new issue
Block a user