|
| 1 | +// forge uiinteract — the OPTIONAL Playwright interaction loop (ROADMAP "Next"; |
| 2 | +// docs/plans/substrate-v2/07-ui-quality-gate.md). `uicheck visual` fingerprints what |
| 3 | +// the browser PAINTS; this checks what it DOES — keyboard reachability, a visible |
| 4 | +// focus ring, console cleanliness, and reduced-motion honesty — and feeds the verdict |
| 5 | +// back as `behavioral` oracle evidence on the project design (`fingerprint`) claim. |
| 6 | +// |
| 7 | +// `behavioral` is the ledger's weakest, cross-family-gated oracle (ledger.js ORACLES, |
| 8 | +// w=0.3): a lone interaction verdict can never move a claim on its own. That is the |
| 9 | +// point — this stays advisory until fixtures promote it (overview §4 honesty register), |
| 10 | +// the same guard-over-prose discipline as the visual gate. |
| 11 | +// |
| 12 | +// ADR-0005: this module is node stdlib only. Playwright is resolved dynamically via |
| 13 | +// uivisual.resolvePlaywright(); its absence is a graceful skip, never a failure — |
| 14 | +// exactly like `uicheck visual`. The target passes through uivisual.resolveTarget()'s |
| 15 | +// security guard (loopback-only unless --remote) BEFORE any browser is launched. |
| 16 | +import { outcomeRecord } from "./ledger.js"; |
| 17 | +import { appendEvidence, loadClaims, repoLedger } from "./ledger_store.js"; |
| 18 | +import { DEFAULT_VIEWPORTS, resolvePlaywright, resolveTarget } from "./uivisual.js"; |
| 19 | +import { contentHash } from "./util.js"; |
| 20 | + |
| 21 | +/** The interaction checks, in the order they run. Pure data so docs + tests can name them. */ |
| 22 | +export const INTERACTION_CHECK_IDS = [ |
| 23 | + "console-clean", |
| 24 | + "keyboard-reachable", |
| 25 | + "focus-visible", |
| 26 | + "reduced-motion", |
| 27 | +]; |
| 28 | + |
| 29 | +/** Aggregate per-check results into a verdict. Empty → not a pass (nothing was proven). */ |
| 30 | +export function summarizeVerdict(checks) { |
| 31 | + const list = Array.isArray(checks) ? checks : []; |
| 32 | + return { pass: list.length > 0 && list.every((c) => c?.ok === true), checks: list }; |
| 33 | +} |
| 34 | + |
| 35 | +/** |
| 36 | + * Express a verdict in the ledger's evidence vocabulary: a `behavioral` outcome that |
| 37 | + * confirms (pass) or contradicts (fail) the design claim. The ref is content-addressed |
| 38 | + * on the checks so re-running the same verdict is idempotent (appendEvidence dedupes). |
| 39 | + * @param {string} url |
| 40 | + * @param {{pass:boolean, checks:any[]}} verdict |
| 41 | + * @param {{author?:string, t?:number}} [opts] |
| 42 | + */ |
| 43 | +export function verdictOutcome(url, verdict, { author = "forge-uiinteract", t = 0 } = {}) { |
| 44 | + const ref = `ui-interact:${url}#${contentHash(JSON.stringify(verdict?.checks ?? []))}`; |
| 45 | + return outcomeRecord({ |
| 46 | + oracle: "behavioral", |
| 47 | + result: verdict?.pass ? "confirm" : "contradict", |
| 48 | + ref, |
| 49 | + author, |
| 50 | + t, |
| 51 | + }); |
| 52 | +} |
| 53 | + |
| 54 | +/** |
| 55 | + * Record a verdict as evidence on the project's design (`fingerprint`) claim. No claim |
| 56 | + * yet → a no-op with guidance (mirrors `uicheck visual`), never an error. |
| 57 | + * @param {string} root |
| 58 | + * @param {string} url |
| 59 | + * @param {{pass:boolean, checks:any[]}} verdict |
| 60 | + * @param {{author?:string, t?:number}} [opts] |
| 61 | + * @returns {{recorded:boolean, claimId?:string, reason?:string}} |
| 62 | + */ |
| 63 | +export function recordInteraction(root, url, verdict, { author = "forge-uiinteract", t = 0 } = {}) { |
| 64 | + const dir = repoLedger(root); |
| 65 | + const claim = loadClaims(dir).find((c) => c.kind === "fingerprint" && !c.tombstone); |
| 66 | + if (!claim) |
| 67 | + return { |
| 68 | + recorded: false, |
| 69 | + reason: |
| 70 | + "no project fingerprint claim — mint one with `forge uicheck fingerprint <ui files> --mint`", |
| 71 | + }; |
| 72 | + const o = verdictOutcome(url, verdict, { author, t }); |
| 73 | + if (!o.ok) return { recorded: false, reason: "reason" in o ? o.reason : "invalid outcome" }; |
| 74 | + const a = appendEvidence(dir, claim.id, o.outcome); |
| 75 | + return a?.ok ? { recorded: true, claimId: claim.id } : { recorded: false, reason: a?.reason }; |
| 76 | +} |
| 77 | + |
| 78 | +// The in-page probe: one function serialized into the browser. Returns the raw signals; |
| 79 | +// the verdict is assembled host-side so the logic stays testable without a browser. |
| 80 | +const PROBE = () => { |
| 81 | + const el = document.activeElement; |
| 82 | + const tag = el && el !== document.body ? el.tagName.toLowerCase() : null; |
| 83 | + const interactive = |
| 84 | + !!el && |
| 85 | + el !== document.body && |
| 86 | + (["a", "button", "input", "select", "textarea"].includes(tag) || |
| 87 | + el.hasAttribute("tabindex") || |
| 88 | + el.getAttribute("role") === "button"); |
| 89 | + let focusVisible = false; |
| 90 | + if (interactive) { |
| 91 | + const s = getComputedStyle(el); |
| 92 | + focusVisible = |
| 93 | + (s.outlineStyle !== "none" && parseFloat(s.outlineWidth) > 0) || |
| 94 | + (s.boxShadow && s.boxShadow !== "none"); |
| 95 | + } |
| 96 | + const running = (document.getAnimations ? document.getAnimations() : []).filter( |
| 97 | + (a) => a.playState === "running", |
| 98 | + ).length; |
| 99 | + return { tag, interactive, focusVisible, running }; |
| 100 | +}; |
| 101 | + |
| 102 | +/** |
| 103 | + * Drive `target` in a headless browser under prefers-reduced-motion and run the |
| 104 | + * interaction checks. Reuses the visual gate's Playwright resolver + target guard. |
| 105 | + * `resolve` is injectable so the skip path is testable without a browser. |
| 106 | + * @param {string} target |
| 107 | + * @param {{remote?:boolean, cwd?:string, timeoutMs?:number, resolve?:()=>Promise<any>}} [opts] |
| 108 | + * @returns {Promise<{ok:true, url:string, verdict:{pass:boolean, checks:any[]}} |
| 109 | + * | {ok:false, skipped:boolean, available?:boolean, url:string|null, reason:string}>} |
| 110 | + */ |
| 111 | +export async function runInteractions(target, opts = {}) { |
| 112 | + const { |
| 113 | + remote = false, |
| 114 | + cwd = process.cwd(), |
| 115 | + timeoutMs = 20000, |
| 116 | + resolve = resolvePlaywright, |
| 117 | + } = opts; |
| 118 | + const t = resolveTarget(target, { remote, cwd }); |
| 119 | + if (!t.ok) |
| 120 | + return { |
| 121 | + ok: false, |
| 122 | + skipped: false, |
| 123 | + url: null, |
| 124 | + reason: "reason" in t ? t.reason : "target refused", |
| 125 | + }; |
| 126 | + |
| 127 | + const pw = await resolve(); |
| 128 | + if (!pw) |
| 129 | + return { |
| 130 | + ok: false, |
| 131 | + skipped: true, |
| 132 | + available: false, |
| 133 | + url: t.url, |
| 134 | + reason: |
| 135 | + "no browser runtime — install Playwright or set FORGE_PLAYWRIGHT (interaction checks skipped)", |
| 136 | + }; |
| 137 | + |
| 138 | + let browser; |
| 139 | + try { |
| 140 | + browser = await pw.chromium.launch(); |
| 141 | + const context = await browser.newContext({ |
| 142 | + reducedMotion: "reduce", |
| 143 | + viewport: DEFAULT_VIEWPORTS?.[0] ?? { width: 1280, height: 800 }, |
| 144 | + }); |
| 145 | + const page = await context.newPage(); |
| 146 | + const consoleErrors = []; |
| 147 | + page.on("console", (m) => { |
| 148 | + if (m.type() === "error") consoleErrors.push(m.text()); |
| 149 | + }); |
| 150 | + page.on("pageerror", (e) => consoleErrors.push(String(e?.message ?? e))); |
| 151 | + |
| 152 | + await page.goto(t.url, { waitUntil: "load", timeout: timeoutMs }); |
| 153 | + await page.keyboard.press("Tab"); |
| 154 | + const p = await page.evaluate(PROBE); |
| 155 | + |
| 156 | + const checks = [ |
| 157 | + { |
| 158 | + id: "console-clean", |
| 159 | + ok: consoleErrors.length === 0, |
| 160 | + detail: consoleErrors.length |
| 161 | + ? `${consoleErrors.length} console error(s); first: ${consoleErrors[0].slice(0, 120)}` |
| 162 | + : "no console errors on load", |
| 163 | + }, |
| 164 | + { |
| 165 | + id: "keyboard-reachable", |
| 166 | + ok: p.interactive, |
| 167 | + detail: p.interactive |
| 168 | + ? `Tab reached <${p.tag}>` |
| 169 | + : "Tab did not reach an interactive control (keyboard trap or no focusable UI)", |
| 170 | + }, |
| 171 | + { |
| 172 | + id: "focus-visible", |
| 173 | + ok: p.interactive ? p.focusVisible : false, |
| 174 | + detail: p.focusVisible |
| 175 | + ? "the focused control shows a visible focus indicator" |
| 176 | + : "no visible focus ring on the focused control (WCAG 2.4.7)", |
| 177 | + }, |
| 178 | + { |
| 179 | + id: "reduced-motion", |
| 180 | + ok: p.running === 0, |
| 181 | + detail: |
| 182 | + p.running === 0 |
| 183 | + ? "no animations run under prefers-reduced-motion" |
| 184 | + : `${p.running} animation(s) still running under prefers-reduced-motion`, |
| 185 | + }, |
| 186 | + ]; |
| 187 | + return { ok: true, url: t.url, verdict: summarizeVerdict(checks) }; |
| 188 | + } catch (e) { |
| 189 | + return { |
| 190 | + ok: false, |
| 191 | + skipped: true, |
| 192 | + available: true, |
| 193 | + url: t.url, |
| 194 | + reason: `interaction run failed: ${e?.message ?? e}`, |
| 195 | + }; |
| 196 | + } finally { |
| 197 | + try { |
| 198 | + await browser?.close(); |
| 199 | + } catch {} |
| 200 | + } |
| 201 | +} |
0 commit comments