Pulls the actual FM06/flow-master-design-philosophy doctrine
(DESIGN_PHILOSOPHY.md + SYNTHESIS.md + IMPLEMENTATION_STANDARD.md
+ ADR 0001/0002) and rebuilds the canvas to match: 'operations
cockpit', 'industrial, instrumented, accountable'. Light paper
canvas + navy frame + amber accent + 1px rules + square edges +
monospace operational labels.
WHAT CHANGED
- ProcessGraph.tsx rewritten: square 1px navy nodes, mono uppercase
labels, orthogonal step edges (ReactFlow type:'step' → MLHV only),
two-layer Background (8px minor + 64px major navy hairline grid),
doctrinal palette tokens via var(--bp-*).
- BlueprintFrame.tsx (new): top instrument readout strip
(DEF / VERSION / HUB / NODES / EDGES / SRC / MODE / TX), top + left
rulers with 8/64px ticks, navy corner glyph at origin, bottom
status legend.
- index.css: scoped [data-canvas="blueprint"] block (~280 lines)
declaring 11 doctrinal hex tokens once; opacity derivatives go
through CSS color-mix(in srgb, var(--bp-navy) 13%, transparent)
not raw rgba(). No box-shadow on selected node (outline instead).
- LeftRail.tsx: gate toast now names the real endpoint
(POST /api/runtime/transactions/{id}/actions/{submit,save_draft})
on the unsigned-in path too, restoring the convention from the
prior real-mutations pass.
- qa/smoke.mjs updated for the new selectors (.bp-node,
.bp-readout-blueprint). Old guided-tour assertions replaced with
Studio scene assertions. 27/27 PASS.
- qa/smoke_blueprint.mjs (new): 15 assertions covering S1–S3 + S5.
- qa/palette_audit.mjs (new): three checks — doctrinal CSS hex,
no raw rgba() in blueprint scope, no hardcoded color literals in
blueprint TSX. All pass.
ORACLE-REVIEWED
Round 1 FAIL: hardcoded TSX color literals + box-shadow + rgba +
narrow palette audit. Round 2 PASS after fixing all four.
CONTRACT EVIDENCE
- vitest: 5 files, 24 tests, all green
- main smoke: 27/27, 0 console errors
- blueprint smoke: 15/15, 0 console errors
- palette audit: 11 CSS doctrinal hex tokens, 0 raw rgba, 2 TSX files clean
- vite build: green
Confidence: high
Scope-risk: narrow (scoped [data-canvas="blueprint"])
Not-tested: pixel-level visual diff (Oracle could not inspect images
this round; relied on programmatic DOM + path-command assertions)
193 lines
8.9 KiB
JavaScript
193 lines
8.9 KiB
JavaScript
// 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" }).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" }).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();
|
|
// 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" }).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" }).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" }).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" }).waitFor({ state: "visible" });
|
|
await page.waitForFunction(
|
|
() => {
|
|
const btns = Array.from(document.querySelectorAll(".link-btn"));
|
|
const btn = btns.find((b) => (b.textContent || "").includes("Refresh"));
|
|
return btn && !btn.disabled;
|
|
},
|
|
{ timeout: 10000 },
|
|
);
|
|
const before = networkCalls.length;
|
|
await page.locator(".link-btn", { hasText: "Refresh" }).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" }).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" }).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(", ")}`);
|