From 727b8af4636327b144d558f30ee518f2aad1eef2 Mon Sep 17 00:00:00 2001 From: canvas-bot Date: Mon, 15 Jun 2026 21:54:28 +0400 Subject: [PATCH] fix(wizard): retry config attach on Confirm if initial PUT failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oracle wave-3 caveat: 'A toast alone is insufficient if the app marks the wizard state as attached locally and never retries.' Track draft.wizardConfigAttached. Set true only after the initial PUT resolves. In handleStructureSave (Confirm Structure), if not yet attached, retry updateDraftConfig before applying the structure batch. Failure of the retry is logged but doesn't block save — structure still lands and downstream config sync can pick up later. --- qa/_wave3_neg_path.mjs | 104 +++++++++++++++++++++++++++++++++++++++++ src/scenes/Wizard.tsx | 24 ++++++++++ 2 files changed, 128 insertions(+) create mode 100644 qa/_wave3_neg_path.mjs diff --git a/qa/_wave3_neg_path.mjs b/qa/_wave3_neg_path.mjs new file mode 100644 index 0000000..255ac06 --- /dev/null +++ b/qa/_wave3_neg_path.mjs @@ -0,0 +1,104 @@ +// Oracle's wave-3 negative path: force the wizard config PUT to fail +// after the draft POST succeeds, then verify: +// 1. The soft toast appears with the exact copy +// 2. The draft remains usable (no hard crash) +// 3. Confirm/handleStructureSave retries the attach +import { chromium } from "playwright"; + +const URL = "https://canvas.flow-master.ai/"; +const r = []; +const pass = (n, d = "") => { r.push({ n, ok: true, d }); console.log(`PASS ${n}${d ? " " + d : ""}`); }; +const fail = (n, d = "") => { r.push({ n, ok: false, d }); console.log(`FAIL ${n}${d ? " " + d : ""}`); }; + +const b = await chromium.launch({ headless: true }); +const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } }); +const p = await ctx.newPage(); +const calls = []; +const consoleMsgs = []; +p.on("console", m => consoleMsgs.push({ type: m.type(), text: m.text().slice(0, 220) })); +p.on("response", res => { + if (res.url().includes("/api/")) calls.push({ status: res.status(), method: res.request().method(), url: res.url().replace("https://canvas.flow-master.ai", "") }); +}); + +await ctx.addInitScript(() => localStorage.setItem("fm.canvas.tour.seen", String(Date.now()))); + +console.log("\n--- Force the first wizard PUT to fail with 500 ---"); +let injected = 0; +const targetKeys = new Set(); +await p.route("**/api/ea2/flow/*", async (route, request) => { + const u = request.url(); + if (request.method() === "PUT" && injected === 0) { + const m = u.match(/\/api\/ea2\/flow\/([a-f0-9]{16,})$/); + if (m) { + targetKeys.add(m[1]); + injected++; + console.log(` injecting 500 on first PUT to ${m[1]}`); + return route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "Injected failure for negative-path test" }) }); + } + } + return route.continue(); +}); + +await p.goto(URL, { waitUntil: "domcontentloaded" }); +await p.waitForTimeout(2500); +await p.locator(".persona-chip").filter({ hasText: /Mariana/ }).click(); +await p.waitForSelector(".topbar", { timeout: 30000 }); +await p.waitForTimeout(2000); + +await p.evaluate(() => localStorage.removeItem("fm.mc.wizard.draft")); +await p.reload({ waitUntil: "domcontentloaded" }); +await p.waitForTimeout(2500); +await p.locator(".tab").filter({ hasText: /Studio/i }).first().click(); +await p.waitForSelector("textarea", { timeout: 10000 }); +await p.locator("textarea").first().fill("Negative path: force PUT failure on attach."); +await p.locator("button.btn-primary").filter({ hasText: /draft/i }).first().click(); + +await p.waitForTimeout(6000); +const posts = calls.filter(c => c.method === "POST" && c.url === "/api/ea2/flow"); +const puts = calls.filter(c => c.method === "PUT" && /\/api\/ea2\/flow\/[a-f0-9]+/.test(c.url)); +console.log(` POSTs:`, posts.map(c => c.status)); +console.log(` PUTs:`, puts.map(c => c.status)); + +if (posts.some(c => c.status === 201)) pass("neg_draft_post_succeeded"); +else fail("neg_draft_post_succeeded", `posts=${JSON.stringify(posts)}`); + +if (puts.some(c => c.status === 500)) pass("neg_first_put_intercepted_500"); +else fail("neg_first_put_intercepted_500", `puts=${JSON.stringify(puts)}`); + +const toasts = await p.locator(".toast").allTextContents(); +console.log(` toasts seen:`, toasts); +const softToast = toasts.find(t => /didn't attach.*Confirm/i.test(t)); +if (softToast) pass("neg_soft_toast_with_exact_copy", softToast.slice(0, 80)); +else fail("neg_soft_toast_with_exact_copy", `toasts=${JSON.stringify(toasts)}`); + +const warnSeen = consoleMsgs.some(m => m.text.includes("[wizard] config attach failed")); +if (warnSeen) pass("neg_console_warn_logged"); +else fail("neg_console_warn_logged"); + +// Draft still usable: should have advanced to Analyze step +const analyzeVisible = await p.locator(".scene").innerText().then(t => /analyze/i.test(t)); +if (analyzeVisible) pass("neg_draft_still_usable_after_failure"); +else fail("neg_draft_still_usable_after_failure"); + +console.log("\n--- Click Confirm Structure, verify retry attach fires ---"); +const putsBeforeConfirm = puts.length; +await p.locator("button.btn-primary").filter({ hasText: /Confirm/i }).first().click(); +await p.waitForTimeout(8000); +const putsAfter = calls.filter(c => c.method === "PUT" && /\/api\/ea2\/flow\/[a-f0-9]+/.test(c.url)); +const newPuts = putsAfter.slice(putsBeforeConfirm); +console.log(` PUTs during Confirm:`, newPuts.map(c => `${c.status} ${c.url.slice(-30)}`)); + +const retryPut = newPuts.find(c => Array.from(targetKeys).some(k => c.url.includes(k))); +if (retryPut && retryPut.status === 200) pass("neg_confirm_retries_attach_successfully", `PUT ${retryPut.status}`); +else if (retryPut) pass("neg_confirm_retries_attach_fired", `PUT ${retryPut.status} (attempt made)`); +else fail("neg_confirm_retries_attach", `no PUT to original draft key during Confirm`); + +const total5xx = calls.filter(c => c.status >= 500).length; +console.log(`\n5xx count: ${total5xx} (expected 1 from injection)`); + +await p.screenshot({ path: "/tmp/canvas-evidence/wave3-neg-path.png" }); +await b.close(); + +const passed = r.filter(x => x.ok).length; +console.log(`\n=== ${passed}/${r.length} negative-path checks passed ===`); +process.exit(passed === r.length ? 0 : 1); diff --git a/src/scenes/Wizard.tsx b/src/scenes/Wizard.tsx index 418ecea..65de243 100644 --- a/src/scenes/Wizard.tsx +++ b/src/scenes/Wizard.tsx @@ -16,6 +16,7 @@ interface DraftState { edges: any[]; fields: { name: string; type: string }[]; rules: string[]; + wizardConfigAttached?: boolean; } export default function Wizard() { @@ -83,6 +84,9 @@ export default function Wizard() { }, }, }) + .then(() => { + updateDraft({ wizardConfigAttached: true }); + }) .catch((err: Error) => { console.warn("[wizard] config attach failed (draft saved without wizard panel state):", err.message); pushToast("warn", "Draft saved, but its wizard state didn't attach. You can keep working — save will retry on Confirm."); @@ -113,6 +117,26 @@ export default function Wizard() { if (!draft.flowKey || !actor) return; setWorking(true); try { + if (!draft.wizardConfigAttached) { + try { + await wizardApi.updateDraftConfig(draft.flowKey, { + config: { + wizard: { + marker: "EA2_DRAFT_PROCESS", + maxDepth: 5, + maxNodes: 40, + chat: [], + uploads: [], + panelState: {}, + debug: [], + }, + }, + }); + updateDraft({ wizardConfigAttached: true }); + } catch (e) { + console.warn("[wizard] config attach retry on Confirm failed:", (e as Error).message); + } + } const stepCreateOps: BatchOperation[] = draft.nodes.map(n => wizardApi.ops.createStep({ display_name: n.display_name,