diff --git a/.claude/skills/qa/SKILL.md b/.claude/skills/qa/SKILL.md new file mode 100644 index 0000000..52fb82f --- /dev/null +++ b/.claude/skills/qa/SKILL.md @@ -0,0 +1,68 @@ +--- +name: qa +description: Quality-assurance testing for the Carlos Arias website (carlosarias.co). Use when summarising a QA run, triaging findings, deciding severity, or fixing an issue the QA agent found (broken link, dead image, form failure, SEO/meta gap, sitemap gap, missing alt text). Explains what the site's QA covers, how severe each kind of issue is, and the gotchas of testing this specific site. +--- + +# Skill: QA for carlosarias.co + +The site is a **static Astro build** (content baked from `cja_projects` / `cja_changelog` +at build time) plus a small **PHP API** at `/api`. The only live runtime endpoints are +`POST /api/contact/submit` and `GET /api/health`; everything else is pre-rendered HTML. So +QA is HTTP-based: a crawler fetches pages and parses the served HTML — no browser, no JS +execution. + +## What a QA run checks + +| check_type | What it verifies | Default severity | +|---|---|---| +| `page` | Every route returns 200 with HTML | **error** if not 200 | +| `link` | Every internal `` resolves (200) | **error** | +| `image` | Every `` / og:image / icon loads | **error** | +| `external` | Off-site links (social, project `links.live`) reachable | **warning** | +| `form` | `/api/contact/submit` alive (honeypot probe → 200) + rejects bad input (→ 422) | error / warning | +| `health` | `/api/health` returns 200 and `db:"connected"` | **error** | +| `seo` | Each page has a `` + meta description; canonical host is carlosarias.co; titles aren't duplicated | warning (error for wrong canonical host) | +| `sitemap` | Every route appears in `sitemap.xml` | **warning** | +| `a11y` | Images have `alt`; `<html lang>` is set | **warning** | + +## Severity model + +- **error** — the site is broken for a real visitor: a page/link/image 404s, the contact + form or API is down, or canonical URLs point at the wrong host. Fix promptly. +- **warning** — degraded but working: a missing meta description, a page absent from the + sitemap, missing alt text, or an external link that looks dead. Fix when convenient. +- **info** — notes, no action needed. + +## Site-specific gotchas (important) + +- **Contact form is honeypot-probed, never really submitted.** The form has a hidden + `company` field; if it's filled, the server returns `200 {received}` and writes **nothing**. + QA fills it on purpose so the "is the endpoint alive" probe leaves no `cja_contact` row and + sends no email. A separate probe sends a too-short `message` to confirm validation returns + 422. Never treat these probes as real leads. +- **External links are warnings, not errors.** Sites like Instagram/LinkedIn frequently + return 403/429 to bots or time out — that is not proof the link is broken. Only a clear + 404/410 or DNS failure is flagged, and only as a warning. Don't over-react. +- **Known sitemap gap:** `sitemap.xml` currently omits `/projects`, `/changelog`, + `/services`, `/resume`, `/faq`. These will show as `sitemap` warnings until + `app/src/pages/sitemap.xml.js` is updated to include them. +- **Duplicate titles** usually mean a page didn't set its own `<title>` and fell back to the + site default in `BaseLayout.astro`. The fix is a page-specific title/description prop. +- **Static build:** a content fix (e.g. a project's broken `links.live`) lives in the DB + (`cja_projects`) or a page's `.astro`, and only goes live after a rebuild — which the + console handles. Don't expect a DB edit alone to change the live page. + +## Fixing a finding + +Each finding carries a `fix_hint` — a ready-made instruction. When the admin turns a finding +into a Web Designer fix task, that hint becomes the task prompt. When you (as the Web +Designer) act on it: make the smallest correct change on the named page, stay on-brand (see +the `brand` skill), and let the console build/publish. Prefer fixing the source of a bad link +(the nav/footer/component or the `cja_projects` `links` value) over patching one instance. + +## Writing the run summary + +When asked to summarise a run: 2–4 sentences, plain English, for the site owner. Lead with +overall health ("clean" / "a few warnings" / "N errors need attention"), name the most +important thing to fix first, and say whether anything is urgent (a down form or API, a 404 +on a linked page). No preamble, no restating every finding. diff --git a/agents/console/server.mjs b/agents/console/server.mjs index 9226d78..9f85c0c 100644 --- a/agents/console/server.mjs +++ b/agents/console/server.mjs @@ -595,6 +595,219 @@ async function drainTasks() { } } +// ---- QA agent ---------------------------------------------------------------- +// A deterministic HTTP crawler tests the live static site (links, images, forms, +// API, SEO/meta), stores findings via the qa-*.php CLI, and a thin LLM step +// writes a plain-English summary. Read-only against the site — it never changes +// anything; fixes only happen when the admin clicks "Create fix task". +const QA_BASE = "https://carlosarias.co"; +const QA_UA = "Mozilla/5.0 (compatible; CarlosAriasQA/1.0; +https://carlosarias.co)"; +const QA_STATIC_ROUTES = [ + "/", "/about", "/services", "/services/website-design", + "/projects", "/blog", "/contact", "/changelog", "/resume", "/faq", +]; +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 qaImgsNoAlt(html) { + return [...String(html).matchAll(/<img\b[^>]*>/gi)].map((m) => m[0]) + .filter((tag) => !/\balt\s*=/i.test(tag)) + .map((tag) => (tag.match(/\bsrc=["']([^"']+)["']/i) || [])[1]) + .filter(Boolean); +} + +async function runQa() { + const findings = []; + const add = (check_type, severity, url, detail, fix_hint = null) => + findings.push({ check_type, severity, url, detail, fix_hint }); + + // ---- route set: static + DB-driven slugs ------------------------------- + let dynamic = []; + try { dynamic = JSON.parse((await phpCli(["api/cli/qa-routes.php"])).out || "[]"); } catch {} + const routes = [...new Set([...QA_STATIC_ROUTES, ...dynamic].map(qaNorm))]; + + // ---- fetch every page, parse HTML -------------------------------------- + const pages = await pMap(routes, 6, async (path) => ({ path, r: await probe(QA_BASE + path, { readBody: true }) })); + + const fetched = new Map(); // normPath -> ok + const titles = new Map(); // title -> [paths] + const internal = new Set(); // internal target URLs + const images = new Set(); // image URLs + const external = new Map(); // external URL -> page it was found on + + 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 !== "carlosarias.co") add("seo", "error", path, `Canonical points to ${h}.`, `${path} canonical points to ${h} instead of carlosarias.co. 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 src of qaImgsNoAlt(html)) add("a11y", "warning", path, `Image without alt: ${src}`, `On ${path}, the image "${src}" has no alt text. Add descriptive alt text.`); + } + + // duplicate titles across pages + 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.`); + } + + // ---- internal links not already fetched -------------------------------- + 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.`); + }); + + // ---- images ------------------------------------------------------------ + 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.`); + }); + + // ---- external links (tolerant: only clear 404/410/DNS failures) --------- + 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.`); + }); + + // ---- contact form: two non-polluting probes ---------------------------- + 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: "qa@carlosarias.co", 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: "qa@carlosarias.co", 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.`); + + // ---- API health -------------------------------------------------------- + const health = await probe(QA_BASE + "/api/health", { readBody: true }); + // The API wraps responses in a {ok, data, error} envelope, so db is at data.db. + 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.`); + + // ---- sitemap coverage -------------------------------------------------- + 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 }; +} + +// Thin LLM step: a plain-English run summary via the qa skill (best-effort). +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 carlosarias.co. 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"); + return new Promise((resolve) => { + const child = spawn(CLAUDE, ["-p", ask, "--output-format", "json", "--allowedTools", "Read Skill", "--model", DEFAULT_MODEL], { cwd: REPO, env: agentEnv() }); + let out = ""; + child.stdout.on("data", (d) => (out += d)); + child.on("close", () => { + let s = ""; + try { const e = JSON.parse(out); if (typeof e.result === "string") s = e.result.trim().slice(0, 800); } catch {} + resolve(s || fallback); + }); + child.on("error", () => resolve(fallback)); + }); +} + +async function runQaFlow(trigger) { + if (qaRunning) return; + qaRunning = true; + let runId = 0; + try { + const start = await 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 = await qaTriage(findings, counts); + const tmp = `/tmp/qa-${runId}.json`; + writeFileSync(tmp, JSON.stringify(findings)); + await phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=done", `--summary=${summary}`, `--counts=${JSON.stringify(counts)}`, `--findings-file=${tmp}`]); + rmSync(tmp, { force: true }); + } catch (e) { + if (runId) await 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; + } +} + // ---- request helpers --------------------------------------------------------- function json(res, status, obj) { const body = JSON.stringify(obj); @@ -698,6 +911,16 @@ const server = createServer(async (req, res) => { return json(res, 200, { ok: true, draining, busy }); } + // Kick a QA run: crawl the live site, store findings. Read-only; returns + // immediately. The admin polls /api/adminqa/runs for status. + if (path === "/qa/run" && method === "POST") { + if (qaRunning) return json(res, 429, { error: "A QA run is already in progress." }); + const body = await readBody(req); + const trigger = body.trigger === "scheduled" ? "scheduled" : "manual"; + runQaFlow(trigger); // fire-and-forget + return json(res, 200, { ok: true }); + } + if (path === "/stream") { const id = url.searchParams.get("conversationId"); const job = id && jobs.get(id); diff --git a/api/cli/qa-finish.php b/api/cli/qa-finish.php new file mode 100644 index 0000000..a1d7d09 --- /dev/null +++ b/api/cli/qa-finish.php @@ -0,0 +1,64 @@ +<?php +/** + * Close a QA run: bulk-insert its findings and mark the run terminal. Called by + * the console runner after the crawl. Findings are read from a temp JSON file + * (not an arg) to stay clear of ARG_MAX. + * + * php api/cli/qa-finish.php --run=42 --status=done \ + * --summary="…" --counts='{"error":2,"warning":5,"info":10}' \ + * --findings-file=/tmp/qa-42.json + * + * findings JSON = [{ check_type, severity, url, detail, fix_hint }, ...] + */ + +require __DIR__ . '/../vendor/autoload.php'; +require __DIR__ . '/../config.php'; + +$opts = getopt('', ['run:', 'status:', 'summary::', 'counts::', 'findings-file::']); +$runId = (int) ($opts['run'] ?? 0); +$status = in_array(($opts['status'] ?? ''), ['done', 'failed'], true) ? $opts['status'] : 'done'; +$summary = (string) ($opts['summary'] ?? ''); +$counts = (string) ($opts['counts'] ?? '{}'); +$file = (string) ($opts['findings-file'] ?? ''); + +if ($runId <= 0) { + fwrite(STDERR, "usage: qa-finish.php --run=<n> --status=done|failed [--summary --counts --findings-file]\n"); + exit(1); +} +if (json_decode($counts) === null && json_last_error() !== JSON_ERROR_NONE) $counts = '{}'; + +$findings = []; +if ($file !== '' && is_readable($file)) { + $findings = json_decode((string) file_get_contents($file), true) ?: []; +} + +$pdo = new PDO( + sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME), + DB_USER, + DB_PASS, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] +); + +$allowed = ['error', 'warning', 'info']; +$ins = $pdo->prepare( + 'INSERT INTO cja_qa_findings (run_id, check_type, severity, url, detail, fix_hint) + VALUES (?, ?, ?, ?, ?, ?)' +); +$n = 0; +foreach ($findings as $f) { + $sev = in_array(($f['severity'] ?? ''), $allowed, true) ? $f['severity'] : 'info'; + $ins->execute([ + $runId, + mb_substr((string) ($f['check_type'] ?? ''), 0, 40), + $sev, + mb_substr((string) ($f['url'] ?? ''), 0, 500), + mb_substr((string) ($f['detail'] ?? ''), 0, 500), + ($f['fix_hint'] ?? null) !== null ? (string) $f['fix_hint'] : null, + ]); + $n++; +} + +$upd = $pdo->prepare('UPDATE cja_qa_runs SET status = ?, summary = ?, counts = ?, finished_at = NOW() WHERE run_id = ?'); +$upd->execute([$status, $summary, $counts, $runId]); + +echo json_encode(['ok' => true, 'run_id' => $runId, 'findings' => $n]); diff --git a/api/cli/qa-routes.php b/api/cli/qa-routes.php new file mode 100644 index 0000000..cff05ec --- /dev/null +++ b/api/cli/qa-routes.php @@ -0,0 +1,26 @@ +<?php +/** + * Print the site's dynamic route paths as a JSON array, so the QA crawler knows + * the full URL set (the sitemap is incomplete). Static pages are hard-coded in + * the crawler; this covers DB-driven slugs. + * + * php api/cli/qa-routes.php -> ["/projects/foo","/projects/bar", ...] + */ + +require __DIR__ . '/../vendor/autoload.php'; +require __DIR__ . '/../config.php'; + +$pdo = new PDO( + sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME), + DB_USER, + DB_PASS, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_COLUMN] +); + +$routes = []; +// Published project case-study pages. +foreach ($pdo->query("SELECT slug FROM cja_projects WHERE published = 1") as $slug) { + if ($slug !== '') $routes[] = "/projects/{$slug}"; +} + +echo json_encode(array_values($routes), JSON_UNESCAPED_SLASHES); diff --git a/api/cli/qa-start.php b/api/cli/qa-start.php new file mode 100644 index 0000000..b8a77ba --- /dev/null +++ b/api/cli/qa-start.php @@ -0,0 +1,25 @@ +<?php +/** + * Open a QA run and print its run_id. Called by the console runner (which has no + * DB driver) before it starts crawling. + * + * php api/cli/qa-start.php --trigger=manual # or --trigger=scheduled + */ + +require __DIR__ . '/../vendor/autoload.php'; +require __DIR__ . '/../config.php'; + +$opts = getopt('', ['trigger::']); +$trigger = in_array(($opts['trigger'] ?? ''), ['manual', 'scheduled'], true) ? $opts['trigger'] : 'manual'; + +$pdo = new PDO( + sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME), + DB_USER, + DB_PASS, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] +); + +$stmt = $pdo->prepare("INSERT INTO cja_qa_runs (status, `trigger`, started_at) VALUES ('running', ?, NOW())"); +$stmt->execute([$trigger]); + +echo json_encode(['run_id' => (int) $pdo->lastInsertId()]); diff --git a/api/db/migrations/013_create_cja_qa.sql b/api/db/migrations/013_create_cja_qa.sql new file mode 100644 index 0000000..d1bde17 --- /dev/null +++ b/api/db/migrations/013_create_cja_qa.sql @@ -0,0 +1,31 @@ +-- QA agent: automated site-test runs and their findings. +-- +-- A run is one crawl of the live site (links, images, forms, API, SEO/meta). +-- Each finding is one issue (or pass) with a severity and a fix hint that can be +-- handed to the Web Designer queue as a fix task. Same shape as cja_tasks. + +CREATE TABLE IF NOT EXISTS `cja_qa_runs` ( + `run_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `status` enum('running','done','failed') NOT NULL DEFAULT 'running', + `trigger` enum('manual','scheduled') NOT NULL DEFAULT 'manual', + `summary` text DEFAULT NULL, -- plain-English triage summary + `counts` longtext DEFAULT NULL CHECK (`counts` IS NULL OR json_valid(`counts`)), + `started_at` datetime NOT NULL DEFAULT current_timestamp(), + `finished_at` datetime DEFAULT NULL, + PRIMARY KEY (`run_id`), + KEY `idx_cja_qa_runs_recent` (`started_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `cja_qa_findings` ( + `finding_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `run_id` int(10) unsigned NOT NULL, + `check_type` varchar(40) NOT NULL DEFAULT '', -- page, link, image, external, form, health, seo, sitemap, a11y + `severity` enum('error','warning','info') NOT NULL DEFAULT 'info', + `url` varchar(500) NOT NULL DEFAULT '', + `detail` varchar(500) NOT NULL DEFAULT '', + `fix_hint` text DEFAULT NULL, -- prompt for a Web Designer fix task + `status` enum('open','fix_queued','ignored') NOT NULL DEFAULT 'open', + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`finding_id`), + KEY `idx_cja_qa_findings_run` (`run_id`, `severity`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/api/public/controllers/adminqa.php b/api/public/controllers/adminqa.php new file mode 100644 index 0000000..7a22091 --- /dev/null +++ b/api/public/controllers/adminqa.php @@ -0,0 +1,119 @@ +<?php + +use App\Controllers\PublicController; + +/** + * QA reports for the admin. The console runner performs the crawl and writes + * cja_qa_runs / cja_qa_findings; this controller reads them and, on request, + * turns a finding into a Web Designer fix task (cja_tasks) — the two queues + * chain, so a one-click fix flows through the existing build-gated designer. + * + * GET /api/adminqa/runs -> recent runs + counts + * GET /api/adminqa/findings?run=ID -> findings for a run (default latest) + * POST /api/adminqa/fix { finding_id } -> queue a Web Designer fix task + * POST /api/adminqa/ignore { finding_id } -> mark a finding ignored + * + * All admin-gated. The QA crawl itself is kicked via POST /devconsole/qa/run. + */ +class AdminQa extends PublicController +{ + public function runs(): void + { + $this->requireAdmin(); + $rows = \Db::select( + 'SELECT run_id, status, `trigger`, summary, counts, started_at, finished_at + FROM cja_qa_runs ORDER BY run_id DESC LIMIT 30' + ); + $out = array_map(function ($r) { + return [ + 'id' => (int) $r['run_id'], + 'status' => $r['status'], + 'trigger' => $r['trigger'], + 'summary' => $r['summary'] ?? '', + 'counts' => json_decode($r['counts'] ?? '{}', true) ?: [], + 'started' => $r['started_at'], + 'finished' => $r['finished_at'], + ]; + }, $rows); + $this->json(['runs' => $out]); + } + + public function findings(): void + { + $this->requireAdmin(); + $runId = (int) ($_GET['run'] ?? 0); + if ($runId <= 0) { + $runId = (int) \Db::getValue("SELECT run_id FROM cja_qa_runs WHERE status = 'done' ORDER BY run_id DESC LIMIT 1"); + } + if ($runId <= 0) { $this->json(['run' => 0, 'findings' => []]); } + + $rows = \Db::select( + "SELECT finding_id, check_type, severity, url, detail, status + FROM cja_qa_findings WHERE run_id = ? + ORDER BY FIELD(severity,'error','warning','info'), check_type, finding_id", + [$runId] + ); + $out = array_map(fn($r) => [ + 'id' => (int) $r['finding_id'], + 'check' => $r['check_type'], + 'severity' => $r['severity'], + 'url' => $r['url'], + 'detail' => $r['detail'], + 'status' => $r['status'], + ], $rows); + $this->json(['run' => $runId, 'findings' => $out]); + } + + /** Turn a finding into a queued Web Designer fix task. */ + public function fix(): void + { + $this->requireAdmin(); + $this->guardPublic('adminqa_fix', 60, 300); + + $in = json_decode((string) file_get_contents('php://input'), true) ?: []; + $fid = (int) ($in['finding_id'] ?? 0); + $f = \Db::getRow('SELECT * FROM cja_qa_findings WHERE finding_id = ?', [$fid]); + if (!$f) { $this->json(null, 404, ['code' => 'not_found', 'message' => 'No such finding.']); } + if ($f['status'] === 'fix_queued') { $this->json(null, 409, ['code' => 'already', 'message' => 'A fix is already queued for this finding.']); } + + // Best-effort target page: a clean root-relative path, else the homepage. + // The prompt (fix_hint) carries the specific instruction + file/URL. + $url = (string) $f['url']; + $target = (str_starts_with($url, '/') && !str_contains($url, ',') && !str_contains($url, '://')) + ? explode('#', explode('?', $url)[0])[0] + : '/'; + $prompt = $f['fix_hint'] !== null && $f['fix_hint'] !== '' + ? (string) $f['fix_hint'] + : "QA found a {$f['check_type']} issue on {$url}: {$f['detail']}. Investigate and fix it."; + $title = 'QA fix: ' . $f['check_type'] . ' — ' . mb_substr($url, 0, 60); + + $order = (int) \Db::getValue('SELECT COALESCE(MAX(sort_order), 0) + 10 FROM cja_tasks'); + \Db::insert('cja_tasks', [ + 'title' => mb_substr($title, 0, 200), + 'target_page' => mb_substr($target, 0, 200), + 'prompt' => $prompt, + 'draft' => '', + 'assets' => json_encode(['images' => [], 'videos' => []]), + 'status' => 'queued', + 'sort_order' => $order, + ]); + $taskId = (int) \Db::getValue('SELECT LAST_INSERT_ID()'); + + \Db::update('cja_qa_findings', ['status' => 'fix_queued'], 'finding_id = ?', [$fid]); + + // The admin page kicks POST /devconsole/tasks/run after this returns. + $this->json(['ok' => true, 'task_id' => $taskId]); + } + + public function ignore(): void + { + $this->requireAdmin(); + $in = json_decode((string) file_get_contents('php://input'), true) ?: []; + $fid = (int) ($in['finding_id'] ?? 0); + if (!\Db::getValue('SELECT finding_id FROM cja_qa_findings WHERE finding_id = ?', [$fid])) { + $this->json(null, 404, ['code' => 'not_found', 'message' => 'No such finding.']); + } + \Db::update('cja_qa_findings', ['status' => 'ignored'], 'finding_id = ?', [$fid]); + $this->json(['ok' => true]); + } +} diff --git a/app/src/layouts/AdminLayout.astro b/app/src/layouts/AdminLayout.astro index 10b61fa..d2eafad 100644 --- a/app/src/layouts/AdminLayout.astro +++ b/app/src/layouts/AdminLayout.astro @@ -60,6 +60,7 @@ const { title = "Admin" } = Astro.props; <span class="name">Carlos Arias · Admin</span> <nav> <a href="/admin/designer">Web Designer</a> + <a href="/admin/qa">QA</a> <a href="/admin/projects">Projects</a> <a href="/" target="_blank">View site ↗</a> <button type="button" data-logout>Sign out</button> diff --git a/app/src/pages/admin/qa.astro b/app/src/pages/admin/qa.astro new file mode 100644 index 0000000..0a46272 --- /dev/null +++ b/app/src/pages/admin/qa.astro @@ -0,0 +1,196 @@ +--- +import AdminLayout from "../../layouts/AdminLayout.astro"; +--- + +<AdminLayout title="QA"> + <div style="display:flex;align-items:baseline;gap:1.25rem"> + <h1>QA</h1> + <button type="button" class="btn" id="runBtn" style="margin-left:auto">Run QA now</button> + </div> + <p class="lead">Automated checks against the live site — links, images, the contact form, the API, and SEO. Turn any finding into a Web Designer fix with one click.</p> + + <p class="msg" id="msg" role="status" aria-live="polite"></p> + + <section id="runWrap" class="run" hidden> + <div class="run-head"> + <span class="run-when" id="runWhen"></span> + <span class="pills" id="runPills"></span> + </div> + <p class="run-summary" id="runSummary"></p> + </section> + + <div id="findings" class="findings">Loading…</div> + + <details class="history"> + <summary>Previous runs</summary> + <div id="history"></div> + </details> + + <style> + .run { border:1px solid var(--rule); border-radius:2px; padding:1.25rem 1.4rem; margin:1.5rem 0; } + .run-head { display:flex; align-items:center; gap:1rem; margin-bottom:.6rem; } + .run-when { font-family:var(--mono); font-size:.65rem; letter-spacing:.08em; text-transform:uppercase; color:var(--ink4); } + .pills { margin-left:auto; display:flex; gap:.5rem; } + .pill { font-family:var(--mono); font-size:.6rem; letter-spacing:.08em; text-transform:uppercase; padding:.2rem .55rem; border-radius:2px; border:1px solid var(--rule); color:var(--ink3); } + .pill.error { color:var(--seal); border-color:var(--seal); } + .pill.ok { color:var(--ink); } + .run-summary { margin:0; font-size:.95rem; line-height:1.6; color:var(--ink2); } + + .findings { display:flex; flex-direction:column; } + .grp { margin-top:1.75rem; } + .grp h2 { font-family:var(--mono); font-size:.62rem; letter-spacing:.14em; text-transform:uppercase; color:var(--ink4); margin:0 0 .5rem; } + .fnd { display:flex; align-items:flex-start; gap:.9rem; padding:.85rem 0; border-top:1px solid var(--rule); } + .fnd:last-child { border-bottom:1px solid var(--rule); } + .sev { flex:0 0 auto; margin-top:.15rem; width:.5rem; height:.5rem; border-radius:50%; background:var(--ink4); } + .sev.error { background:var(--seal); } .sev.warning { background:var(--ink3); } + .fnd .body { flex:1; min-width:0; } + .fnd .top { display:flex; gap:.6rem; align-items:baseline; flex-wrap:wrap; } + .fnd .ck { font-family:var(--mono); font-size:.6rem; letter-spacing:.08em; text-transform:uppercase; color:var(--ink4); } + .fnd .url { font-family:var(--mono); font-size:.78rem; color:var(--ink2); word-break:break-all; } + .fnd .detail { margin:.25rem 0 0; font-size:.88rem; color:var(--ink3); } + .fnd .acts { flex:0 0 auto; display:flex; gap:.5rem; align-items:center; } + .fnd .acts button { font-family:var(--mono); font-size:.58rem; letter-spacing:.06em; text-transform:uppercase; + background:none; border:1px solid var(--rule); border-radius:2px; padding:.35rem .6rem; color:var(--ink3); cursor:pointer; } + .fnd .acts button.fix:hover { color:var(--paper); background:var(--ink); border-color:var(--ink); } + .fnd .acts button.ignore:hover { color:var(--seal); } + .fnd .state { font-family:var(--mono); font-size:.58rem; letter-spacing:.06em; text-transform:uppercase; color:var(--ink4); } + .clean { padding:2rem 0; color:var(--ink3); } + .history { margin-top:2.5rem; } + .history summary { font-family:var(--mono); font-size:.62rem; letter-spacing:.12em; text-transform:uppercase; color:var(--ink4); cursor:pointer; } + .history .hrow { display:flex; gap:1rem; padding:.6rem 0; border-top:1px solid var(--rule); font-size:.85rem; } + .history .hrow .hw { font-family:var(--mono); font-size:.62rem; color:var(--ink4); } + </style> + + <script> + const $ = (id) => document.getElementById(id); + const msg = $("msg"); + const setMsg = (t, cls = "") => { msg.textContent = t; msg.className = "msg " + cls; }; + let polling = null; + + const SEV_ORDER = ["error", "warning", "info"]; + const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); + + $("runBtn").addEventListener("click", async () => { + const btn = $("runBtn"); btn.disabled = true; + setMsg("Running QA against the live site… this takes a moment."); + try { + const r = await fetch("/devconsole/qa/run", { method: "POST", credentials: "same-origin" }); + if (r.status === 429) { setMsg("A QA run is already in progress.", "error"); btn.disabled = false; return; } + // poll for a fresh completed run + pollRuns(true); + } catch { setMsg("Couldn't start the QA run.", "error"); btn.disabled = false; } + }); + + async function pollRuns(waitForNew = false) { + if (polling) clearInterval(polling); + let seen = waitForNew ? await latestRunId() : null; + const tick = async () => { + const runs = await getRuns(); + renderHistory(runs); + const latest = runs[0]; + if (!latest) return; + if (waitForNew && latest.status !== "done") { setMsg("Running QA…"); return; } + if (waitForNew && latest.id === seen && latest.status === "done") { /* still old */ return; } + // a done run is available + renderRun(latest); + await renderFindings(latest.id); + setMsg(""); + $("runBtn").disabled = false; + if (latest.status === "done") { clearInterval(polling); polling = null; } + }; + polling = setInterval(tick, 4000); + tick(); + } + async function latestRunId() { const r = await getRuns(); return r[0]?.id ?? null; } + async function getRuns() { + try { const r = await fetch("/api/adminqa/runs", { credentials: "same-origin" }); return (await r.json())?.data?.runs || []; } + catch { return []; } + } + + function renderRun(run) { + $("runWrap").hidden = false; + $("runWhen").textContent = (run.trigger === "scheduled" ? "Scheduled · " : "") + (run.finished || run.started || ""); + const c = run.counts || {}; + const pills = []; + pills.push(`<span class="pill ${c.error ? "error" : "ok"}">${c.error || 0} error${c.error === 1 ? "" : "s"}</span>`); + pills.push(`<span class="pill">${c.warning || 0} warning${c.warning === 1 ? "" : "s"}</span>`); + pills.push(`<span class="pill">${c.pages || 0} pages</span>`); + $("runPills").innerHTML = pills.join(""); + $("runSummary").textContent = run.summary || ""; + } + + async function renderFindings(runId) { + const el = $("findings"); + let data; + try { data = (await (await fetch("/api/adminqa/findings?run=" + runId, { credentials: "same-origin" })).json())?.data; } + catch { el.textContent = "Couldn't load findings."; return; } + const findings = (data?.findings || []).filter((f) => f.status !== "ignored"); + if (!findings.length) { el.innerHTML = `<p class="clean">✓ No open issues in this run.</p>`; return; } + el.innerHTML = ""; + for (const sev of SEV_ORDER) { + const group = findings.filter((f) => f.severity === sev); + if (!group.length) continue; + const wrap = document.createElement("div"); + wrap.className = "grp"; + wrap.innerHTML = `<h2>${sev} · ${group.length}</h2>`; + for (const f of group) { + const row = document.createElement("div"); + row.className = "fnd"; + const acts = f.status === "fix_queued" + ? `<span class="state">fix queued</span>` + : `<button class="fix" data-fix="${f.id}">Create fix task</button><button class="ignore" data-ignore="${f.id}">Ignore</button>`; + row.innerHTML = ` + <span class="sev ${f.severity}"></span> + <div class="body"> + <div class="top"><span class="ck">${esc(f.check)}</span><span class="url">${esc(f.url)}</span></div> + <p class="detail">${esc(f.detail)}</p> + </div> + <div class="acts">${acts}</div>`; + const fixBtn = row.querySelector("[data-fix]"); + if (fixBtn) fixBtn.addEventListener("click", () => createFix(f.id, runId)); + const igBtn = row.querySelector("[data-ignore]"); + if (igBtn) igBtn.addEventListener("click", () => ignore(f.id, runId)); + wrap.appendChild(row); + } + el.appendChild(wrap); + } + } + + async function createFix(id, runId) { + try { + const r = await fetch("/api/adminqa/fix", { + method: "POST", credentials: "same-origin", + headers: { "content-type": "application/json" }, body: JSON.stringify({ finding_id: id }), + }); + const d = await r.json(); + if (!r.ok || !d?.data?.ok) { setMsg(d?.error?.message || "Couldn't queue the fix.", "error"); return; } + await fetch("/devconsole/tasks/run", { method: "POST", credentials: "same-origin" }); + setMsg("Fix queued — the Web Designer is on it.", "ok"); + renderFindings(runId); + } catch { setMsg("Couldn't queue the fix.", "error"); } + } + async function ignore(id, runId) { + try { + await fetch("/api/adminqa/ignore", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ finding_id: id }) }); + renderFindings(runId); + } catch {} + } + + function renderHistory(runs) { + $("history").innerHTML = runs.slice(0, 15).map((r) => { + const c = r.counts || {}; + return `<div class="hrow"><span class="hw">${esc(r.finished || r.started || "")}</span><span>${c.error || 0} err · ${c.warning || 0} warn · ${r.status}</span></div>`; + }).join(""); + } + + // initial load: show the latest completed run + (async () => { + const runs = await getRuns(); + renderHistory(runs); + const latest = runs[0]; + if (latest && latest.status === "done") { renderRun(latest); await renderFindings(latest.id); } + else if (latest && latest.status === "running") { setMsg("A QA run is in progress…"); pollRuns(false); } + else { $("findings").innerHTML = `<p class="clean">No QA runs yet — click “Run QA now”.</p>`; } + })(); + </script> +</AdminLayout>