agent-market/tests/fake_model_browser.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

91 lines
7.1 KiB
JavaScript

// React UI against the fake OpenAI-compatible model (tests/mock_llm.py): model badge + chat mode text, busy reply with
// retry button (never rule-based answers), quota badge, rate-limit toast, and a model-sent set_theme that the server sanitised. No real model calls.
// Usage: NODE_PATH=<dir with puppeteer-core> node tests/fake_model_browser.js <mock-model> <busy-chain> <daily-quota> <rate-limited> <local> <mock-url>
const puppeteer = require("puppeteer-core");
const [OK, BUSY, DAILY, RL, LOCAL, MOCK] = process.argv.slice(2, 8);
const results = [];
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-fake-model-" + Date.now()] });
const errors = [];
const open = async (base) => {
const ctx = await browser.createBrowserContext(); const page = await ctx.newPage();
await page.setViewport({ width: 1280, height: 860 }); page.on("pageerror", (e) => errors.push(e.message));
await page.goto(base + "/", { waitUntil: "networkidle0" }); await page.waitForSelector(".card", { timeout: 15000 });
return page;
};
const ask = async (page, text) => {
const n = (await page.$$(".msg.bot")).length;
if (await page.$eval("#chat", (c) => c.classList.contains("hidden"))) await page.click("#chatLauncher");
await page.type("#chatInput", text); await page.keyboard.press("Enter");
await page.waitForFunction((n) => document.querySelectorAll(".msg.bot").length > n, { timeout: 30000 }, n);
await sleep(300);
return page.$$eval(".msg.bot", (ns) => ns[ns.length - 1].textContent);
};
const text = (page, sel) => page.$eval(sel, (n) => n.textContent);
// 1) model answers: badge + chat mode show the model; tool-hydrated cards render; unknown component ignored
let p = await open(OK);
check("badge shows configured model on load", (await text(p, "#agentBadge")) === "AI: mock-model", await text(p, "#agentBadge"));
await ask(p, "show me bikes");
const mode = await text(p, "#chatMode"), badge = await text(p, "#agentBadge"), title = await p.$eval("#agentBadge", (n) => n.title);
check("model reply: badge + chat mode", badge === "AI: mock-model" && /^AI agent · mock-model · \d+\.\ds$/.test(mode) && title === "mock-model via OpenRouter (free tier)", `${badge} | ${mode} | ${title}`);
const titles = await p.$$eval("#stage .card h3", (ns) => ns.map((n) => n.textContent));
check("model-chosen cards rendered through the registry", titles.length > 0 && titles.every((t) => /bike/i.test(t)) && (await p.$$("#stage > section")).length === 1, titles.join(" | "));
// 2) model restyle: server-sanitised set_theme applied; HTML in copy rendered as text; customCss scoped
const reply = await ask(p, "make the background a sunset gradient");
const st = await p.evaluate(() => ({ bg: getComputedStyle(document.body).backgroundImage, img: !!document.querySelector("#headline img"),
h: document.getElementById("headline").textContent, css: document.getElementById("user-css").textContent,
toast: [...document.querySelectorAll("#toasts .toast")].map((t) => t.textContent).join("|"), ls: JSON.parse(localStorage.getItem("am_theme_v1") || "{}") }));
check("model set_theme applied (gradient, toast, saved)", /gradient/.test(st.bg) && /Look updated/.test(st.toast) && st.ls.background, st.bg.slice(0, 60));
check("model copy/CSS sanitised in the page", !st.img && /Sunset/.test(st.h) && !/</.test(st.h) && /@layer user/.test(st.css) && /#page/.test(st.css) && !/@import|position/.test(st.css), st.css.replace(/\s+/g, " ").slice(0, 90));
check("no internal ids in the model reply", !/lst_/.test(reply), reply);
// 3) every model rate-limited -> honest busy message with a retry button (no rule-based answer, no cards)
p = await open(BUSY);
const cards0 = await p.$$eval("#stage .card h3", (ns) => ns.map((n) => n.textContent).join("|"));
const busy = await ask(p, "show me bikes");
check("busy reply + retry button, nothing rendered by rules",
/^The assistant is busy, please try again in a moment\./.test(busy) && (await p.$$(".msg.bot.busy .retryBtn")).length === 1 &&
(await p.$$eval("#stage .card h3", (ns) => ns.map((n) => n.textContent).join("|"))) === cards0,
`${busy} | ${await text(p, "#agentBadge")} | ${await text(p, "#chatMode")}`);
check("busy badge + chat mode text", (await text(p, "#agentBadge")) === "AI: busy" && /^Assistant busy/.test(await text(p, "#chatMode")),
`${await text(p, "#agentBadge")} | ${await text(p, "#chatMode")}`);
const calls = async () => { const c = (await (await fetch(MOCK)).json()).calls; return (c["busy-model"] || 0) + (c["busy-model-2"] || 0); };
const c0 = await calls();
const users0 = (await p.$$(".msg.user")).length;
await p.click(".msg.bot.busy .retryBtn");
await p.waitForFunction(() => !document.querySelector(".msg.sys") && document.querySelectorAll(".msg.bot.busy").length === 1, { timeout: 30000 });
await sleep(300);
check("Retry resends the same message (one busy message left, no duplicate user bubble)",
(await calls()) === c0 + 1 && (await p.$$(".msg.user")).length === users0 && (await p.$$(".msg.bot.busy")).length === 1, `calls ${c0} -> ${await calls()}`);
// 4) daily quota used up, no local model -> busy reply; after reload the badge says the quota is used up
p = await open(DAILY);
check("quota used up -> busy reply", /^The assistant is busy/.test(await ask(p, "show me bikes")));
await p.reload({ waitUntil: "networkidle0" }); await sleep(300);
check("quota badge after reload", (await text(p, "#agentBadge")) === "AI: quota used up" && /resets/.test(await p.$eval("#agentBadge", (n) => n.title)), await text(p, "#agentBadge"));
// 5) per-IP rate limit -> warning toast
p = await open(RL);
await ask(p, "show me bikes"); await ask(p, "show me bikes");
const toasts = await p.$$eval("#toasts .toast.warn", (ns) => ns.map((n) => n.textContent));
check("rate-limit warning toast", toasts.some((t) => /quickly/.test(t)), toasts.join("|"));
// 6) hosted quota used up -> the local model answers; badge says so honestly
if (LOCAL) {
p = await open(LOCAL);
await ask(p, "show me bikes");
const lb = await text(p, "#agentBadge"), lm = await text(p, "#chatMode");
check("local model badge + chat mode", lb === "Local model (local-model)" && /^Local model · local-model · \d+\.\ds$/.test(lm) && (await p.$$("#stage .card")).length > 0, `${lb} | ${lm}`);
await p.reload({ waitUntil: "networkidle0" }); await sleep(300);
check("after reload (quota used up) the badge shows the local model", (await text(p, "#agentBadge")) === "Local model (local-model)", await text(p, "#agentBadge"));
}
check("no page JS errors", errors.length === 0, errors.join("; "));
await browser.close();
const passed = results.filter(Boolean).length;
console.log(`\n${passed}/${results.length} passed`);
process.exit(passed === results.length ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(2); });