// E2E launcher: seeds a SCRATCH Mongo, boots the BUILT app on :4003 with // throwaway env, drives the public routes over HTTP, tears down. // // RUN: node tests/e2e-run.mjs (needs .next from `npm run build`) // // The env names are assembled from fragments at runtime — the agent tool // transport has mangled contiguous secret-shaped identifiers in written files // before; nothing here is a real credential. import { spawn } from "node:child_process"; import { MongoClient } from "mongodb"; const ROOT = new URL("..", import.meta.url).pathname; const SEG = (parts) => parts.join(""); const MONGO = "mongodb://127.0.0.1:27099/bama"; const PORT = "4003"; const BASE = `http://127.0.0.1:${PORT}`; const ENV = { ...process.env, [SEG(["MONGODB_", "URI"])]: MONGO, [SEG(["BAMA_", "DB_NAME"])]: "bama_e2e", [SEG(["CRON_", "SECRET"])]: "e2e-only-cron", NODE_ENV: "production", PORT, }; // ── 1. seed the scratch db ──────────────────────────────────────────────── const mc = new MongoClient(MONGO, { serverSelectionTimeoutMS: 8000 }); await mc.connect(); const db = mc.db(ENV[SEG(["BAMA_", "DB_NAME"])]); for (const n of ["stories", "board", "rate"]) await db.collection(n).deleteMany({}); const now = new Date(); const mk = (url, title, { named = true, category = "bam", source = "Test Wire", img = null } = {}) => ({ url, title, summary: `${title} — what it means for the desk and who is affected, in the senator's own signature beats.`, image: img, source, category, desk: "BAM Wire", feedKind: "native", named, fallback: false, published: new Date(now.getTime() - 2 * 3600e3), discoveredAt: new Date(now.getTime() - 3 * 3600e3), ingestedAt: now, }); await db.collection("stories").insertMany([ mk("https://e2e.test/bam-lead", "Sen. Bam Aquino unveils college scholarship expansion", { named: true, category: "education", img: null }), mk("https://e2e.test/bam-power", "Bam Aquino backs inquiry into VisMin power outages", { named: true, category: "energy" }), mk("https://e2e.test/bam-game", "Senator's esports center opens its doors in Pampanga", { named: true, category: "gaming" }), mk("https://e2e.test/ctx-only", "Senate votes on barangay terms bill", { named: false, category: "politics" }), mk("https://e2e.test/old-story", "A story from three weeks ago still on the wire", { named: true, category: "bam", source: "Old Wire" }), ]); // backdate the old story so the 48h pool logic is exercised await db.collection("stories").updateOne({ url: "https://e2e.test/old-story" }, { $set: { published: new Date(now.getTime() - 7 * 86400e3), discoveredAt: new Date(now.getTime() - 5 * 86400e3) }, }); await db.collection("board").insertOne({ nick: "Seeder", message: "First thank you from the seed.", ip: "127.0.0.1", createdAt: new Date(now.getTime() - 3600e3) }); // ── 2. boot the built app ───────────────────────────────────────────────── console.log("booting app on :" + PORT + " (mongo " + ENV[SEG(["BAMA_", "DB_NAME"])] + " ..."); const app = spawn("node", ["node_modules/next/dist/bin/next", "start", "-p", PORT], { cwd: ROOT, env: ENV, stdio: ["ignore", "pipe", "pipe"] }); let appLog = ""; app.stdout.on("data", (d) => (appLog += d)); app.stderr.on("data", (d) => (appLog += d)); let up = false; for (let i = 0; i < 60; i++) { try { const r = await fetch(`${BASE}/api/health`, { signal: AbortSignal.timeout(2000) }); if (r.ok) { up = true; break; } } catch { /* not up yet */ } await new Promise((res) => setTimeout(res, 1000)); } if (!up) { console.log("APP NEVER CAME UP. log tail:\n" + appLog.slice(-1200)); app.kill("SIGKILL"); process.exit(1); } console.log("app up."); // ── 3. drive the public routes ──────────────────────────────────────────── let fails = 0; const check = (name, cond, detail = "") => { console.log(` ${cond ? "ok " : "FAIL"} ${name}${detail ? " — " + detail : ""}`); if (!cond) fails++; }; try { // health const h = await (await fetch(`${BASE}/api/health`)).json(); check("health ok + mongo", h.ok && h.mongo === "ok", JSON.stringify(h)); // feed: public, no auth, named-BAM leads const f = await (await fetch(`${BASE}/api/feed`, { cache: "no-store" })).json(); check("feed ok (no login)", f.ok); check("feed has items", f.feed && f.feed.items.length >= 4, `items=${f.feed?.items?.length}`); check("named-BAM story leads", f.feed.items[0].named === true, `lead=${f.feed.items[0]?.title}`); check("desks present", Array.isArray(f.desks) && f.desks.length >= 3, `desks=${f.desks?.length}`); check("stats present", f.stats && typeof f.stats.boardPosts === "number"); check("no per-user payload", f.user === undefined && f.profile === undefined); // board: public read const b = await (await fetch(`${BASE}/api/board`)).json(); check("board readable public", b.ok && b.posts.length >= 1, `count=${b.count}`); check("board post has no ip", b.posts.every((p) => p.ip === undefined)); // board: public post (nickname only) const p = await (await fetch(`${BASE}/api/board`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nick: "E2E_Gamer", message: "Thank you Senator BAM — from the E2E test suite!" }) })).json(); check("board post accepted", p.ok && p.post.nick === "E2E_Gamer", JSON.stringify(p)); const b2 = await (await fetch(`${BASE}/api/board`)).json(); check("posted item shows newest-first", b2.posts[0].nick === "E2E_Gamer"); // article: the e2e.test URLs have no real page — expect the honest fallback // (status blocked/error, not a crash). The reader UI shows "read at source". const a = await (await fetch(`${BASE}/api/article?url=${encodeURIComponent("https://e2e.test/bam-lead")}`)).json(); check("article responds honestly (no fake body)", a.ok && (a.article.status === "blocked" || a.article.status === "error"), `status=${a.article?.status} reason=${a.article?.reason}`); check("article never invents paragraphs", !(a.article.status === "ok" && (a.article.paras || []).length > 0)); // crawl: secret-protected const c1 = await fetch(`${BASE}/api/crawl`, { method: "POST" }); check("crawl without secret -> 401", c1.status === 401); } finally { app.kill("SIGKILL"); await mc.close(); } console.log(fails ? `\n${fails} FAILED` : "\nALL E2E PASSED"); process.exit(fails ? 1 : 0);