Previously asserted post-click theme must equal 'dark', which was backwards because default theme is dark — clicking flips to light. Now record before, click, record after, assert they differ. Also tighten the selector to .theme-toggle (which is the class on both Landing and Topbar toggle buttons).
192 lines
7.8 KiB
JavaScript
192 lines
7.8 KiB
JavaScript
// 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(".theme-toggle, .link-btn[aria-label*='theme' i]").first();
|
|
if (await themeBtn.count() > 0) {
|
|
try {
|
|
const before = await p.evaluate(() => document.documentElement.dataset.theme);
|
|
await themeBtn.click({ timeout: 5000 });
|
|
await p.waitForTimeout(500);
|
|
const after = await p.evaluate(() => document.documentElement.dataset.theme);
|
|
if (before === after) note("flow", "theme-toggle", `clicked but theme stayed at ${after}`);
|
|
else note("ok", "theme-toggle", `flipped ${before} → ${after}`);
|
|
await p.screenshot({ path: `${OUT}/f1-05-after-theme-toggle.png` });
|
|
} catch (e) {
|
|
note("flow", "theme-toggle", `click failed: ${(e).message.slice(0, 80)}`);
|
|
}
|
|
} else {
|
|
note("missing", "theme-toggle", "no visible theme toggle button");
|
|
}
|
|
|
|
// 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}/`);
|