From c2815b4f7f586d40a9fca1b6d3ec05fde4e46d70 Mon Sep 17 00:00:00 2001 From: shad Date: Sun, 14 Jun 2026 17:14:09 +0400 Subject: [PATCH] fix(approvals): buyer-safe toast copy; flip runtimeHealth on action failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buyer-script QA caught two regressions: 1. Toast on action failure leaked 'runtime-values isn't reachable' and 'Backend can't accept' — engineering jargon a buyer would read as 'product is broken'. 2. After a real 500, the button stayed enabled so a buyer could click again into the same error. Now: - Failure toast says 'The runtime engine couldn't accept that decision. Action buttons are now disabled until it recovers.' (no jargon, no raw error strings). - handleAction sets runtimeHealth='down' on the failure, which flips the runtime-note banner on and disables every action button. - Non-runtime failures get the even-shorter 'Something went wrong handling that action. Try again in a moment.' --- qa/audit_buyer_script.mjs | 106 ++++++++++++++++++++++++++++++++++++++ src/scenes/Approvals.tsx | 7 +-- 2 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 qa/audit_buyer_script.mjs diff --git a/qa/audit_buyer_script.mjs b/qa/audit_buyer_script.mjs new file mode 100644 index 0000000..e47150e --- /dev/null +++ b/qa/audit_buyer_script.mjs @@ -0,0 +1,106 @@ +// Buyer-script behavioural QA. Walks every visible CTA the way a non-engineer +// buyer would, then sweeps the rendered DOM text on each scene for embarrassing +// strings ("not configured", "backend can't", "runtime-values", "provider key", +// raw 32-hex IDs). Anything that surfaces on first glance is a buyer-facing +// failure even if the underlying scaffolding is correct. +import { chromium } from "playwright"; + +const URL = "https://canvas.flow-master.ai/"; +const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"]; + +const FORBIDDEN_PATTERNS = [ + { name: "raw 32-hex IDs", re: /[a-f0-9]{32}/i }, + { name: "runtime-values raw mention", re: /runtime[_\s-]?values/i }, + { name: "action_execution_failed raw", re: /action_execution_failed/i }, + { name: "OPENAI_API_KEY raw", re: /OPENAI_API_KEY/ }, + { name: "ANTHROPIC_API_KEY raw", re: /ANTHROPIC_API_KEY/ }, + { name: "kubectl", re: /\bkubectl\b/ }, + { name: "404 raw", re: /\bHTTP 404\b/i }, + { name: "500 raw", re: /\bHTTP 500\b/i }, + { name: "stack trace", re: /at\s+\w+\s+\(.*\.ts:\d+/ }, +]; + +const results = []; +function record(name, ok, detail = "") { + results.push({ name, ok, detail }); + console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); +} + +const b = await chromium.launch({ headless: true, args }); +const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } }); +const p = await ctx.newPage(); + +async function inspectScene(label) { + await p.waitForTimeout(1200); + const body = (await p.locator("body").innerText()).toLowerCase(); + const findings = FORBIDDEN_PATTERNS + .filter((f) => f.re.test(body)) + .map((f) => `${f.name}: "${(body.match(f.re) || [""])[0]}"`) + .slice(0, 4); + record(`buyer_${label}_no_engineering_copy`, findings.length === 0, findings.length ? findings.join(" | ") : "clean"); +} + +await p.goto(URL, { waitUntil: "networkidle" }); +await p.waitForTimeout(800); +await inspectScene("login"); + +await p.locator(".persona-chip", { hasText: /Mariana/ }).click(); +await p.locator(".dev-login-btn").click(); +await p.waitForSelector(".hero-actions", { timeout: 15000 }); +await p.waitForFunction(() => document.querySelectorAll(".hub-chip").length >= 8, undefined, { timeout: 15000 }).catch(() => {}); +await inspectScene("landing"); + +const scenes = [ + { chip: /Approvals queue/, label: "approvals" }, + { chip: /^Documents$/, label: "documents" }, + { chip: /Procurement Hub/, label: "procurement_hub" }, + { chip: /People Hub/, label: "people_hub" }, + { chip: /IT Hub/, label: "it_hub" }, + { chip: /Attendance Map/, label: "geo" }, + { chip: /Command Assistant/, label: "assistant" }, + { chip: /Team Chat/, label: "chat" }, + { chip: /What is FlowMaster/, label: "explainer" }, +]; + +for (const s of scenes) { + await p.goto(URL, { waitUntil: "networkidle" }).catch(() => {}); + await p.waitForSelector(".hub-chip", { timeout: 15000 }).catch(() => {}); + await p.waitForTimeout(600); + await p.locator(".hub-chip", { hasText: s.chip }).click().catch(() => {}); + await inspectScene(s.label); +} + +// Approvals: click the first action button — if the runtime is up the button +// is enabled; if it's down it should be disabled (we should NEVER allow a +// click through to an error). Either way no embarrassing copy may surface. +await p.goto(URL, { waitUntil: "networkidle" }).catch(() => {}); +await p.waitForSelector(".hub-chip", { timeout: 15000 }).catch(() => {}); +await p.locator(".hub-chip", { hasText: /Approvals queue/ }).click(); +await p.waitForSelector(".approvals-row", { timeout: 15000 }).catch(() => {}); +await p.locator(".approvals-row").first().click(); +await p.waitForSelector(".approvals-card", { timeout: 8000 }).catch(() => {}); +await p.waitForTimeout(2500); +const btn = p.locator(".approvals-actions .btn").first(); +if (await btn.count() > 0) { + const isDisabled = await btn.isDisabled(); + if (!isDisabled) { + await btn.click(); + await p.waitForTimeout(3000); + } + const tail = (await p.locator("body").innerText()).toLowerCase(); + const tailFindings = FORBIDDEN_PATTERNS.filter((f) => f.re.test(tail)).map((f) => f.name).slice(0, 3); + record( + "buyer_approvals_action_click_no_embarrassing_copy", + tailFindings.length === 0, + tailFindings.length ? `LEAK: ${tailFindings.join(", ")} (was disabled? ${isDisabled})` : `clean (disabled? ${isDisabled})` + ); +} + +const fails = results.filter((r) => !r.ok); +console.log(`\n=== buyer script: ${results.length - fails.length}/${results.length} passed ===`); +if (fails.length) { + console.log("FAILED:"); + fails.forEach((f) => console.log(` - ${f.name}: ${f.detail}`)); +} +await b.close(); +process.exit(fails.length === 0 ? 0 : 1); diff --git a/src/scenes/Approvals.tsx b/src/scenes/Approvals.tsx index de9e10c..39c3d9e 100644 --- a/src/scenes/Approvals.tsx +++ b/src/scenes/Approvals.tsx @@ -96,10 +96,11 @@ export default function Approvals() { await loadQueue(); } catch (err: any) { const msg = err.message || ""; - if (/runtime-values|action_execution_failed/i.test(msg)) { - pushToast("err", "Backend can't accept this action yet — runtime-values isn't reachable. The frontend sent the right shape."); + if (/runtime-values|action_execution_failed|500/i.test(msg)) { + setRuntimeHealth("down"); + pushToast("err", "The runtime engine couldn't accept that decision. Action buttons are now disabled until it recovers."); } else { - pushToast("err", `Action failed: ${msg.slice(0, 100)}`); + pushToast("err", "Something went wrong handling that action. Try again in a moment."); } } finally { setBusy(null);