Menu
Public repository / 4f0b1d374fab4075f2b552b5f250bca5f28afee9
AkurAI-Mail / scripts/webmail-e2e.ts
Latest change 054e5cda0171bd13facff3c16a38f2048a1052cc - OLI-249: compose, settings, admin console and sign-in on the framework; SvelteKit removed by Ólafur Búi Ólafsson
// Browser check for the framework webmail: mock API + built site + headless chromium over CDP.
// Run: bun scripts/build.ts && bun scripts/webmail-e2e.ts [out.png]
import { spawn } from "node:child_process";
import { existsSync, readFileSync, statSync } from "node:fs";
import { extname, join } from "node:path";
const OUT = process.argv[2] ?? "webmail-list.png";
const SITE_PORT = 4174;
const CDP_PORT = 9334;
const MESSAGES = Array.from({ length: 40 }, (_, i) => ({ id: `m${i}`, from: `Sender ${i} <s${i}@example.com>`, subject: `Subject ${i}`, preview: `Preview ${i}`, date: "2026-10-01T08:00:00Z", unread: i % 3 === 0, flagged: i === 2, attachments: i === 1 ? [{ filename: "a.pdf", size: 2048, index: 0 }] : [] }));
let preferences: Record<string, unknown> = {};
let adminSession = false;
const actions: any[] = [];
const STATUS = { hostname: "mail.test", publicIp: "203.0.113.5", domain: "example.is", services: { postfix: "active", dovecot: "active", opendkim: "inactive" }, queue: { count: 2, summary: "2 deferred" }, deliveryErrors: ["warning: relay timeout"], users: [{ email: "ann@example.is", quotaUsedMb: 12, lastDelivery: 1759000000 }], aliases: [{ alias: "info@example.is", target: "ann@example.is" }], antiSpam: { totalSpam: 3, policy: { enabled: true, autoMove: false, threshold: 5, blocklist: ["spam@bad.example"], domainBlocklist: [], stats: { moved: 1, scanned: 40 } }, mailboxes: [{ email: "ann@example.is", spam: 3 }] }, dnsHealth: [{ type: "MX", name: "example.is", ok: true, values: ["10 mail.example.is"] }] };
const posts: any[] = [];
const gets: string[] = [];
const json = (body: unknown) => new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } });
const MIME: Record<string, string> = { ".js": "text/javascript", ".css": "text/css", ".html": "text/html", ".svg": "image/svg+xml", ".png": "image/png" };
const server = Bun.serve({
port: SITE_PORT,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/__e2e/admin") { adminSession = url.searchParams.get("on") === "1"; return json({ ok: true }); }
if (url.pathname === "/api/auth/check") return json({ authenticated: true, email: "me@example.com", admin: adminSession, method: "oidc" });
if (url.pathname === "/api/status") return json(STATUS);
if (url.pathname === "/api/domain-list") return json({ domains: [{ domain: "example.is", status: "verified", localChecks: [{ name: "postfix virtual", ok: true, detail: "ok" }], dnsRecords: [{ type: "TXT", name: "example.is", value: "v=spf1 mx -all" }] }] });
if (url.pathname === "/api/dns") return json({ records: [{ type: "A", name: "mail.example.is", value: "203.0.113.5" }] });
if (url.pathname === "/api/actions") { const body = await req.json(); actions.push(body); return json({ ok: true, ...(body.action === "add-user" ? { password: "s3cret-pw" } : {}) }); }
if (url.pathname === "/api/login") { const body = await req.json(); posts.push({ action: "login", ...body }); return body.password === "pw" ? json({ ok: true, next: "/" }) : new Response(JSON.stringify({ ok: false, error: "The email or password is incorrect." }), { status: 401, headers: { "content-type": "application/json" } }); }
if (url.pathname === "/api/webmail") {
if (req.method === "POST") {
const body = await req.json();
posts.push(body);
if (body.action === "read") return json({ ok: true, message: { ...MESSAGES.find((m) => m.id === body.id), bodyHtml: "<p>hello <b>there</b></p>", to: "me@example.com" } });
if (body.action === "config" && body.kind === "preferences") preferences = body.value;
if (body.action === "scheduled-sends") return json({ ok: true, scheduledSends: [] });
if (body.action === "send") return json({ ok: true, pendingSend: { id: "p1", send_at: Math.floor(Date.now() / 1000) + 10 } });
return json({ ok: true });
}
gets.push(url.search);
if (url.searchParams.get("action") === "signals") return json({ ok: true, signals: { c1: { conversationId: "c1", priority: { value: "high", provenance: "model", confidence: 0.9, evidence: ["boss asked"] } } }, messageSignals: { m3: "c1" } });
const folder = url.searchParams.get("folder") ?? "Inbox";
const query = url.searchParams.get("query") ?? "";
const list = folder === "Archive" ? MESSAGES.slice(0, 2) : query ? MESSAGES.filter((m) => m.subject.includes(query)) : MESSAGES;
return json({ folders: [{ name: "Inbox", path: "Inbox", unread: 14 }, { name: "Archive", path: "Archive", unread: 0 }, { name: "Projects/2026", unread: 1 }], messages: list, total: list.length, preferences, vipSenders: ["s4@example.com"], contacts: [{ name: "Anna", email: "anna@example.is" }], signatures: { "me@example.com": "-- me" }, rules: [{ field: "from", contains: "news", action: "Archive" }] });
}
let file = join("build", url.pathname);
if (existsSync(file) && statSync(file).isDirectory()) file = join(file, "index.html");
if (existsSync(file)) return new Response(readFileSync(file), { headers: { "content-type": MIME[extname(file)] ?? "application/octet-stream" } });
return new Response("not found", { status: 404 });
},
});
const profile = (process.env.PAPERCLIP_RUN_SCRATCH_DIR ?? process.env.TMPDIR ?? "/tmp") + "/webmail-framework-e2e-profile";
const chrome = spawn("chromium", ["--headless=new", `--remote-debugging-port=${CDP_PORT}`, "--no-sandbox", "--window-size=1400,900", `--user-data-dir=${profile}`, "about:blank"], { stdio: "ignore" });
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
let targets: any[] = [];
for (let i = 0; i < 50 && !targets.length; i++) {
await sleep(200);
targets = await fetch(`http://127.0.0.1:${CDP_PORT}/json`).then((r) => r.json()).catch(() => []);
}
const ws = new WebSocket(targets.find((t) => t.type === "page").webSocketDebuggerUrl);
await new Promise((r) => (ws.onopen = r));
let nextId = 1;
const pending = new Map<number, (v: any) => void>();
const consoleErrors: string[] = [];
ws.onmessage = (m) => {
const d = JSON.parse(String(m.data));
if (d.method === "Runtime.exceptionThrown") consoleErrors.push(d.params.exceptionDetails.exception?.description ?? d.params.exceptionDetails.text);
if (d.method === "Runtime.consoleAPICalled" && d.params.type === "error") consoleErrors.push(d.params.args.map((a: any) => a.value ?? a.description).join(" "));
if (d.method === "Runtime.consoleAPICalled" && process.env.E2E_DEBUG) console.log(`[console.${d.params.type}]`, d.params.args.map((a: any) => a.value ?? a.description).join(" "));
const waiter = pending.get(d.id);
if (waiter) { pending.delete(d.id); waiter(d); }
};
const send = (method: string, params: object = {}) => new Promise<any>((res) => {
const id = nextId++;
pending.set(id, res);
ws.send(JSON.stringify({ id, method, params }));
// A navigation can destroy the context an evaluation was waiting on; never hang on it.
setTimeout(() => { if (pending.has(id)) { pending.delete(id); console.log(`TIMEOUT ${method}`); res({ result: { result: { value: undefined } } }); } }, 8000);
});
const evalJs = async (expression: string) => (await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })).result.result.value;
const press = async (key: string, text = key.length === 1 ? key : undefined) => {
await send("Input.dispatchKeyEvent", { type: "keyDown", key, text });
await send("Input.dispatchKeyEvent", { type: "keyUp", key });
await sleep(300);
};
const click = async (selector: string) => { await evalJs(`document.querySelector(${JSON.stringify(selector)}).click()`); await sleep(400); };
const selectedSubject = () => evalJs(`document.querySelector("[data-message-list] button.selected strong")?.textContent ?? null`);
const failures: string[] = [];
const check = (name: string, ok: boolean, detail?: unknown) => { console.log(`${ok ? "PASS" : "FAIL"} ${name}${ok ? "" : " " + JSON.stringify(detail)}`); if (!ok) failures.push(name); };
await send("Page.enable");
await send("Runtime.enable");
await send("Page.navigate", { url: `http://127.0.0.1:${SITE_PORT}/webmail/` });
await sleep(2500);
if (process.env.E2E_DEBUG) console.log(await evalJs(`document.querySelector("[data-message-list]").outerHTML.slice(0, 600)`));
check("list renders a window of rows, not all 40", (await evalJs(`document.querySelectorAll("[data-message-list] .thread-row").length`)) > 5 && (await evalJs(`document.querySelectorAll("[data-message-list] .thread-row").length`)) < 40, await evalJs(`document.querySelectorAll("[data-message-list] .thread-row").length`));
check("folder tree shows nested folder", await evalJs(`[...document.querySelectorAll(".folder-button")].some(b => b.title === "2026")`));
check("unread count in folder summary", await evalJs(`document.querySelector(".folder-summary strong").textContent`) === "15 unread", await evalJs(`document.querySelector(".folder-summary strong").textContent`));
check("reader shows empty state before selection", await evalJs(`/Choose a conversation/.test(document.querySelector(".reader-column").textContent)`));
await press("Enter");
check("Enter opens first message", (await selectedSubject()) === "Subject 0", await selectedSubject());
check("reader renders the message body", await evalJs(`document.querySelector(".message-body b")?.textContent`) === "there");
for (let i = 0; i < 30; i++) await press("j");
check("j moves selection", (await selectedSubject()) === "Subject 30", await selectedSubject());
if (process.env.E2E_DEBUG) { await sleep(500); console.log("debug", await evalJs(`JSON.stringify({ rows: [...document.querySelectorAll(".thread-row")].map(r => r.dataset.thread), selected: document.querySelector("[data-message-list] button.selected")?.closest(".thread-row")?.dataset.thread ?? null, scrollTop: document.querySelector("[data-message-list]").scrollTop, height: document.querySelector("[data-message-list]").clientHeight })`)); }
check("selected row scrolled into view", await evalJs(`(() => { const r = document.querySelector("[data-message-list] button.selected").getBoundingClientRect(); return r.top >= 0 && r.bottom <= innerHeight; })()`));
check("rows virtualized after scrolling", (await evalJs(`document.querySelectorAll("[data-message-list] .thread-row").length`)) < 40);
const shot = await send("Page.captureScreenshot", { format: "png" });
await Bun.write(OUT, Buffer.from(shot.result.data, "base64"));
await press("k");
await press("k");
await click('[aria-label="Archive message"]');
check("archive posts the message action", posts.some((p) => p.action === "message" && p.messageAction === "archive" && p.id === "m28"), posts.at(-2));
await press("Enter");
await press("#");
check("# asks to delete", await evalJs(`/delete this message/i.test(document.body.innerText)`));
await press("Escape");
check("Escape closes the dialog", !(await evalJs(`/delete this message/i.test(document.body.innerText)`)));
await evalJs(`document.querySelector("[data-search]").focus()`);
await send("Input.insertText", { text: "Subject 1" });
await press("Enter");
await sleep(600);
check("search reloads with the query", gets.some((q) => q.includes("query=Subject+1")), gets.at(-2));
check("list filtered by search", (await evalJs(`document.querySelectorAll("[data-message-list] .thread-row").length`)) === 11, await evalJs(`document.querySelectorAll("[data-message-list] .thread-row").length`));
await click(".clear-search");
await click('.folder-button[title="Archive"]');
await sleep(600);
check("folder switch loads Archive", gets.some((q) => q.includes("folder=Archive")) && (await evalJs(`document.querySelector(".column-bar strong").textContent`)) === "Archive");
await click(".chip");
check("chip toggles the unread filter", !!gets.at(-2)?.includes("unread=true"), gets.at(-2));
await click(".chip-clear");
await evalJs(`document.activeElement?.blur()`);
await press("?");
check("? opens the shortcut reference", await evalJs(`!!document.querySelector(".shortcuts-modal")`));
await press("Escape");
await evalJs(`document.querySelector('[aria-label="Keyboard shortcuts"]').click()`);
check("toolbar help button opens the reference", await evalJs(`!!document.querySelector(".shortcuts-modal")`));
await press("Escape");
// ── compose ──
await click('.primary-action');
check("Compose opens the dialog", await evalJs(`!!document.querySelector(".compose-window")`));
await evalJs(`document.querySelector('.compose-window input[aria-label="To"]').focus()`);
await send("Input.insertText", { text: "an" });
await sleep(300);
check("recipient suggestions appear", await evalJs(`document.querySelectorAll(".combobox-list [role=option]").length`) === 1, await evalJs(`document.querySelectorAll(".combobox-list [role=option]").length`));
await press("ArrowDown");
await press("Enter");
check("Enter completes the recipient", await evalJs(`document.querySelector('.compose-window input[aria-label="To"]').value`) === "anna@example.is, ", await evalJs(`document.querySelector('.compose-window input[aria-label="To"]').value`));
await evalJs(`document.querySelector('.compose-window input[aria-label="Subject"]').focus()`);
await send("Input.insertText", { text: "Hello" });
await evalJs(`document.querySelector('.compose-window [data-rt-editor]').focus()`);
await send("Input.insertText", { text: "Body text" });
await sleep(300);
await click('[aria-label="Bold"]');
check("rich text toolbar is wired", await evalJs(`!!document.querySelector('.compose-window [data-rt-editor]')`));
check("signature note shows for a saved signature", await evalJs(`/saved signature/.test(document.querySelector(".compose-window").innerText)`));
await click('.compose-window .primary-action');
await sleep(500);
const sent = posts.find((p) => p.action === "send");
check("send posts the draft with recipients, subject and html body", !!sent && sent.message.to === "anna@example.is, " && sent.message.subject === "Hello" && /Body text/.test(sent.message.body) && sent.message.undoDelaySecs === 10, sent?.message);
check("compose closes after sending", !(await evalJs(`!!document.querySelector(".compose-window")`)));
check("undo toast counts down", await evalJs(`/Message sending in [0-9]+s/.test(document.body.innerText)`));
await click(".undo-send-toast .subtle-button");
check("undo cancels the pending send", posts.some((p) => p.action === "cancel-send" && p.id === "p1"));
// ── settings ──
await click('[aria-label="Open settings"]');
check("settings drawer opens", await evalJs(`!!document.querySelector(".settings-drawer")`));
check("saved signature loaded into the field", await evalJs(`document.querySelector(".settings-drawer textarea").value`) === "-- me", await evalJs(`document.querySelector(".settings-drawer textarea").value`));
check("rules listed", await evalJs(`/news/.test(document.querySelector(".settings-drawer").innerText)`));
const clickToggle = () => evalJs(`[...document.querySelectorAll(".settings-drawer label.setting-copy")].find(l => l.textContent.includes("Keyboard shortcuts")).querySelector("input").click()`);
await clickToggle();
await sleep(800);
check("shortcut toggle persists to server preferences", preferences.shortcuts === false, preferences);
await press("Escape");
await click('[aria-label="Open settings"]');
await evalJs(`[...document.querySelectorAll(".settings-drawer .segmented button")].find(b => b.textContent === "Dark").click()`);
await sleep(800);
check("theme choice persists and applies", preferences.theme === "dark" && (await evalJs(`document.documentElement.dataset.theme`)) === "dark", preferences);
await press("Escape");
check("Escape closes the drawer", !(await evalJs(`!!document.querySelector(".settings-drawer")`)));
// ── login page ──
await send("Page.navigate", { url: `http://127.0.0.1:${SITE_PORT}/login/?return_to=/webmail/` });
await sleep(800);
check("login page offers AkurAI Login with the return target", await evalJs(`document.getElementById("sso").getAttribute("href")`) === "/auth/login?return_to=%2Fwebmail%2F", await evalJs(`document.getElementById("sso").getAttribute("href")`));
await evalJs(`document.querySelector('details:nth-of-type(2)').open = true; document.querySelector('form[data-login=admin] [name=email]').value = "admin"; document.querySelector('form[data-login=admin] [name=password]').value = "wrong"; document.querySelector('form[data-login=admin] button').click()`);
await sleep(600);
check("a wrong admin password shows the API error", await evalJs(`document.getElementById("login-error").textContent`) === "The email or password is incorrect.", await evalJs(`document.getElementById("login-error").textContent`));
await evalJs(`document.querySelector('form[data-login=admin] [name=password]').value = "pw"; document.querySelector('form[data-login=admin] button').click()`);
await sleep(1200);
check("a correct admin password follows next", (await evalJs(`location.pathname`)) === "/webmail/" || posts.some((p) => p.action === "login" && p.password === "pw"), await evalJs(`location.pathname`));
// ── admin console ──
await fetch(`http://127.0.0.1:${SITE_PORT}/__e2e/admin?on=1`);
await send("Page.navigate", { url: `http://127.0.0.1:${SITE_PORT}/` });
await sleep(1500);
check("dashboard renders service health and queue", await evalJs(`document.querySelectorAll(".service-strip article").length`) === 3 && (await evalJs(`document.querySelector(".big").textContent`)) === "2");
check("rail marks the active page", await evalJs(`document.querySelector("nav a.active")?.textContent`) === "Dashboard");
await send("Page.navigate", { url: `http://127.0.0.1:${SITE_PORT}/mailboxes/` });
await sleep(1500);
await evalJs(`document.querySelector('form input[type=email]').value = "new@example.is"; document.querySelector('form button.primary').click()`);
await sleep(800);
check("add mailbox posts the action and shows the password", actions.some((a) => a.action === "add-user" && a.email === "new@example.is") && (await evalJs(`/s3cret-pw/.test(document.body.innerText)`)), actions);
await send("Page.navigate", { url: `http://127.0.0.1:${SITE_PORT}/domains/` });
await sleep(1500);
check("domains page lists the domain with its DNS records", await evalJs(`/example\.is/.test(document.querySelector(".domain-panel h2")?.textContent ?? "") && /v=spf1/.test(document.body.innerText)`));
await click('.domain-actions .secondary');
await sleep(800);
check("verify DNS posts a domain-check", actions.some((a) => a.action === "domain-check" && a.domain === "example.is"));
await send("Page.navigate", { url: `http://127.0.0.1:${SITE_PORT}/antispam/` });
await sleep(1500);
check("anti-spam page shows policy and blocklist", await evalJs(`/Enabled/.test(document.body.innerText) && /spam@bad\.example/.test(document.body.innerText)`));
await send("Page.navigate", { url: `http://127.0.0.1:${SITE_PORT}/dns/` });
await sleep(1500);
check("dns page shows records and health", await evalJs(`/203\.0\.113\.5/.test(document.body.innerText) && /10 mail\.example\.is/.test(document.body.innerText)`));
await fetch(`http://127.0.0.1:${SITE_PORT}/__e2e/admin?on=0`);
await send("Page.navigate", { url: `http://127.0.0.1:${SITE_PORT}/` });
await sleep(1200);
check("a member opening the admin console lands in webmail", (await evalJs(`location.pathname`)) === "/webmail", await evalJs(`location.pathname`));
check("no console errors", consoleErrors.length === 0, consoleErrors);
ws.close(); chrome.kill(); server.stop();
console.log(`screenshot: ${OUT}; posts: ${posts.map((p) => p.action).join(",")}`);
process.exit(failures.length ? 1 : 0);