agent-market/tests/theme_check.js
leora d785091d64 Typed chat: hosted free models, then the local model, nothing else; busy reply + Retry; llama-server watchdog
- Remove the keyword-rules agent from typed chat: fallback_agent, parse_look_command (+ tables), the copy-edit parser
  and _rules_check are gone. If every model fails: 'The assistant is busy, please try again in a moment.' with a Retry
  button (agent 'unavailable', no actions). Clicks, forms and payments stay deterministic (fallback_event); the landing
  view is a deterministic featured-listings panel; the Sell button opens the empty form.
- Local model via llama.cpp llama-server directly (ops/local-llm-watchdog.sh, loopback only, restart on exit/hang,
  crash backoff, log rotation; deploy.sh ensures it runs). Lean: no vision projector, no weight repack (mmapped), ctx
  6144, 1 slot, q8 KV, 6 threads at nice 10, poll 0, 1 GiB prompt cache. Idle 0% CPU, 2.9 GB RSS (0.6 GB private).
- Local requests use a JSON GBNF grammar, worked examples in the cached system prompt, and a labelled visitor message.
  10/10 correct on the real prompt, warm 4-13 s.
- Primer re-primes after a model-server restart (llama-server /metrics counter) and otherwise stays idle.
- Fix: hosted calls kept the Authorization header off when a local model was configured (cfg['local'] is a dict on
  hosted configs); now only the local server itself skips the key. Test added.
- Badge/chat header: 'AI: busy', 'AI: quota used up', 'AI: unavailable'; no 'built-in'/'checked by rules' texts.
- Tests: proxy checks expect the busy reply (never rules), clicks still work while busy, no-model server, local grammar/
  prompt/no key; browser checks for busy + Retry; e2e_http accepts busy for typed turns unless REQUIRE_LLM=1;
  test_copyedit.py replaced by test_copy_defaults.py; README updated.
2026-10-08 21:58:41 +08:00

156 lines
12 KiB
JavaScript

// Chat-driven restyle walkthrough + guardrail checks. Usage:
// NODE_PATH=<dir with puppeteer-core> 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: 90000 }, 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);
// The look is stored per browser: a reload keeps it.
await page.reload({ waitUntil: "networkidle0" }); await page.waitForSelector(".card", { timeout: 15000 });
check("look persists across reload (localStorage)", /buy and sell anything nearby/i.test(await page.$eval("#headline", (n) => n.textContent)) && (await cs("#bullets", "display")) === "none");
await page.click("#chatLauncher");
// 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));
// A hostile stored theme (tampered localStorage) can't hide or recolour protected parts, and a hidden results
// section is shown again when the agent renders a panel. Then the "Reset look" button restores the default.
await page.evaluate(() => localStorage.setItem("am_theme_v1", JSON.stringify({
palette: { bg: "#ffffff", surface: "#fefefe", text: "#fdfdfd", muted: "#fefefe", accent: "#ffff00" }, sections: { hidden: ["results"] },
customCss: ".testbar{display:none!important;opacity:0} #agentBadge{visibility:hidden;color:#fff} [data-component=checkout] *{color:#fff!important;background:#fff} body{--p-ink:#fff} .card{border-width:3px}" })));
await page.reload({ waitUntil: "networkidle0" }); await page.waitForSelector(".card", { timeout: 15000 }); await sleep(300);
const hv = await page.evaluate(() => ({ stage: getComputedStyle(document.getElementById("stage")).display, css: document.getElementById("user-css").textContent,
toast: [...document.querySelectorAll("#toasts .toast")].map((t) => t.textContent).join("|"), hidden: (JSON.parse(localStorage.getItem("am_theme_v1") || "{}").sections || {}).hidden }));
check("hidden results shown again for agent panels", hv.stage !== "none" && /Results section shown again/.test(hv.toast) && !hv.hidden.includes("results"), hv.toast);
check("tampered stored customCss dropped by the client (fail-closed)", /^@layer user/.test(hv.css) && !/testbar|important|--p-ink|agentBadge/.test(hv.css), hv.css.replace(/\s+/g, " ").slice(0, 120));
await guardrails("hostile stored theme");
await page.click("#resetLook"); await sleep(300);
const rs = await page.evaluate(() => ({ ls: localStorage.getItem("am_theme_v1"), h: document.getElementById("headline").textContent, css: document.getElementById("user-css").textContent,
toast: [...document.querySelectorAll("#toasts .toast")].map((t) => t.textContent).join("|") }));
check("Reset look button restores the default", rs.ls === null && /just talk to/.test(rs.h) && rs.css === "" && /Look reset to default/.test(rs.toast), JSON.stringify(rs).slice(0, 120));
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|Local model/.test(x.mode) ? x.mode.split(" · ").slice(0, 2).join(" ") : "built-in agent")))].join(", ");
if (process.env.REQUIRE_LLM) check("all restyles answered by the real model", replies.every((x) => /AI agent|Local model/.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); });