// 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); });