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,