Phase 2. host + agent modules no longer hardcode identity: - site.mjs derives name/url/host/model/agentUser/home/role/qaEmail from site.config.json + astroagent.config.json - host uses site.model/site.tools/site.home; QA uses site.url/host/qaEmail/routes - personas templated from site.*; site-specific brand wording (sumi-e, .ca-*, 'law firms') dropped — brand now comes only from brand/BRAND.md + the brand skill Core (host.mjs + agents/*) is now site-agnostic; identity lives in config.
233 lines
13 KiB
JavaScript
233 lines
13 KiB
JavaScript
/**
|
|
* QA agent — a deterministic HTTP crawler that tests the live static site (links,
|
|
* images, forms, API, SEO/meta, a11y), stores findings via the qa-*.php CLI, and
|
|
* a thin LLM step writes a summary. Read-only against the site. Auto-fixes safe
|
|
* findings by kicking the Web Designer queue (host.drainTasks). Owns: /qa/run +
|
|
* an in-process heartbeat.
|
|
*/
|
|
|
|
import { writeFileSync, rmSync } from "node:fs";
|
|
import { site } from "../site.mjs";
|
|
|
|
let H;
|
|
|
|
const QA_BASE = site.url;
|
|
const QA_UA = `Mozilla/5.0 (compatible; AstroAgentQA/1.0; +${site.url})`;
|
|
const QA_STATIC_ROUTES = site.qaRoutes;
|
|
const QA_AUTOFIX = (process.env.QA_AUTOFIX ?? "1") !== "0";
|
|
const QA_HEARTBEAT_MIN = Number(process.env.QA_HEARTBEAT_MIN || 60);
|
|
let qaRunning = false;
|
|
|
|
async function probe(url, { method = "GET", readBody = false, timeout = 12000, headers = {}, body = null } = {}) {
|
|
const ctrl = new AbortController();
|
|
const t = setTimeout(() => ctrl.abort(), timeout);
|
|
try {
|
|
const r = await fetch(url, { method, redirect: "follow", signal: ctrl.signal, headers: { "user-agent": QA_UA, ...headers }, body });
|
|
let text = null;
|
|
if (readBody) text = await r.text();
|
|
else { try { await r.body?.cancel(); } catch {} }
|
|
return { status: r.status, ok: r.ok, finalUrl: r.url, text };
|
|
} catch (e) {
|
|
return { status: 0, ok: false, error: e.name === "AbortError" ? "timeout" : (e.message || "network error") };
|
|
} finally { clearTimeout(t); }
|
|
}
|
|
|
|
async function pMap(items, concurrency, fn) {
|
|
const out = []; let i = 0;
|
|
const workers = Array.from({ length: Math.min(concurrency, items.length || 1) }, async () => {
|
|
while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); }
|
|
});
|
|
await Promise.all(workers);
|
|
return out;
|
|
}
|
|
|
|
const qaNorm = (p) => { p = String(p).split("#")[0].split("?")[0]; if (p.length > 1) p = p.replace(/\/+$/, ""); return p || "/"; };
|
|
const qaAbs = (href, pagePath) => { try { return new URL(href, QA_BASE + pagePath).href; } catch { return null; } };
|
|
const qaGrabAll = (re, html) => [...String(html).matchAll(re)].map((m) => m[1]);
|
|
const qaFirst = (re, html) => { const m = String(html).match(re); return m ? m[1].trim() : ""; };
|
|
function qaAltIssues(html) {
|
|
const out = [];
|
|
for (const m of String(html).matchAll(/<img\b[^>]*>/gi)) {
|
|
const tag = m[0];
|
|
const src = (tag.match(/\bsrc=["']([^"']+)["']/i) || [])[1];
|
|
if (!src) continue;
|
|
const withVal = tag.match(/\salt\s*=\s*["']([^"']*)["']/i);
|
|
if (withVal) { if (withVal[1].trim() === "") out.push({ src, kind: "empty" }); }
|
|
else if (/\salt(\s|>|\/)/i.test(tag)) out.push({ src, kind: "empty" });
|
|
else out.push({ src, kind: "missing" });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function runQa() {
|
|
const findings = [];
|
|
const add = (check_type, severity, url, detail, fix_hint = null) => findings.push({ check_type, severity, url, detail, fix_hint });
|
|
|
|
let dynamic = [];
|
|
try { dynamic = JSON.parse((await H.phpCli(["api/cli/qa-routes.php"])).out || "[]"); } catch {}
|
|
const routes = [...new Set([...QA_STATIC_ROUTES, ...dynamic].map(qaNorm))];
|
|
|
|
const pages = await pMap(routes, 6, async (path) => ({ path, r: await probe(QA_BASE + path, { readBody: true }) }));
|
|
|
|
const fetched = new Map();
|
|
const titles = new Map();
|
|
const internal = new Set();
|
|
const images = new Set();
|
|
const external = new Map();
|
|
|
|
for (const { path, r } of pages) {
|
|
fetched.set(path, r.ok);
|
|
if (!r.ok) {
|
|
add("page", "error", path, `Returns ${r.status || r.error} instead of 200.`,
|
|
`${path} returns ${r.status || r.error} instead of 200. Investigate why the page fails to render and fix it.`);
|
|
continue;
|
|
}
|
|
const html = r.text || "";
|
|
const title = qaFirst(/<title[^>]*>([^<]*)<\/title>/i, html);
|
|
const desc = qaFirst(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i, html);
|
|
const canon = qaFirst(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i, html);
|
|
if (!title) add("seo", "warning", path, "Missing <title>.", `${path} has no <title>. Add a page-specific title via its BaseLayout props.`);
|
|
else { if (!titles.has(title)) titles.set(title, []); titles.get(title).push(path); }
|
|
if (!desc) add("seo", "warning", path, "Missing meta description.", `${path} has no meta description. Add a page-specific description via its BaseLayout props.`);
|
|
if (canon) { try { const h = new URL(canon).host; if (h && h !== site.host) add("seo", "error", path, `Canonical points to ${h}.`, `${path} canonical points to ${h} instead of ${site.host}. Fix the site URL / canonical.`); } catch {} }
|
|
if (!/<html[^>]+lang=/i.test(html)) add("a11y", "warning", path, "<html> has no lang attribute.", `${path} <html> tag has no lang attribute. Add lang="en".`);
|
|
|
|
for (const href of qaGrabAll(/<a\b[^>]*\bhref=["']([^"']+)["']/gi, html)) {
|
|
if (/^(mailto:|tel:|javascript:|#|data:)/i.test(href)) continue;
|
|
const u = qaAbs(href, path); if (!u) continue;
|
|
const noHash = u.split("#")[0];
|
|
if (noHash.startsWith(QA_BASE)) internal.add(noHash);
|
|
else if (/^https?:\/\//i.test(noHash) && !external.has(noHash)) external.set(noHash, path);
|
|
}
|
|
for (const src of qaGrabAll(/<img\b[^>]*\bsrc=["']([^"']+)["']/gi, html)) { const u = qaAbs(src, path); if (u && /^https?:/i.test(u)) images.add(u.split("#")[0]); }
|
|
for (const a of qaAltIssues(html)) {
|
|
if (a.kind === "empty") add("a11y", "warning", path, `Empty alt: ${a.src}`,
|
|
`On ${path}, the image "${a.src}" has an empty alt attribute. If it conveys meaning (a photo, cover, or screenshot), add descriptive alt text that explains what it shows; leave it empty only if it is purely decorative.`);
|
|
else add("a11y", "warning", path, `Missing alt: ${a.src}`,
|
|
`On ${path}, the image "${a.src}" has no alt attribute. Add descriptive alt text that explains what it shows.`);
|
|
}
|
|
}
|
|
|
|
for (const [title, paths] of titles) {
|
|
if (paths.length > 1) add("seo", "warning", paths.join(", "), `${paths.length} pages share the title "${title}".`,
|
|
`These pages share one <title> ("${title}"): ${paths.join(", ")}. Give each a distinct, page-specific title.`);
|
|
}
|
|
|
|
const internalPaths = [...new Set([...internal].map((u) => qaNorm(u.replace(QA_BASE, "") || "/")))];
|
|
const toCheck = internalPaths.filter((p) => !fetched.has(p));
|
|
await pMap(toCheck, 8, async (p) => {
|
|
const r = await probe(QA_BASE + p, {});
|
|
if (!r.ok) add("link", "error", p, `Broken internal link (${r.status || r.error}).`,
|
|
`An internal link points to ${p}, which returns ${r.status || r.error}. Find that link in the page source and fix the URL or remove the link.`);
|
|
});
|
|
|
|
await pMap([...images], 8, async (u) => {
|
|
const r = await probe(u, {});
|
|
if (!r.ok) add("image", "error", u, `Image returns ${r.status || r.error}.`,
|
|
`The image ${u} returns ${r.status || r.error}. Fix the image path or replace the image.`);
|
|
});
|
|
|
|
await pMap([...external.keys()], 6, async (u) => {
|
|
const r = await probe(u, { method: "GET", timeout: 12000 });
|
|
const clearlyBad = r.status === 404 || r.status === 410 || (r.status === 0 && r.error && r.error !== "timeout");
|
|
if (clearlyBad) add("external", "warning", u, `External link may be broken (${r.status || r.error}); found on ${external.get(u)}.`,
|
|
`The external link ${u} (on ${external.get(u)}) appears broken (${r.status || r.error}). Verify it and update or remove it.`);
|
|
});
|
|
|
|
const cHeaders = { "content-type": "application/json", origin: QA_BASE, referer: QA_BASE + "/contact" };
|
|
const hp = await probe(QA_BASE + "/api/contact/submit", {
|
|
method: "POST", readBody: true, headers: cHeaders,
|
|
body: JSON.stringify({ name: "QA Bot", email: site.qaEmail, subject: "other", message: "QA honeypot probe — please ignore.", company: "qa-honeypot" }),
|
|
});
|
|
if (hp.status !== 200) add("form", "error", "/api/contact/submit", `Contact honeypot probe returned ${hp.status || hp.error} (expected 200).`,
|
|
`POST /api/contact/submit returned ${hp.status || hp.error} instead of 200 for a probe. The contact form endpoint may be broken — check api/public/controllers/contact.php.`);
|
|
const val = await probe(QA_BASE + "/api/contact/submit", {
|
|
method: "POST", readBody: true, headers: cHeaders,
|
|
body: JSON.stringify({ name: "QA", email: site.qaEmail, subject: "other", message: "hi" }),
|
|
});
|
|
if (val.status !== 422) add("form", "warning", "/api/contact/submit", `Validation probe returned ${val.status || val.error} (expected 422 for a too-short message).`,
|
|
`POST /api/contact/submit did not reject an invalid submission (got ${val.status || val.error}, expected 422). Server-side validation may be off.`);
|
|
|
|
const health = await probe(QA_BASE + "/api/health", { readBody: true });
|
|
let dbOk = false; try { const j = JSON.parse(health.text || "{}"); dbOk = (j.data?.db ?? j.db) === "connected"; } catch {}
|
|
if (health.status !== 200 || !dbOk) add("health", "error", "/api/health", `Status ${health.status || health.error}, db ${dbOk ? "connected" : "not connected"}.`,
|
|
`/api/health returned ${health.status || health.error}${dbOk ? "" : " and the database is not connected"}. The API or database may be down.`);
|
|
|
|
const sm = await probe(QA_BASE + "/sitemap.xml", { readBody: true });
|
|
if (sm.ok) {
|
|
const locs = new Set(qaGrabAll(/<loc>([^<]+)<\/loc>/gi, sm.text || "").map((l) => qaNorm(l.replace(QA_BASE, ""))));
|
|
for (const p of routes) if (!locs.has(p)) add("sitemap", "warning", p, "Not listed in sitemap.xml.",
|
|
`${p} is not in sitemap.xml. Add it in app/src/pages/sitemap.xml.js so search engines can find it.`);
|
|
} else {
|
|
add("sitemap", "warning", "/sitemap.xml", `sitemap.xml returned ${sm.status || sm.error}.`, `/sitemap.xml is unreachable (${sm.status || sm.error}). Check app/src/pages/sitemap.xml.js.`);
|
|
}
|
|
|
|
const counts = { error: 0, warning: 0, info: 0, pages: pages.length, links: toCheck.length, images: images.size, external: external.size };
|
|
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
return { findings, counts };
|
|
}
|
|
|
|
async function qaTriage(findings, counts) {
|
|
const top = [
|
|
...findings.filter((f) => f.severity === "error").slice(0, 12),
|
|
...findings.filter((f) => f.severity === "warning").slice(0, 12),
|
|
];
|
|
const lines = top.map((f) => `- [${f.severity}] ${f.check_type} ${f.url}: ${f.detail}`).join("\n") || "(no issues found)";
|
|
const fallback = `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`;
|
|
const ask = [
|
|
`You are the QA agent for ${site.host}. A crawler just tested the live site. Consult your qa skill.`,
|
|
`Counts: ${counts.error} errors, ${counts.warning} warnings across ${counts.pages} pages.`,
|
|
"Top findings:", lines,
|
|
"",
|
|
"Write a 2-4 sentence plain-English summary for the site owner: overall health, the most important things to fix first, and whether anything is urgent. No preamble — just the summary.",
|
|
].join("\n");
|
|
const { ok, result } = await H.runClaudeJson({ prompt: ask, tools: "Read Skill" });
|
|
const s = ok && typeof result === "string" ? result.trim().slice(0, 800) : "";
|
|
return s || fallback;
|
|
}
|
|
|
|
async function runQaFlow(trigger) {
|
|
if (qaRunning) return;
|
|
qaRunning = true;
|
|
let runId = 0;
|
|
try {
|
|
const start = await H.phpCli(["api/cli/qa-start.php", `--trigger=${trigger}`]);
|
|
runId = JSON.parse(start.out || "{}").run_id || 0;
|
|
if (!runId) throw new Error("could not open a QA run");
|
|
const { findings, counts } = await runQa();
|
|
const summary = (trigger === "manual" || counts.error > 0)
|
|
? await qaTriage(findings, counts)
|
|
: `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`;
|
|
const tmp = `/tmp/qa-${runId}.json`;
|
|
writeFileSync(tmp, JSON.stringify(findings));
|
|
await H.phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=done", `--summary=${summary}`, `--counts=${JSON.stringify(counts)}`, `--findings-file=${tmp}`]);
|
|
rmSync(tmp, { force: true });
|
|
|
|
if (QA_AUTOFIX) {
|
|
const af = await H.phpCli(["api/cli/qa-autofix.php", `--run=${runId}`]);
|
|
let queued = 0; try { queued = JSON.parse(af.out || "{}").queued || 0; } catch {}
|
|
if (queued > 0 && H.drainTasks) H.drainTasks(); // fire-and-forget
|
|
}
|
|
} catch (e) {
|
|
if (runId) await H.phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=failed", `--summary=QA run failed: ${String((e && e.message) || e).slice(0, 180)}`]);
|
|
} finally {
|
|
qaRunning = false;
|
|
}
|
|
}
|
|
|
|
export function register(host) {
|
|
H = host;
|
|
|
|
host.route("POST", "/qa/run", async (req, res) => {
|
|
if (qaRunning) return H.json(res, 429, { error: "A QA run is already in progress." });
|
|
const body = await H.readBody(req);
|
|
const trigger = body.trigger === "scheduled" ? "scheduled" : "manual";
|
|
runQaFlow(trigger); // fire-and-forget
|
|
H.json(res, 200, { ok: true });
|
|
});
|
|
|
|
if (QA_HEARTBEAT_MIN > 0) {
|
|
host.everyMinutes(QA_HEARTBEAT_MIN, () => runQaFlow("scheduled"), { bootDelayMs: 90_000 });
|
|
console.log(`[qa] heartbeat every ${QA_HEARTBEAT_MIN} min`);
|
|
}
|
|
}
|