fix(wizard): retry config attach on Confirm if initial PUT failed
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

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.
This commit is contained in:
canvas-bot
2026-06-15 21:54:28 +04:00
parent 83a6b334c3
commit 727b8af463
2 changed files with 128 additions and 0 deletions
+104
View File
@@ -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);
+24
View File
@@ -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,