// Chat-driven restyle walkthrough + guardrail checks. Usage:
// NODE_PATH=
node tests/theme_check.js BASE_URL [screenshot_dir]
const puppeteer = require("puppeteer-core");
const BASE = (process.argv[2] || "http://127.0.0.1:8795").replace(/\/$/, "");
const SHOTS = process.argv[3] || "";
const results = []; const replies = [];
const check = (name, ok, d = "") => { results.push(!!ok); console.log((ok ? "PASS " : "FAIL ") + name + (d ? ` [${d}]` : "")); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
const browser = await puppeteer.launch({ executablePath: process.env.CHROME || "/usr/bin/google-chrome", headless: "new",
args: ["--no-sandbox", "--disable-gpu", "--user-data-dir=/tmp/am-theme-profile-" + Date.now()] });
const ctx = await browser.createBrowserContext();
const page = await ctx.newPage();
await page.setViewport({ width: 1280, height: 860 });
const errors = []; page.on("pageerror", (e) => errors.push(e.message));
await page.goto(BASE + "/", { waitUntil: "networkidle0" });
await page.evaluate(() => localStorage.removeItem("am_theme_v1"));
await page.reload({ waitUntil: "networkidle0" });
await page.waitForSelector(".card", { timeout: 20000 });
await page.click("#chatLauncher");
const ask = async (text) => {
for (let attempt = 0; attempt < 4; attempt++) {
const n = (await page.$$(".msg.bot")).length; const t0 = Date.now();
await page.type("#chatInput", text); await page.keyboard.press("Enter");
await page.waitForFunction((n) => document.querySelectorAll(".msg.bot").length > n, { timeout: 60000 }, n);
await sleep(500);
const last = await page.$$eval(".msg.bot", (ns) => ns[ns.length - 1].textContent);
if (/sending messages quickly/.test(last)) { const s = +(last.match(/(\d+)s/) || [0, 6])[1]; await sleep((s + 1) * 1000); continue; }
const mode = await page.$eval("#chatMode", (n) => n.textContent);
replies.push({ text, reply: last, ms: Date.now() - t0, mode });
console.log(` > ${text}\n ${mode} · ${Date.now() - t0}ms · "${last.slice(0, 160)}"`);
return last;
}
};
const theme = () => page.evaluate(() => JSON.parse(localStorage.getItem("am_theme_v1") || "{}"));
const cs = (sel, prop) => page.$eval(sel, (n, p) => getComputedStyle(n)[p], prop);
// Guardrails: banner, badge and checkout fee lines must stay visible and >= 4.5:1 contrast.
async function guardrails(label) {
// open a checkout for the first online-payable item via the deterministic click path
await page.evaluate(() => { const b = [...document.querySelectorAll("#stage .card button.btn:not(.cash)")][0]; if (b) b.click(); });
const hasCheckout = await page.waitForSelector("[data-component=checkout]", { timeout: 15000 }).then(() => true, () => false);
const r = await page.evaluate(() => {
const eff = (n) => { while (n) { const c = getComputedStyle(n).backgroundColor; if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return c; n = n.parentElement; } return "rgb(255,255,255)"; };
const vis = (n) => { const s = getComputedStyle(n); const r = n.getBoundingClientRect(); return s.display !== "none" && s.visibility === "visible" && +s.opacity === 1 && r.width > 0 && r.height > 0; };
const out = {};
for (const [k, sel] of [["banner", ".testbar"], ["badge", "#agentBadge"], ["fee", "[data-testid=fee]"], ["total", "[data-testid=total]"], ["testcard", ".testcard"]]) {
const n = document.querySelector(sel); if (!n) { out[k] = null; continue; }
out[k] = { c: window.__amContrast(getComputedStyle(n).color, eff(n)), vis: vis(n), text: n.textContent.trim().slice(0, 40), fs: parseFloat(getComputedStyle(n).fontSize) };
}
out.input = (() => { const i = document.querySelector("[data-component=checkout] input"); return i && { vis: vis(i), c: window.__amContrast(getComputedStyle(i).color, getComputedStyle(i).backgroundColor) }; })();
return out;
});
const ok = hasCheckout && ["banner", "badge", "fee", "total", "testcard"].every((k) => r[k] && r[k].vis && r[k].c >= 4.5 && r[k].fs >= 12) && r.input && r.input.vis && r.input.c >= 4.5;
check(`guardrails intact under ${label}`, ok, ["banner", "badge", "fee", "total"].map((k) => r[k] ? `${k} ${r[k].c.toFixed(1)}:1` : `${k} missing`).join(", ") + (r.fee ? ` fee="${r.fee.text}"` : ""));
const feeOk = r.fee && r.total && /^\$\d+\.\d\d$/.test(r.fee.text) && r.banner && /TEST DEMO/.test(r.banner.text);
check(`fee amount + banner text unchanged under ${label}`, feeOk);
}
await ask("show me bikes");
await page.waitForSelector("#stage .card", { timeout: 15000 });
await ask("make the background a sunset gradient");
const bg1 = await cs("body", "backgroundImage");
check("sunset gradient background", /gradient/.test(bg1), bg1.slice(0, 90));
if (SHOTS) { await page.evaluate(() => window.scrollTo(0, 0)); await sleep(3800); await page.screenshot({ path: SHOTS + "/6-theme-sunset-gradient.png" }); }
await guardrails("sunset gradient");
const heroC = await page.evaluate(() => {
const t = JSON.parse(localStorage.getItem("am_theme_v1") || "{}"); const stops = ((t.background || {}).colors || []);
const eff = (n) => { while (n && n !== document.body) { const c = getComputedStyle(n).backgroundColor; if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return [c]; n = n.parentElement; } return stops; };
return ["headline", "subhead"].map((id) => { const n = document.getElementById(id); return Math.min(...eff(n).map((c) => window.__amContrast(getComputedStyle(n).color, c))); });
});
check("hero text readable on the gradient (>= 4.5:1 vs every stop)", heroC.every((c) => c >= 4.5), heroC.map((c) => c.toFixed(1) + ":1").join(", "));
await ask("show me everything");
await ask("switch to light mode with a green accent");
const t2 = await theme();
const acc = await page.evaluate(() => { const s = document.createElement("i"); s.style.color = getComputedStyle(document.documentElement).getPropertyValue("--acc"); document.body.append(s); const c = getComputedStyle(s).color; s.remove(); return c; });
const [r, g, b] = acc.match(/\d+/g).map(Number);
check("light mode with green accent", t2.mode === "light" && g > r && g > b, `mode=${t2.mode} accent=${acc}`);
if (SHOTS) { await page.evaluate(() => window.scrollTo(0, 0)); await sleep(3800); await page.screenshot({ path: SHOTS + "/7-theme-light-green.png" }); }
await guardrails("light + green");
await ask("use a serif font and make the text bigger");
const ff = await cs("body", "fontFamily"); const t3 = await theme();
check("serif font + bigger text", /Georgia|serif|Lora|Merriweather|Playfair|Roboto Slab/i.test(ff) && (t3.fontScale || 1) > 1, `${ff.slice(0, 40)} scale=${t3.fontScale}`);
await ask("show results as a list, not cards");
await ask("show me everything");
const cols = await page.$eval("#stage .grid", (n) => getComputedStyle(n).gridTemplateColumns.split(" ").length).catch(() => 0);
check("results as a list", (await page.$eval("body", (n) => n.classList.contains("layout-list"))) && cols === 1, `columns=${cols}`);
await ask("move the chat to the left");
const left = await page.$eval("#chat", (n) => n.getBoundingClientRect().left);
check("chat moved to the left", left < 100, `left=${left}`);
await ask("make it look retro");
const ff2 = await cs("body", "fontFamily"); const t4 = await theme();
check("retro preset applied", /VT323|Press Start|Space Mono|mono|Courier/i.test(ff2) || /retro/i.test(JSON.stringify(t4)), ff2.slice(0, 50));
if (SHOTS) { await ask("show me everything"); await page.evaluate(() => window.scrollTo(0, 0)); await sleep(3800); await page.screenshot({ path: SHOTS + "/8-theme-retro.png" }); }
await guardrails("retro");
await ask("hide the bullet points");
check("bullets hidden", (await cs("#bullets", "display")) === "none");
await ask("change the headline to Buy and sell anything nearby");
const h = await page.$eval("#headline", (n) => n.textContent);
check("headline changed", /buy and sell anything nearby/i.test(h), h);
// A second, fresh session must still see the default look.
const ctx2 = await browser.createBrowserContext(); const p2 = await ctx2.newPage();
await p2.goto(BASE + "/", { waitUntil: "networkidle0" });
const fresh = await p2.evaluate(() => ({ h: document.getElementById("headline").textContent, bg: getComputedStyle(document.body).backgroundImage, bul: getComputedStyle(document.getElementById("bullets")).display, ls: localStorage.getItem("am_theme_v1") }));
check("fresh session sees default look", /just talk to/.test(fresh.h) && fresh.bg === "none" && fresh.bul !== "none" && !fresh.ls, JSON.stringify(fresh).slice(0, 120));
await ctx2.close();
await ask("reset the look");
const t5 = await theme(); const h2 = await page.$eval("#headline", (n) => n.textContent);
check("reset the look", Object.keys(t5).length === 0 && /just talk to/.test(h2), JSON.stringify(t5));
check("no internal ids in replies", replies.every((x) => !/\b(lst|pi|cash|ord)_[0-9a-f]{6,}/.test(x.reply)));
const who = [...new Set(replies.map((x) => (/AI agent/.test(x.mode) ? x.mode.split(" · ")[1] : "built-in agent")))].join(", ");
if (process.env.REQUIRE_LLM) check("all restyles answered by the real model", replies.every((x) => /AI agent/.test(x.mode)), who);
else console.log("answered by: " + who);
check("no page JS errors", errors.length === 0, errors.join("; "));
const ms = replies.map((x) => x.ms).sort((a, b) => a - b);
console.log(`\nmodel replies: median ${(ms[ms.length >> 1] / 1000).toFixed(1)}s, max ${(ms[ms.length - 1] / 1000).toFixed(1)}s`);
await browser.close();
const passed = results.filter(Boolean).length;
console.log(`${passed}/${results.length} passed`);
process.exit(passed === results.length ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(2); });