/** * 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 <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`); } }