Mission Control demo v2
Polished command-center for FlowMaster with two data modes:
- SNAPSHOT: bundled src/scenarios.json from demo.flow-master.ai
- LIVE: in-browser fetch via src/lib/api.ts (dev-login + bearer)
Scenarios:
- procurement, extra-1, extra-2 (live from EA2)
- ar, hcm, gl, service (industry blueprints, same typed shell)
Honesty pass after Oracle review:
- No invented numbers (Telemetry derives SLA + agent acceptance from real data)
- Preview-only actions fire toasts naming the endpoint to wire them
- Blueprint tours framed as 'industry blueprint', not 'we don't have this yet'
- Mode pill + last-fetch age + refresh in topbar
- Dev CORS dodged via vite proxy; production deploys same-origin
18 vitest tests + 26 playwright smoke assertions + DOM layout audit.
Constraint: cross-origin live mode rejected by browser → fall back to snapshot
Rejected: hardcoded SLA % | dishonest demo metrics
Directive: wire preview-only action handlers to /api/runtime/transactions/{id}/actions to ship them for real
Confidence: high
Scope-risk: narrow
Not-tested: production deployment via flowmaster-ops overlay
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
// Programmatic visual-QA: walks the rendered DOM and reports likely
|
||||
// layout defects (overflow, clipping, overlap, zero-size, low contrast).
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const URL = process.env.URL || "http://127.0.0.1:5173";
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
const findings = [];
|
||||
function add(kind, msg) { findings.push({ kind, msg }); }
|
||||
|
||||
await page.goto(URL, { waitUntil: "networkidle" });
|
||||
|
||||
async function auditScene(label, prep) {
|
||||
await prep();
|
||||
await page.waitForTimeout(500);
|
||||
// For each candidate selector, check for overflow / hidden / zero size
|
||||
const results = await page.evaluate(() => {
|
||||
const out = [];
|
||||
const sels = [
|
||||
".hero-title", ".hero-sub", ".sc-card-title", ".sc-card-sub",
|
||||
".mc-hero-title", ".mc-hero-sub", ".mc-tab-label",
|
||||
".node-name", ".panel-h", ".qcard-title", ".inspector-title h3",
|
||||
".tour-title", ".tour-body", ".telemetry .t-block", ".rh-row-step",
|
||||
".graph-overlay-name",
|
||||
];
|
||||
for (const sel of sels) {
|
||||
for (const el of Array.from(document.querySelectorAll(sel))) {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
const clipped = el.scrollWidth > r.width + 1 && cs.overflow !== "visible" && cs.textOverflow === "ellipsis";
|
||||
const overflowH = el.scrollHeight > r.height + 1 && cs.overflow !== "visible" && cs.overflowY !== "auto" && cs.overflowY !== "scroll";
|
||||
const offscreen = r.right < 0 || r.left > window.innerWidth || r.bottom < 0 || r.top > window.innerHeight + 1;
|
||||
const zero = (r.width < 2 || r.height < 2) && el.textContent && el.textContent.trim().length > 0;
|
||||
if (clipped || overflowH || offscreen || zero) {
|
||||
out.push({
|
||||
sel, text: (el.textContent || "").trim().slice(0, 80),
|
||||
w: r.width.toFixed(0), h: r.height.toFixed(0),
|
||||
sw: el.scrollWidth, sh: el.scrollHeight,
|
||||
issues: { clipped, overflowH, offscreen, zero },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
for (const r of results) {
|
||||
add("layout", `[${label}] ${r.sel}: "${r.text}" (${r.w}x${r.h} content ${r.sw}x${r.sh}) ${JSON.stringify(r.issues)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await auditScene("landing", async () => {});
|
||||
await auditScene("mission-1st-live", async () => {
|
||||
await page.locator(".sc-card").first().click();
|
||||
await page.waitForSelector(".mc");
|
||||
});
|
||||
await auditScene("mission-ar", async () => {
|
||||
await page.locator(".mc-tab", { hasText: "Accounts Receivable" }).click();
|
||||
});
|
||||
await auditScene("inspector-raw", async () => {
|
||||
await page.locator(".itab", { hasText: "Raw" }).click();
|
||||
});
|
||||
await auditScene("tour", async () => {
|
||||
await page.locator(".link-btn", { hasText: "Tour" }).click();
|
||||
await page.waitForSelector(".tour-card");
|
||||
});
|
||||
await auditScene("history", async () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(100);
|
||||
await page.locator(".tab", { hasText: "Runs" }).click();
|
||||
await page.waitForSelector(".rh");
|
||||
});
|
||||
|
||||
console.log(`findings: ${findings.length}`);
|
||||
for (const f of findings.slice(0, 50)) console.log(` · ${f.msg}`);
|
||||
|
||||
// Also dump key layout dimensions for sanity
|
||||
const dims = await page.evaluate(() => {
|
||||
const get = (sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { sel, w: Math.round(r.width), h: Math.round(r.height), top: Math.round(r.top), left: Math.round(r.left) };
|
||||
};
|
||||
return [
|
||||
get(".rh-head"),
|
||||
get(".rh-list"),
|
||||
get(".rh-row"),
|
||||
];
|
||||
});
|
||||
console.log("\nrh dimensions:", JSON.stringify(dims, null, 2));
|
||||
|
||||
await browser.close();
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
// 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(".node").count();
|
||||
logOk("graph rendered nodes > 0", reactFlowNodes > 0, `nodes=${reactFlowNodes}`);
|
||||
|
||||
// Switch to AR blueprint
|
||||
await page.locator(".mc-tab", { hasText: "Accounts Receivable" }).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 badge (not SYNTHETIC)", await page.locator(".graph-overlay .tag-syn", { hasText: "BLUEPRINT" }).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" }).click();
|
||||
await page.waitForTimeout(150);
|
||||
logOk("evidence tab opens", await page.locator(".evt, .empty").first().isVisible());
|
||||
await page.locator(".itab", { hasText: "Raw" }).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" }).click();
|
||||
await page.waitForTimeout(150);
|
||||
const approveBtn = page.locator(".i-actions .btn", { hasText: "Approve" }).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();
|
||||
logOk("preview action fires toast", /preview[- ]only/i.test(toastTxt), `toast="${toastTxt.slice(0, 80)}"`);
|
||||
} 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());
|
||||
|
||||
await page.locator('.cmd [cmdk-input]').fill("tour");
|
||||
await page.waitForTimeout(150);
|
||||
logOk("tour command appears", await page.locator(".cmd").getByText(/start guided tour/i).isVisible());
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Start tour
|
||||
await page.locator(".link-btn", { hasText: "Tour" }).click();
|
||||
await page.waitForSelector(".tour-card");
|
||||
await page.waitForTimeout(250);
|
||||
await page.screenshot({ path: `${OUT}/06-tour.png` });
|
||||
logOk("tour card renders", await page.locator(".tour-card").isVisible());
|
||||
const firstTourCombined = ((await page.locator(".tour-title").first().innerText()) + " " + (await page.locator(".tour-body").first().innerText())).toLowerCase();
|
||||
logOk("AR tour uses positive 'industry blueprint' framing", /industry blueprint/i.test(firstTourCombined), `text~="${firstTourCombined.slice(0, 100)}"`);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.waitForTimeout(450);
|
||||
logOk("tour advances", (await page.locator(".tour-title").first().innerText()).length > 0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Live-mode toggle (real network call to demo.flow-master.ai)
|
||||
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" }).isVisible());
|
||||
}
|
||||
|
||||
// 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" }).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" }).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(", ")}`);
|
||||
Reference in New Issue
Block a user