Inspected dev.flow-master.ai/dashboard/hubs/{procurement,hr,it} via real
dev-login. Real hub structure is: Workbench title + Workflow Commands
(4 named buttons) + Queue (left) + Selected Work (right).
- Procurement: New purchase request / Vendor approval / PO change-cancel / Three-way match
- HR: Employee onboarding / Off-boarding / Sick leave / Payroll management
- IT: Access request / Equipment request / Service ticket / User off-boarding
Hub.tsx rewritten: header + Workflow Commands grid + queue/selected
split pane that matches dev's 'Queue' + 'Selected work' layout.
Oracle gap #3 (hubs not feature parity) — partial close. Approvals queue
and document browser still outstanding but the workbench shape now
mirrors dev.
172 lines
8.5 KiB
JavaScript
172 lines
8.5 KiB
JavaScript
// Single end-to-end QA gate. Exercises every new surface as a real user would,
|
|
// against the live canvas.flow-master.ai URL with real EA2 traffic. Exits non-zero
|
|
// on any failure so CI can wrap it.
|
|
import { chromium } from "playwright";
|
|
|
|
const URL = "https://canvas.flow-master.ai/";
|
|
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
|
|
|
|
const results = [];
|
|
function record(name, ok, detail = "") {
|
|
results.push({ name, ok, detail });
|
|
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
|
}
|
|
|
|
const b = await chromium.launch({ headless: true, args });
|
|
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
|
|
const p = await ctx.newPage();
|
|
|
|
// 1. Landing renders & login page is gated
|
|
await p.goto(URL, { waitUntil: "networkidle" });
|
|
await p.waitForTimeout(1000);
|
|
const onLogin = await p.locator(".login-page").count();
|
|
record("auth_guard_unauthed_redirects_to_login", onLogin === 1);
|
|
const chips = await p.locator(".persona-chip").allTextContents();
|
|
record("login_shows_three_personas", chips.length === 3, chips.join(" | "));
|
|
const ssoBtn = await p.locator(".sso-btn").count();
|
|
record("login_has_microsoft_sso_button", ssoBtn === 1);
|
|
|
|
// 2. Persona dev-login works
|
|
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
|
|
await p.locator(".dev-login-btn").click();
|
|
await p.waitForTimeout(2500);
|
|
const onLanding = await p.locator(".hero-actions").count();
|
|
record("ceo_persona_dev_login_lands_on_landing", onLanding === 1);
|
|
|
|
// 3. Landing chips include every new surface
|
|
const hubChips = await p.locator(".hub-chip").allTextContents();
|
|
const expected = ["Procurement Hub", "People Hub", "IT Hub", "Attendance Map", "Command Assistant", "Team Chat", "What is FlowMaster?"];
|
|
const missing = expected.filter((e) => !hubChips.some((h) => h.includes(e)));
|
|
record("landing_exposes_all_hubs_and_extras", missing.length === 0, missing.length ? `missing: ${missing.join(", ")}` : "all 7 present");
|
|
|
|
// 4. Explainer renders 8 cards
|
|
await p.locator(".hub-chip", { hasText: /What is FlowMaster/ }).click();
|
|
await p.waitForTimeout(1000);
|
|
const explainerCards = await p.locator(".explainer-card").count();
|
|
record("explainer_renders_eight_cards", explainerCards === 8, `cards=${explainerCards}`);
|
|
|
|
// 5. Geo-attendance: tiles + markers + stats
|
|
await p.locator(".tab", { hasText: /Home/ }).first().click();
|
|
await p.waitForTimeout(800);
|
|
await p.locator(".hub-chip", { hasText: /Attendance Map/ }).click();
|
|
await p.waitForTimeout(3000);
|
|
const tiles = await p.locator(".leaflet-tile").count();
|
|
const markers = await p.locator(".leaflet-marker-icon").count();
|
|
record("geo_attendance_renders_tiles", tiles > 10, `tiles=${tiles}`);
|
|
record("geo_attendance_renders_eight_markers", markers === 8, `markers=${markers}`);
|
|
|
|
// 6. Command Assistant: tool list + list_processes round-trip
|
|
await p.locator(".tab", { hasText: /Home/ }).first().click();
|
|
await p.waitForTimeout(800);
|
|
await p.locator(".hub-chip", { hasText: /Command Assistant/ }).click();
|
|
await p.waitForTimeout(1200);
|
|
const toolNames = await p.locator(".agent-tool-name").allTextContents();
|
|
record("assistant_shows_five_tools", toolNames.length === 5, toolNames.join(", "));
|
|
const beforeCount = await p.locator(".agent-turn-agent .agent-turn-body").count();
|
|
await p.locator(".agent-composer textarea").fill("list processes");
|
|
await p.locator(".agent-composer button", { hasText: /Send/i }).click();
|
|
await p.waitForFunction(
|
|
(n) => document.querySelectorAll(".agent-turn-agent .agent-turn-body").length > n,
|
|
beforeCount,
|
|
{ timeout: 8000 }
|
|
).catch(() => {});
|
|
await p.waitForTimeout(500);
|
|
const lastReply = (await p.locator(".agent-turn-agent .agent-turn-body").last().textContent()) || "";
|
|
record("assistant_list_processes_returns_real_ea2_data", lastReply.includes("•") || lastReply.toLowerCase().includes("no published"), lastReply.slice(0, 80));
|
|
|
|
// 7. Procurement hub returns real catalogue
|
|
await p.locator(".tab", { hasText: /Home/ }).first().click();
|
|
await p.waitForTimeout(800);
|
|
await p.locator(".hub-chip", { hasText: /Procurement Hub/ }).click();
|
|
await p.waitForTimeout(2200);
|
|
const hubFlows = await p.locator(".hub-flow-title").count();
|
|
record("procurement_hub_lists_real_flows", hubFlows > 0, `flows=${hubFlows}`);
|
|
|
|
// 8. Chat thread + message round-trip in one session (already covered by audit_chat,
|
|
// here we just verify the Chat scene loads + sidebar shows existing threads)
|
|
await p.locator(".tab", { hasText: /Chat/ }).first().click();
|
|
await p.waitForTimeout(2000);
|
|
const existingThreads = await p.locator(".chat-thread-row").count();
|
|
record("chat_scene_loads_with_thread_sidebar", existingThreads >= 0, `threads=${existingThreads}`);
|
|
|
|
// 9. Theme toggle flips the data-theme attribute
|
|
const themeBtn = p.locator(".theme-toggle").first();
|
|
const before = await p.evaluate(() => document.documentElement.dataset.theme || "light");
|
|
await themeBtn.click();
|
|
await p.waitForTimeout(300);
|
|
const after = await p.evaluate(() => document.documentElement.dataset.theme || "light");
|
|
record("theme_toggle_flips_theme", before !== after, `${before} -> ${after}`);
|
|
|
|
// 10. Security headers
|
|
const headers = (await p.request.get(URL)).headers();
|
|
const securityChecks = {
|
|
"strict-transport-security": (v) => /max-age=\d+/.test(v),
|
|
"x-frame-options": (v) => /deny|sameorigin/i.test(v),
|
|
"x-content-type-options": (v) => /nosniff/i.test(v),
|
|
"referrer-policy": (v) => v.length > 0,
|
|
"content-security-policy": (v) => v.length > 0,
|
|
"permissions-policy": (v) => v.length > 0,
|
|
};
|
|
for (const [h, check] of Object.entries(securityChecks)) {
|
|
const v = headers[h] || "";
|
|
record(`security_header_${h.replace(/-/g, "_")}`, !!v && check(v), v ? v.slice(0, 60) : "missing");
|
|
}
|
|
|
|
// 11. NEGATIVE assertions: no dev artefacts anywhere a buyer would look.
|
|
// Oracle remediation gap #4 — these must stay green after every change.
|
|
async function pageBodyText() {
|
|
return (await p.locator("body").innerText()).toLowerCase();
|
|
}
|
|
async function neg(name, scene, banned) {
|
|
await p.locator(".tab", { hasText: /Home/i }).first().click().catch(() => {});
|
|
await p.waitForTimeout(400);
|
|
if (scene === "landing") {
|
|
// already there
|
|
} else if (scene === "agent-list") {
|
|
await p.locator(".hub-chip", { hasText: /Command Assistant/i }).click();
|
|
await p.waitForTimeout(800);
|
|
const before = await p.locator(".agent-turn-agent .agent-turn-body").count();
|
|
await p.locator(".agent-composer textarea").fill("list processes");
|
|
await p.locator(".agent-composer button", { hasText: /Send/i }).click();
|
|
await p.waitForFunction((n) => document.querySelectorAll(".agent-turn-agent .agent-turn-body").length > n, before, { timeout: 8000 }).catch(() => {});
|
|
await p.waitForTimeout(500);
|
|
} else {
|
|
await p.locator(".hub-chip", { hasText: new RegExp(scene, "i") }).click();
|
|
await p.waitForTimeout(1500);
|
|
}
|
|
const body = await pageBodyText();
|
|
const hits = banned.filter((b) => body.includes(b.toLowerCase()));
|
|
record(`negative_${name}_no_dev_artefacts`, hits.length === 0, hits.length ? `LEAKED: ${hits.join(", ")}` : "clean");
|
|
}
|
|
const BANNED = ["rv test flow", "rv_flow", "atlas f1", "atlas-f1-fresh", "mcp sdx smoke", "codex test", "sidekick", "orphan rv_", " orphan "];
|
|
await neg("landing", "landing", BANNED);
|
|
await neg("procurement_hub", "Procurement Hub", BANNED);
|
|
await neg("people_hub", "People Hub", BANNED);
|
|
await neg("it_hub", "IT Hub", BANNED);
|
|
await neg("agent_list_processes", "agent-list", BANNED);
|
|
|
|
// 12. NEGATIVE: no raw 32-char hex IDs in user-facing toasts/UI.
|
|
// Acceptable in dev console / debug only.
|
|
await p.locator(".tab", { hasText: /Home/i }).first().click().catch(() => {});
|
|
await p.waitForTimeout(300);
|
|
const visibleText = await pageBodyText();
|
|
const hexLeaks = visibleText.match(/[a-f0-9]{32}/g) || [];
|
|
record("negative_no_raw_32hex_ids_visible", hexLeaks.length === 0, hexLeaks.length ? `LEAKED ${hexLeaks.length}: ${hexLeaks[0]}` : "clean");
|
|
|
|
// 13. NEGATIVE: agent welcome must not claim to be an "AI agent".
|
|
await p.locator(".hub-chip", { hasText: /Command Assistant/i }).click();
|
|
await p.waitForTimeout(800);
|
|
const welcome = (await p.locator(".agent-turn-agent .agent-turn-body").first().textContent()) || "";
|
|
const honestyOk = !/\bai agent\b/i.test(welcome) || /command assistant/i.test(welcome);
|
|
record("agent_welcome_does_not_overclaim_ai", honestyOk, welcome.slice(0, 80));
|
|
|
|
// Summarise
|
|
const fails = results.filter((r) => !r.ok);
|
|
console.log(`\n=== ${results.length - fails.length}/${results.length} passed ===`);
|
|
if (fails.length) {
|
|
console.log("FAILED:");
|
|
fails.forEach((f) => console.log(` - ${f.name}${f.detail ? ": " + f.detail : ""}`));
|
|
}
|
|
await b.close();
|
|
process.exit(fails.length === 0 ? 0 : 1);
|