Menu
Public repository / 3af23b0e937520000e1aa43c7e2eb9de2675abd1
AkurAI-Mail / scripts/webmail-e2e.ts
Latest change 3af23b0e937520000e1aa43c7e2eb9de2675abd1 - OLI-248: webmail list and reader as framework islands on a signals store 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> = {};
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 === "/api/auth/check") return json({ authenticated: true, email: "me@example.com", admin: false, method: "oidc" });
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: [] });
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"] });
}
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(" "));
pending.get(d.id)?.(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 })); });
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");
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);