// Playwright smoke + screenshot capture against the running dev server. // Covers: landing, MC procurement, MC blueprint (AR), inspector tabs, // command palette, tour, run history, live-mode toggle, toast on preview action. import { chromium } from "playwright"; import { mkdirSync } from "node:fs"; const URL = process.env.URL || "http://127.0.0.1:5173"; const OUT = "qa/screenshots"; mkdirSync(OUT, { recursive: true }); const VP = { width: 1440, height: 900 }; function logOk(label, ok, detail = "") { console.log(`${ok ? "✓" : "✗"} ${label}${detail ? " · " + detail : ""}`); if (!ok) process.exitCode = 1; } const browser = await chromium.launch({ headless: true }); const ctx = await browser.newContext({ viewport: VP, deviceScaleFactor: 1 }); const page = await ctx.newPage(); const errors = []; page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`)); page.on("console", (m) => { if (m.type() === "error") errors.push(`console.error: ${m.text()}`); }); await page.goto(URL, { waitUntil: "networkidle" }); await page.waitForTimeout(400); await page.screenshot({ path: `${OUT}/01-landing.png` }); logOk("landing renders", await page.locator(".landing").isVisible()); logOk("hero title present", await page.locator(".hero-title").isVisible()); const cards = await page.locator(".sc-card").count(); logOk("scenario cards >= 7", cards >= 7, `count=${cards}`); logOk("Go live button visible on landing", await page.getByRole("button", { name: /go live/i }).isVisible()); // → Mission Control via first card (live procurement) await page.locator(".sc-card").first().click(); await page.waitForSelector(".mc"); await page.waitForTimeout(600); await page.screenshot({ path: `${OUT}/02-mission-procurement.png` }); logOk("mission control loaded", await page.locator(".mc-strip").isVisible()); // Topbar mode pill is SNAPSHOT by default const modeBefore = (await page.locator(".mode-toggle .mode-pill").innerText()).trim(); logOk("default mode is SNAPSHOT", modeBefore === "SNAPSHOT", `was=${modeBefore}`); // Tab strip + graph const tabCount = await page.locator(".mc-tab").count(); logOk("tab strip has >= 7 scenarios", tabCount >= 7, `count=${tabCount}`); const reactFlowNodes = await page.locator(".bp-node").count(); logOk("graph rendered blueprint nodes > 0", reactFlowNodes > 0, `nodes=${reactFlowNodes}`); // Switch to AR blueprint await page.locator(".mc-tab", { hasText: /accounts receivable/i }).click(); await page.waitForTimeout(600); await page.screenshot({ path: `${OUT}/03-mission-ar.png` }); logOk("AR scenario active", (await page.locator(".mc-hero-title").innerText()).toLowerCase().includes("refund")); logOk("AR shows BLUEPRINT readout (not SYNTHETIC)", await page.locator(".bp-readout-blueprint .bp-readout-val", { hasText: /blueprint/i }).isVisible()); // Queue card → inspector update const qCards = await page.locator(".qcard").count(); logOk("queue cards present", qCards > 0, `count=${qCards}`); if (qCards > 0) { await page.locator(".qcard").first().click(); await page.waitForTimeout(200); } // Inspector tab switching await page.locator(".itab", { hasText: /evidence/i }).click(); await page.waitForTimeout(150); logOk("evidence tab opens", await page.locator(".evt, .empty").first().isVisible()); await page.locator(".itab", { hasText: /^raw/i }).click(); await page.waitForTimeout(150); logOk("raw tab opens", await page.locator(".raw-json").isVisible()); await page.screenshot({ path: `${OUT}/04-inspector-raw.png` }); // Preview-only action toast await page.locator(".itab", { hasText: /overview/i }).click(); await page.waitForTimeout(150); const approveBtn = page.locator(".i-actions .btn", { hasText: /approve/i }).first(); const approveCount = await approveBtn.count(); if (approveCount > 0) { await approveBtn.click(); await page.waitForTimeout(300); const toastTxt = (await page.locator(".toast-msg").first().innerText()).trim(); // After the real-mutations pass, the toast now names the LIVE-mode gate and the real endpoint. logOk( "action toast names live-mode gate + real endpoint", /(LIVE mode|preview-only|\/api\/runtime\/transactions)/i.test(toastTxt), `toast="${toastTxt.slice(0, 100)}"`, ); } else { logOk("approve button preview-marker present", await page.locator(".preview-marker").first().isVisible()); } // Command palette await page.keyboard.press("Meta+k"); await page.waitForSelector(".cmd", { state: "visible" }); await page.waitForTimeout(150); await page.screenshot({ path: `${OUT}/05-command-palette.png` }); logOk("command palette opens with ⌘K", await page.locator(".cmd").isVisible()); logOk("Data mode group present in palette", await page.locator(".cmd").getByText(/data mode/i).isVisible()); // Studio command appears in palette (replaces the deprecated tour command) await page.locator('.cmd [cmdk-input]').fill("studio"); await page.waitForTimeout(150); logOk("studio command appears in palette", await page.locator(".cmd").getByText(/process studio/i).isVisible()); await page.keyboard.press("Escape"); await page.waitForTimeout(100); // Studio scene renders + shows JSON preview + node editor await page.locator(".tab", { hasText: /studio/i }).click(); await page.waitForSelector(".studio"); await page.waitForTimeout(250); await page.screenshot({ path: `${OUT}/06-studio.png` }); logOk("studio scene renders", await page.locator(".studio-panel").count() >= 4); logOk("studio JSON preview shows config payload", /\"nodes\"/.test(await page.locator(".raw-json").first().innerText())); await page.locator(".tab", { hasText: /mission/i }).click(); await page.waitForSelector(".bp-frame"); // Live-mode toggle (real network call to demo.flow-master.ai via vite proxy) const networkCalls = []; page.on("request", (req) => { if (req.url().includes("/api/ea2/work-items")) networkCalls.push(req.url()); }); const toggleBtn = page.locator(".mode-toggle").first(); await toggleBtn.click(); const liveOk = await page.waitForFunction( () => Array.from(document.querySelectorAll(".mode-pill")).some((el) => el.textContent?.trim() === "LIVE"), { timeout: 12000 }, ).then(() => true).catch(() => false); logOk("toggle to LIVE mode resolves and updates pill", liveOk); if (liveOk) { await page.waitForTimeout(400); await page.screenshot({ path: `${OUT}/09-live-mode.png` }); logOk("Refresh button appears in live mode", await page.locator(".link-btn", { hasText: /refresh/i }).isVisible()); // Refresh must trigger ANOTHER /api/ea2/work-items request (Oracle round 2 fix). // Wait for the Refresh button to become enabled (initial fetch may still be settling). await page.locator(".link-btn", { hasText: /refresh/i }).waitFor({ state: "visible" }); await page.waitForFunction( () => { const btns = Array.from(document.querySelectorAll(".link-btn")); const btn = btns.find((b) => /refresh/i.test(b.textContent || "")); return btn && !btn.disabled; }, { timeout: 10000 }, ); const before = networkCalls.length; await page.locator(".link-btn", { hasText: /refresh/i }).click(); await page.waitForTimeout(2500); logOk("Refresh re-fetches /api/ea2/work-items", networkCalls.length > before, `before=${before} after=${networkCalls.length}`); } // LeftRail Confirm button: toast must name /api/runtime/transactions/{id}/actions await page.locator(".mc-tab", { hasText: /accounts receivable/i }).click(); await page.waitForTimeout(400); const confirmBtn = page.locator(".agent-acts .btn-primary").first(); if (await confirmBtn.count() > 0) { await confirmBtn.click(); await page.waitForTimeout(300); const lastToast = (await page.locator(".toast-msg").last().innerText()).trim(); logOk("LeftRail Confirm toast names /api/runtime/transactions endpoint", /\/api\/runtime\/transactions\/\{id\}\/actions/.test(lastToast), `toast="${lastToast.slice(0, 80)}"`); } // Telemetry honesty (check before leaving MC because RH has no telemetry strip) const tel = await page.locator(".telemetry").innerText(); logOk("telemetry has no hardcoded 97.4%", !/97\.4%/.test(tel), `tel="${tel.replace(/\s+/g, " ").slice(0, 80)}"`); logOk("telemetry labels SLA as derived", /derived/i.test(tel)); // Run history scene await page.locator(".tab", { hasText: /runs/i }).click(); await page.waitForSelector(".rh"); await page.waitForTimeout(250); await page.screenshot({ path: `${OUT}/07-run-history.png` }); const rhRows = await page.locator(".rh-row").count(); logOk("run-history rows present", rhRows > 0, `rows=${rhRows}`); await page.locator(".rh-chip", { hasText: /^running/i }).click(); await page.waitForTimeout(200); const rhRunning = await page.locator(".rh-row").count(); logOk("run-history filter shrinks list", rhRunning > 0 && rhRunning <= rhRows, `running=${rhRunning} of ${rhRows}`); console.log(`\nconsole errors: ${errors.length}`); for (const e of errors.slice(0, 8)) console.log(" -", e); if (errors.length > 0) process.exitCode = 1; await browser.close(); const fs = await import("node:fs/promises"); const list = await fs.readdir(OUT); console.log(`\n→ ${OUT}/ has ${list.length} screenshots: ${list.sort().join(", ")}`);