// Real human-style QA against https://canvas.flow-master.ai. // Drives the live site through login → mission → real actions exactly // as a person would. Captures: console errors, network errors, real // round-trips, identity switching, dark/light, mobile, security headers. // // Bypasses local DNS cache by mapping to the LB IP directly. import { chromium, devices } from "playwright"; import { mkdirSync, writeFileSync } from "node:fs"; const URL = "https://canvas.flow-master.ai/"; const FORCE_IP = "65.21.71.186"; const OUT = "qa/screenshots/human"; mkdirSync(OUT, { recursive: true }); const findings = []; const note = (kind, where, msg) => { const f = { kind, where, msg }; findings.push(f); console.log(`[${kind}] ${where} :: ${msg}`); }; async function attach(page, label) { page.on("pageerror", (e) => note("pageerror", label, e.message.slice(0, 200))); page.on("console", (m) => { if (m.type() === "error") note("console.error", label, m.text().slice(0, 200)); }); page.on("requestfailed", (r) => note("requestfailed", label, `${r.url()} :: ${r.failure()?.errorText}`)); page.on("response", (r) => { if (r.status() >= 400) note("http", label, `${r.status()} ${r.url()}`); }); } async function devLogin(page) { const emailInput = page.locator("input[type='email'], input[name='email']").first(); if (await emailInput.count() === 0) { note("flow", "login", "no email input visible on /login"); return false; } await emailInput.fill("dev@flow-master.ai"); const devBtn = page.locator("button, a", { hasText: /dev[- ]?login|developer/i }).first(); if (await devBtn.count() === 0) { note("flow", "login", "no dev-login button visible — dev-login flag may be off in production"); return false; } await devBtn.click(); await page.waitForTimeout(2500); return true; } const browser = await chromium.launch({ headless: true, args: [ `--host-resolver-rules=MAP canvas.flow-master.ai ${FORCE_IP}`, "--ignore-certificate-errors", ], }); { console.log("\n=== FLOW 0: auth-guard redirect ==="); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true }); const p = await ctx.newPage(); await attach(p, "F0"); await p.goto(URL, { waitUntil: "networkidle" }); await p.waitForTimeout(800); await p.screenshot({ path: `${OUT}/f0-01-unauthed.png` }); const visibleText = await p.evaluate(() => document.body.innerText); const hasLogin = /sign in|log in|continue with microsoft|dev[- ]?login|developer|email/i.test(visibleText); const hasLanding = /every process|mission control overview|enter mission|business-as-code/i.test(visibleText); if (!hasLogin && hasLanding) note("auth-guard", "/", "landing rendered without login — guard not enforced"); if (!hasLogin) note("auth-guard", "/", "no login UI visible after redirect"); if (hasLogin) note("ok", "auth-guard", "login UI present — guard works"); const demoHits = (visibleText.match(/\bdemo\b/gi) || []).length; note(demoHits === 0 ? "ok" : "de-demo", "/", `'demo' visible ${demoHits} times in unauthed view`); await ctx.close(); } { console.log("\n=== FLOW 1: dev-login → mission walkthrough ==="); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true }); const p = await ctx.newPage(); await attach(p, "F1"); const apiCalls = []; p.on("request", (req) => { if (req.url().includes("/api/")) apiCalls.push({ url: req.url(), method: req.method(), at: Date.now() }); }); await p.goto(URL, { waitUntil: "networkidle" }); await p.screenshot({ path: `${OUT}/f1-01-login.png` }); const loggedIn = await devLogin(p); if (!loggedIn) { note("flow", "login", "dev-login flow not available; remaining tests skipped"); await ctx.close(); } else { await p.screenshot({ path: `${OUT}/f1-02-after-login.png` }); const cards = await p.locator(".sc-card").count(); if (cards > 0) { await p.locator(".sc-card").first().click(); await p.waitForSelector(".mc", { timeout: 8000 }).catch(() => {}); await p.waitForTimeout(800); await p.screenshot({ path: `${OUT}/f1-03-mission.png` }); } else { note("flow", "post-login", "no scenario cards after login"); } console.log("idle 30s to measure poll frequency…"); const beforeIdle = apiCalls.length; await p.waitForTimeout(30000); const idleCount = apiCalls.length - beforeIdle; note(idleCount <= 6 ? "ok" : "perf", "mission-idle-30s", `${idleCount} /api calls in 30s idle (target ≤6)`); // Console drawer test const consoleBtn = p.locator(".link-btn", { hasText: /console/i }).first(); if (await consoleBtn.count() > 0) { await consoleBtn.click(); await p.waitForSelector(".console").catch(() => {}); await p.waitForTimeout(600); await p.screenshot({ path: `${OUT}/f1-04-console.png` }); } // Theme toggle (in topbar or settings) const themeBtn = p.locator(".link-btn, button", { hasText: /theme|dark|light|sun|moon/i }).first(); if (await themeBtn.count() > 0) { try { await themeBtn.click({ timeout: 5000 }); await p.waitForTimeout(500); const isDark = await p.evaluate(() => document.documentElement.dataset.theme === "dark"); note(isDark ? "ok" : "flow", "theme-toggle", isDark ? "theme flipped to dark" : "theme toggle did not flip theme"); await p.screenshot({ path: `${OUT}/f1-05-after-theme-toggle.png` }); } catch (e) { note("flow", "theme-toggle", `theme button click failed: ${(e).message.slice(0, 80)}`); } } else { note("missing", "theme-toggle", "no visible theme toggle button in topbar"); } // Wizard tab const wizardTab = p.locator(".tab", { hasText: /wizard|studio|build/i }).first(); if (await wizardTab.count() > 0) { await wizardTab.click(); await p.waitForTimeout(1500); await p.screenshot({ path: `${OUT}/f1-06-wizard.png` }); const wizardLanded = await p.evaluate(() => /wizard|build|create.*process|describe/i.test(document.body.innerText)); note(wizardLanded ? "ok" : "flow", "wizard", wizardLanded ? "wizard scene visible" : "wizard tab did not navigate"); } else { note("missing", "wizard-tab", "no Wizard/Studio tab in topbar"); } await ctx.close(); } } { console.log("\n=== FLOW 2: mobile ==="); const ctx = await browser.newContext({ ...devices["iPhone 14 Pro"], ignoreHTTPSErrors: true }); const p = await ctx.newPage(); await attach(p, "F2-mobile"); await p.goto(URL, { waitUntil: "networkidle" }); await p.waitForTimeout(800); await p.screenshot({ path: `${OUT}/f2-01-mobile.png` }); const overflowH = await p.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 2); note(overflowH ? "mobile" : "ok", "/", overflowH ? "horizontal overflow on mobile" : "no horizontal overflow on mobile"); await ctx.close(); } { console.log("\n=== FLOW 3: security headers ==="); const ctx = await browser.newContext({ ignoreHTTPSErrors: true }); const p = await ctx.newPage(); const resp = await p.goto(URL); const h = resp?.headers() ?? {}; for (const k of [ "strict-transport-security", "x-content-type-options", "x-frame-options", "referrer-policy", "content-security-policy", "permissions-policy", ]) { if (!h[k]) note("security", "headers", `missing ${k}`); else note("ok", "headers", `${k} present`); } await ctx.close(); } await browser.close(); writeFileSync(`${OUT}/findings.json`, JSON.stringify(findings, null, 2)); const byKind = findings.reduce((acc, f) => { acc[f.kind] = (acc[f.kind] || 0) + 1; return acc; }, {}); console.log("\n=== SUMMARY ==="); console.log(JSON.stringify(byKind, null, 2)); console.log(`\n→ ${findings.length} findings; screenshots in ${OUT}/`);