fix(approvals): buyer-safe toast copy; flip runtimeHealth on action failure
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

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.'
This commit is contained in:
2026-06-14 17:14:09 +04:00
parent d4d74cb685
commit c2815b4f7f
2 changed files with 110 additions and 3 deletions
+106
View File
@@ -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);