Tests for the React DOM: SPA shell + bundle, compare bar, chat popup, persistence, hostile stored theme, fake-model browser suite; deploy AM_EXTRA_ENV
This commit is contained in:
parent
e62c9af423
commit
b30f8ef1e2
@ -21,6 +21,9 @@ CONF=$HOME/.config/agent-market
|
|||||||
set -a
|
set -a
|
||||||
[ -f "$CONF/llm.env" ] && . "$CONF/llm.env"
|
[ -f "$CONF/llm.env" ] && . "$CONF/llm.env"
|
||||||
[ -f "$CONF/$ENVN.env" ] && . "$CONF/$ENVN.env"
|
[ -f "$CONF/$ENVN.env" ] && . "$CONF/$ENVN.env"
|
||||||
|
# Optional extra env file for offline test runs, e.g. point the LLM at tests/mock_llm.py so no real model calls are made:
|
||||||
|
# AM_EXTRA_ENV=/path/offline.env ./deploy.sh staging my-branch
|
||||||
|
[ -n "${AM_EXTRA_ENV:-}" ] && . "$AM_EXTRA_ENV"
|
||||||
set +a
|
set +a
|
||||||
# Build the React frontend (web/ -> static/dist) BEFORE stopping the running server, so a failed build
|
# Build the React frontend (web/ -> static/dist) BEFORE stopping the running server, so a failed build
|
||||||
# leaves the current version up. Reproducible: exact versions from web/package-lock.json via `npm ci`.
|
# leaves the current version up. Reproducible: exact versions from web/package-lock.json via `npm ci`.
|
||||||
|
|||||||
@ -15,6 +15,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|||||||
await page.goto(BASE + "/", { waitUntil: "networkidle0" });
|
await page.goto(BASE + "/", { waitUntil: "networkidle0" });
|
||||||
await page.waitForSelector(".card", { timeout: 15000 });
|
await page.waitForSelector(".card", { timeout: 15000 });
|
||||||
check("landing renders agent-chosen cards", (await page.$$(".card")).length >= 4);
|
check("landing renders agent-chosen cards", (await page.$$(".card")).length >= 4);
|
||||||
|
check("React app mounted in #root", await page.evaluate(() => !!document.querySelector("#root > .testbar") && !!document.querySelector("#root main#page #stage [data-component=cards]")));
|
||||||
const badge = await page.$eval("#agentBadge", (n) => n.textContent);
|
const badge = await page.$eval("#agentBadge", (n) => n.textContent);
|
||||||
check("agent mode badge shown", /fallback|built-in|AI:|LLM/.test(badge), badge);
|
check("agent mode badge shown", /fallback|built-in|AI:|LLM/.test(badge), badge);
|
||||||
|
|
||||||
@ -82,6 +83,21 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|||||||
await page.waitForSelector("[data-component=compare]", { timeout: 15000 });
|
await page.waitForSelector("[data-component=compare]", { timeout: 15000 });
|
||||||
check("compare view renders", (await page.$$("[data-component=compare] th")).length >= 3);
|
check("compare view renders", (await page.$$("[data-component=compare] th")).length >= 3);
|
||||||
|
|
||||||
|
// compare via card checkboxes + compare bar
|
||||||
|
await ask("show me everything");
|
||||||
|
await page.$$eval("#stage .card input[aria-label=compare]", (ns) => ns.slice(0, 2).forEach((n) => n.click()));
|
||||||
|
const bar = await page.$eval(".cmpbar", (n) => ({ t: n.textContent, d: getComputedStyle(n).display }));
|
||||||
|
await page.click(".cmpbar button");
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll("[data-component=compare] tr:first-child th").length === 3, { timeout: 15000 }).catch(() => {});
|
||||||
|
check("compare bar: 2 selected -> compare view", bar.d === "flex" && /^2 selected/.test(bar.t) && (await page.$$("[data-component=compare] tr:first-child th")).length === 3, bar.t);
|
||||||
|
|
||||||
|
// chat popup close / reopen
|
||||||
|
await page.click("#chatClose");
|
||||||
|
const closed = await page.evaluate(() => document.getElementById("chat").classList.contains("hidden") && getComputedStyle(document.getElementById("chatLauncher")).display !== "none");
|
||||||
|
await page.click("#chatLauncher");
|
||||||
|
const reopened = await page.evaluate(() => !document.getElementById("chat").classList.contains("hidden") && document.querySelectorAll("#log .msg").length > 5);
|
||||||
|
check("chat popup closes and reopens with history", closed && reopened);
|
||||||
|
|
||||||
check("no page JS errors", errors.length === 0, errors.join("; "));
|
check("no page JS errors", errors.length === 0, errors.join("; "));
|
||||||
await browser.close();
|
await browser.close();
|
||||||
const passed = results.filter(Boolean).length;
|
const passed = results.filter(Boolean).length;
|
||||||
|
|||||||
67
tests/fake_model_browser.js
Normal file
67
tests/fake_model_browser.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
// React UI against the fake OpenAI-compatible model (tests/mock_llm.py): model badge + chat mode text, fallback badge
|
||||||
|
// texts, 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>
|
||||||
|
const puppeteer = require("puppeteer-core");
|
||||||
|
const [OK, BUSY, DAILY, RL] = process.argv.slice(2, 6);
|
||||||
|
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 -> built-in agent answers, fallback badge text
|
||||||
|
p = await open(BUSY);
|
||||||
|
await ask(p, "show me bikes");
|
||||||
|
check("fallback badge + chat mode text", (await text(p, "#agentBadge")) === "agent: built-in (AI unavailable)" && (await text(p, "#chatMode")) === "built-in agent answered (AI model unavailable)" && (await p.$$("#stage .card")).length > 0,
|
||||||
|
`${await text(p, "#agentBadge")} | ${await text(p, "#chatMode")}`);
|
||||||
|
// 4) daily quota used up -> after reload the badge says so (from /api/status)
|
||||||
|
p = await open(DAILY);
|
||||||
|
await ask(p, "show me bikes");
|
||||||
|
await p.reload({ waitUntil: "networkidle0" }); await sleep(300);
|
||||||
|
check("quota badge after reload", (await text(p, "#agentBadge")) === "agent: built-in (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("|"));
|
||||||
|
|
||||||
|
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); });
|
||||||
15
tests/fake_model_browser.sh
Executable file
15
tests/fake_model_browser.sh
Executable file
@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Browser checks of the React UI against the fake model (no real key, no real model calls). Needs a built frontend
|
||||||
|
# (cd web && npm ci && npm run build) and puppeteer-core on NODE_PATH.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
[ -f static/dist/index.html ] || { echo "build the frontend first (cd web && npm run build)"; exit 1; }
|
||||||
|
D=$(mktemp -d); KEYF=$D/key; printf 'test-dummy-key' > $KEYF; chmod 600 $KEYF
|
||||||
|
python3 tests/mock_llm.py 18803 & M=$!
|
||||||
|
start() { # port models rpm burst
|
||||||
|
AGENT_MARKET_PORT=$1 AGENT_MARKET_DATA=$D/d$1 AGENT_MARKET_LLM_API_KEY_FILE=$KEYF AGENT_MARKET_CHAT_RPM=$3 AGENT_MARKET_CHAT_BURST=$4 \
|
||||||
|
AGENT_MARKET_LLM_BASE_URL=http://127.0.0.1:18803/v1 AGENT_MARKET_LLM_MODELS=$2 python3 server.py >/dev/null 2>&1 & echo $!; }
|
||||||
|
S1=$(start 18804 mock-model 0 6); S2=$(start 18805 busy-model,busy-model-2 0 6); S3=$(start 18806 daily-model 0 6); S4=$(start 18807 mock-model 1 1)
|
||||||
|
trap 'kill $M $S1 $S2 $S3 $S4 2>/dev/null; rm -rf $D' EXIT
|
||||||
|
sleep 1.5
|
||||||
|
node tests/fake_model_browser.js http://127.0.0.1:18804 http://127.0.0.1:18805 http://127.0.0.1:18806 http://127.0.0.1:18807
|
||||||
@ -109,6 +109,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|||||||
const h = await page.$eval("#headline", (n) => n.textContent);
|
const h = await page.$eval("#headline", (n) => n.textContent);
|
||||||
check("headline changed", /buy and sell anything nearby/i.test(h), h);
|
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.
|
// A second, fresh session must still see the default look.
|
||||||
const ctx2 = await browser.createBrowserContext(); const p2 = await ctx2.newPage();
|
const ctx2 = await browser.createBrowserContext(); const p2 = await ctx2.newPage();
|
||||||
await p2.goto(BASE + "/", { waitUntil: "networkidle0" });
|
await p2.goto(BASE + "/", { waitUntil: "networkidle0" });
|
||||||
@ -120,6 +125,22 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|||||||
const t5 = await theme(); const h2 = await page.$eval("#headline", (n) => n.textContent);
|
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("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("stored customCss sanitised (scoped to #page, no !important / custom properties)", /^@layer user/.test(hv.css) && !/!important|--p-ink/.test(hv.css) && /#page \.card\{border-width:3px\}/.test(hv.css.replace(/\s+/g, " ").replace(/ \{/g, "{")), 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)));
|
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(", ");
|
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);
|
if (process.env.REQUIRE_LLM) check("all restyles answered by the real model", replies.every((x) => /AI agent/.test(x.mode)), who);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user