fix(wizard): include request_id in apply-batch payload

Backend EA2 apply-batch requires a request_id (idempotency key). Without
it, every node/edge write after Intake was failing with HTTP 422 and the
wizard stayed stuck on the Analyze phase.

Verified via qa/audit_wizard_writes.mjs against the live URL: previously
1 successful POST (draft creation) + 6x 422, now full multi-phase
write path enabled.
This commit is contained in:
2026-06-14 12:08:26 +04:00
parent 89fca578d9
commit fab3b988b4
2 changed files with 129 additions and 2 deletions
+120
View File
@@ -0,0 +1,120 @@
// EA2 step-write audit: drive the live wizard like a human, capture
// every /api/ea2/* + /api/runtime/* POST/PUT, and report which Wizard
// phase actually hit the backend.
import { chromium } from "playwright";
const URL = "https://canvas.flow-master.ai/";
const b = await chromium.launch({
headless: true,
args: ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"],
});
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
const writes = [];
p.on("request", (req) => {
const u = req.url();
const m = req.method();
if ((m === "POST" || m === "PUT") && /\/api\/(ea2|runtime)\//.test(u)) {
writes.push({ method: m, path: u.replace("https://canvas.flow-master.ai", ""), at: Date.now() });
}
});
p.on("response", async (res) => {
const u = res.url();
if (/\/api\/(ea2|runtime)\//.test(u) && (res.request().method() === "POST" || res.request().method() === "PUT")) {
let body = "";
try { body = (await res.text()).slice(0, 200); } catch {}
console.log(`[resp ${res.status()}] ${res.request().method()} ${u.replace("https://canvas.flow-master.ai", "")}${body}`);
}
});
await p.goto(URL, { waitUntil: "networkidle" });
await p.waitForTimeout(1500);
await p.screenshot({ path: "qa/screenshots/audit/01-landing.png" });
await p.locator("input[type='email']").fill("dev@flow-master.ai");
await p.locator("button", { hasText: /developer|dev-login/i }).first().click();
await p.waitForTimeout(2500);
await p.screenshot({ path: "qa/screenshots/audit/02-after-login.png" });
console.log("[after login] visible buttons:");
for (const b of await p.locator("button").all()) {
const t = (await b.textContent())?.trim();
if (t) console.log(" -", t.slice(0, 60));
}
// Flip to LIVE mode (button labelled "Go live" on landing)
const liveBtn = p.locator("button", { hasText: /^Go live$/ }).first();
if (await liveBtn.count() > 0) {
await liveBtn.click();
await p.waitForTimeout(1200);
console.log("[step] clicked Go live");
}
console.log("[after Go live] visible buttons:");
for (const b of await p.locator("button").all()) {
const t = (await b.textContent())?.trim();
if (t) console.log(" -", t.slice(0, 70));
}
// Open the wizard
const designBtn = p.locator("button", { hasText: /Design a process/ }).first();
if (await designBtn.count() > 0) {
await designBtn.click();
await p.waitForTimeout(1500);
console.log("[step] clicked Design a process");
} else {
console.log("[warn] no 'Design a process' button found after Go live");
}
await p.screenshot({ path: "qa/screenshots/audit/03-wizard.png" });
console.log("[after Design] body excerpt:");
const body = await p.locator("body").innerText();
console.log(body.slice(0, 500));
console.log("=== writes during Wizard navigation only ===");
const baselineWrites = writes.length;
writes.slice().forEach((w) => console.log(` ${w.method} ${w.path}`));
// Type into the textarea (description), which is what gates Start Drafting
const desc = p.locator("textarea").first();
if (await desc.count() > 0) {
await desc.fill("When a store manager requests a new laptop, run a procurement approval");
console.log("\n[step] typed description into textarea");
} else {
console.log("\n[warn] no textarea found");
}
const nameInput = p.locator("input[type='text']").first();
if (await nameInput.count() > 0) {
await nameInput.fill("Laptop Procurement");
}
await p.waitForTimeout(400);
const continueBtn = p.locator("button:not([disabled])", { hasText: /start drafting|continue|next|generate|analyze|confirm|publish/i }).first();
if (await continueBtn.count() > 0) {
console.log(`[step] clicking "${(await continueBtn.textContent())?.trim()}"`);
await continueBtn.click();
await p.waitForTimeout(3500);
}
console.log(`\n=== writes after first 'Continue' (${writes.length - baselineWrites} new) ===`);
writes.slice(baselineWrites).forEach((w) => console.log(` ${w.method} ${w.path}`));
// Try to click through more phases
for (let i = 0; i < 6; i++) {
const next = p.locator("button:not([disabled])", { hasText: /confirm|continue|next|publish|save|accept|review/i }).first();
if (await next.count() === 0) break;
console.log(`[loop ${i}] clicking "${(await next.textContent())?.trim()}"`);
const before = writes.length;
await next.click().catch(() => {});
await p.waitForTimeout(2500);
if (writes.length === before) break;
}
console.log(`\n=== TOTAL ${writes.length} writes across full wizard run ===`);
writes.forEach((w) => console.log(` ${w.method} ${w.path}`));
console.log("\n=== SUMMARY ===");
const summary = writes.reduce((acc, w) => {
const key = `${w.method} ${w.path.split("?")[0].replace(/\/[a-f0-9]{8,}/g, "/{id}")}`;
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
console.log(JSON.stringify(summary, null, 2));
await b.close();
+9 -2
View File
@@ -61,16 +61,23 @@ export const wizardApi = {
}, },
async applyBatch(flow_key: string, ops: BatchOperation[], actor: Actor, signal?: AbortSignal) { async applyBatch(flow_key: string, ops: BatchOperation[], actor: Actor, signal?: AbortSignal) {
const request_id =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `req-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, { const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`, Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ flow_key, ops, actor }), body: JSON.stringify({ flow_key, ops, actor, request_id }),
signal, signal,
}); });
if (!res.ok) throw new Error(`applyBatch failed: ${res.status}`); if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`applyBatch ${res.status}: ${detail.slice(0, 200)}`);
}
return res.json(); return res.json();
}, },