/**
* 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(/
]*>/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>/i, html);
const desc = qaFirst(/]+name=["']description["'][^>]+content=["']([^"']*)["']/i, html);
const canon = qaFirst(/]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i, html);
if (!title) add("seo", "warning", path, "Missing .", `${path} has no . 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 (!/]+lang=/i.test(html)) add("a11y", "warning", path, " has no lang attribute.", `${path} tag has no lang attribute. Add lang="en".`);
for (const href of qaGrabAll(/]*\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(/
]*\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}"): ${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>/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`);
}
}