#!/usr/bin/env node /** * SKIP-safe wrapper around `playwright test` for the e2e a11y gate. * * Skips (exit 0) when any of the following are true: * - Playwright runtime is not installed (`npx --no-install playwright --version` fails) * - Target URL (E2E_BASE_URL or http://localhost:3000) is not reachable * * Set REQUIRE_E2E_A11Y=1 in staging/CI lanes where missing browser runtime or * an unreachable target must fail instead of skipping. Set REQUIRE_AUTH_E2E=1 * when authenticated browser specs must not silently skip. Set * REQUIRE_E2E_READINESS=1 for staging/readiness lanes where authenticated * dashboard, governance settings, subscription, portal management, admin email * sequences, plugin settings, workflow builder, workflow smoke inputs, and * read-only plugin credentials must be present before Playwright starts. Set * REQUIRE_E2E_MUTATING_READINESS=1 only * for disposable readiness lanes where registration/account/plugin mutation * opt-ins must also be present. Set E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1 only in * lanes with an externally acquired checkout success path; it requires the * readiness lane, mobile browser matrix, E2E_TENANT_DASHBOARD_PATH, and * E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH instead of letting that smoke skip. * * Otherwise runs the default Playwright suite under `tests/e2e/`. Set * E2E_BROWSER_MATRIX=1 to add mobile Chromium/WebKit projects from * playwright.config.ts. */ import { spawnSync } from "node:child_process"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; export const DEFAULT_TARGET_URL = "http://localhost:3000"; export function resolveTargetUrl({ env = process.env } = {}) { const configuredUrl = env.E2E_BASE_URL?.trim(); return configuredUrl || DEFAULT_TARGET_URL; } export function hasPlaywrightRuntime({ spawn = spawnSync } = {}) { const result = spawn("npx", ["--no-install", "playwright", "--version"], { stdio: "ignore", timeout: 30_000, }); return result.status === 0; } export function isUrlReachable(url, { spawn = spawnSync, allowServerErrors = true } = {}) { const result = spawn( "curl", ["-fsS", "--max-time", "5", "-o", "/dev/null", "-w", "%{http_code}", url], { encoding: "utf8", timeout: 10_000 }, ); if (result.status !== 0) return false; const code = parseInt((result.stdout || "").trim(), 10); // In local SKIP-safe mode, any HTTP response means the app is reachable and // Playwright can decide which pages should pass or skip. Strict readiness // lanes fail early on 5xx so a broken staging target cannot look like a pass. if (!Number.isFinite(code) || code <= 0) return false; return allowServerErrors || code < 500; } function defaultStdout(message) { process.stdout.write(`[e2e-a11y] ${message}\n`); } function requiredFailure({ stdout, reason, message }) { stdout(`FAIL: ${message}`); return { status: "fail", reason, exitCode: 1 }; } export function hasRequiredAuthEnv({ env = process.env } = {}) { return Boolean(env.E2E_TENANT_EMAIL && env.E2E_TENANT_PASSWORD); } export function missingReadinessEnv({ env = process.env } = {}) { const required = [ "E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION", "E2E_TENANT_DASHBOARD_PATH", "E2E_TENANT_SETTINGS_ACCOUNT_PATH", "E2E_TENANT_SETTINGS_PROFILE_PATH", "E2E_TENANT_SUBSCRIPTION_PATH", "E2E_TENANT_MEMBERS_PATH", "E2E_TENANT_ROLES_AND_PERMISSIONS_PATH", "E2E_TENANT_NOTIFICATIONS_PATH", "E2E_TENANT_LOGS_PATH", "E2E_TENANT_LOG_EVENTS_PATH", "E2E_TENANT_AUDIT_EXPORT_PATH", "E2E_TENANT_PRIVACY_PATH", "E2E_TENANT_CHATBOT_PATH", "E2E_TENANT_AI_USAGE_PATH", "E2E_TENANT_API_PATH", "E2E_TENANT_API_KEYS_PATH", "E2E_TENANT_API_DOCS_PATH", "E2E_TENANT_API_LOGS_PATH", "E2E_TENANT_POLICIES_PATH", "E2E_TENANT_PORTAL_ANALYTICS_PATH", "E2E_TENANT_PORTAL_SETTINGS_PATH", "E2E_ADMIN_EMAIL_SEQUENCES_PATH", "E2E_PLUGIN_SETTINGS_PATH", "E2E_PLUGIN_READONLY_EMAIL", "E2E_PLUGIN_READONLY_PASSWORD", "E2E_WORKFLOW_ENGINE_PATH", "E2E_WORKFLOW_BUILDER_PATH", "E2E_WORKFLOW_RUN_API_PATH", "E2E_WORKFLOW_RUN_MANUAL_PATH", "E2E_WORKFLOW_WEBHOOK_BUILDER_PATH", "E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH", "E2E_WORKFLOW_WAITING_BUILDER_PATH", "E2E_WORKFLOW_WAITING_RUN_MANUAL_PATH", "E2E_WORKFLOW_WAITING_RUN_API_PATH", "E2E_WORKFLOW_WAITING_EXECUTIONS_PATH", "E2E_WORKFLOW_WAITING_EXECUTION_ID", "PLUGIN_BINARY_BASE_PATH", ]; const missing = required.filter((key) => !env[key]?.trim()); const registrationVerification = env.E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION; if (registrationVerification && registrationVerification !== "0" && registrationVerification !== "1") { missing.push("E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION=0|1"); } return missing; } export function missingReadinessModeEnv({ env = process.env } = {}) { const requiredModes = { E2E_A11Y_STRICT: "1", E2E_BROWSER_MATRIX: "1", E2E_ALLOW_WORKFLOW_RUN: "1", }; return Object.entries(requiredModes) .filter(([key, expected]) => env[key] !== expected) .map(([key, expected]) => `${key}=${expected}`); } export function missingReadinessMutatingOptInEnv({ env = process.env } = {}) { const requiredOptIns = { E2E_ALLOW_REGISTRATION_CREATE: "1", E2E_ALLOW_ACCOUNT_CREATE: "1", E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE: "1", E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION: "1", E2E_ALLOW_PLUGIN_SETTINGS_WRITE: "1", }; return Object.entries(requiredOptIns) .filter(([key, expected]) => env[key] !== expected) .map(([key, expected]) => `${key}=${expected}`); } export function missingNoTenantAccountCreateEnv({ env = process.env } = {}) { if (env.E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE !== "1") return []; return ["E2E_NO_TENANT_EMAIL", "E2E_NO_TENANT_PASSWORD"].filter((key) => !env[key]?.trim()); } export function missingSubscriptionSuccessReadinessEnv({ env = process.env } = {}) { if (env.E2E_REQUIRE_SUBSCRIPTION_SUCCESS !== "1") return []; const missing = ["E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH", "E2E_TENANT_DASHBOARD_PATH"].filter((key) => !env[key]?.trim()); if (env.REQUIRE_E2E_READINESS !== "1") { missing.push("REQUIRE_E2E_READINESS=1"); } if (env.E2E_BROWSER_MATRIX !== "1") { missing.push("E2E_BROWSER_MATRIX=1"); } return missing; } export function missingWorkflowWebhookRunReadinessEnv({ env = process.env } = {}) { if (env.E2E_ALLOW_WORKFLOW_WEBHOOK_RUN !== "1") return []; return [ "E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_WORKFLOW_WEBHOOK_BUILDER_PATH", "E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH", "E2E_WORKFLOW_WEBHOOK_TARGET_URL", ].filter((key) => !env[key]?.trim()); } export function missingWorkflowOutboxDrainReadinessEnv({ env = process.env } = {}) { if (env.E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN !== "1") return []; const missing = ["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_WORKFLOW_RUN_MANUAL_PATH"].filter((key) => !env[key]?.trim()); const expectedStatus = env.E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS?.trim().toLowerCase(); if (expectedStatus && expectedStatus !== "success" && expectedStatus !== "error") { missing.push("E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS=success|error"); } return missing; } export function runPlaywrightA11y({ env = process.env, spawn = spawnSync, stdout = defaultStdout, } = {}) { const targetUrl = resolveTargetUrl({ env }); const requireE2eA11y = env.REQUIRE_E2E_A11Y === "1"; const requireAuthE2e = env.REQUIRE_AUTH_E2E === "1"; const requireReadinessE2e = env.REQUIRE_E2E_READINESS === "1"; const requireMutatingReadinessE2e = env.REQUIRE_E2E_MUTATING_READINESS === "1"; if (env.SKIP_E2E_A11Y === "1") { const requiredLanes = [ ["REQUIRE_E2E_A11Y", requireE2eA11y], ["REQUIRE_AUTH_E2E", requireAuthE2e], ["REQUIRE_E2E_READINESS", requireReadinessE2e], ["REQUIRE_E2E_MUTATING_READINESS", requireMutatingReadinessE2e], ["E2E_REQUIRE_SUBSCRIPTION_SUCCESS", env.E2E_REQUIRE_SUBSCRIPTION_SUCCESS === "1"], ] .filter(([, enabled]) => enabled) .map(([key]) => `${key}=1`); if (requiredLanes.length > 0) { return requiredFailure({ stdout, reason: "explicit-skip-in-required-lane", message: `SKIP_E2E_A11Y=1 cannot be used with required E2E lanes: ${requiredLanes.join(", ")}.`, }); } stdout("SKIP_E2E_A11Y=1 set — skipping."); return { status: "skip", reason: "explicit-skip", exitCode: 0 }; } const missingWorkflowWebhookRun = missingWorkflowWebhookRunReadinessEnv({ env }); if (missingWorkflowWebhookRun.length > 0) { return requiredFailure({ stdout, reason: "workflow-webhook-run-readiness-env-missing", message: `E2E workflow webhook run readiness env missing: ${missingWorkflowWebhookRun.join(", ")} while E2E_ALLOW_WORKFLOW_WEBHOOK_RUN=1.`, }); } const missingWorkflowOutboxDrain = missingWorkflowOutboxDrainReadinessEnv({ env }); if (missingWorkflowOutboxDrain.length > 0) { return requiredFailure({ stdout, reason: "workflow-outbox-drain-readiness-env-missing", message: `E2E workflow outbox-drain readiness env missing: ${missingWorkflowOutboxDrain.join(", ")} while E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN=1.`, }); } const missingSubscriptionSuccess = missingSubscriptionSuccessReadinessEnv({ env }); if (missingSubscriptionSuccess.length > 0) { return requiredFailure({ stdout, reason: "subscription-success-readiness-env-missing", message: `E2E subscription success readiness env missing: ${missingSubscriptionSuccess.join(", ")} while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1.`, }); } const missingNoTenantAccountCreate = missingNoTenantAccountCreateEnv({ env }); if (missingNoTenantAccountCreate.length > 0) { return requiredFailure({ stdout, reason: "no-tenant-account-create-env-missing", message: `E2E no-tenant account creation env missing: ${missingNoTenantAccountCreate.join(", ")} while E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE=1.`, }); } if (!hasPlaywrightRuntime({ spawn })) { if (requireE2eA11y) { return requiredFailure({ stdout, reason: "runtime-missing", message: "Playwright runtime not installed while REQUIRE_E2E_A11Y=1.", }); } stdout("SKIP: Playwright runtime not installed (run `pnpm install` and `pnpm exec playwright install chromium`)."); return { status: "skip", reason: "runtime-missing", exitCode: 0 }; } if (!isUrlReachable(targetUrl, { spawn, allowServerErrors: !requireE2eA11y })) { if (requireE2eA11y) { return requiredFailure({ stdout, reason: "target-unreachable", message: `Target URL ${targetUrl} not reachable while REQUIRE_E2E_A11Y=1.`, }); } stdout(`SKIP: Target URL ${targetUrl} not reachable.`); stdout("Hint: start the app with `pnpm dev` or set E2E_BASE_URL=."); return { status: "skip", reason: "target-unreachable", exitCode: 0 }; } if (requireAuthE2e && !hasRequiredAuthEnv({ env })) { return requiredFailure({ stdout, reason: "auth-env-missing", message: "E2E_TENANT_EMAIL / E2E_TENANT_PASSWORD not set while REQUIRE_AUTH_E2E=1.", }); } if (requireReadinessE2e) { const missing = missingReadinessEnv({ env }); if (missing.length > 0) { return requiredFailure({ stdout, reason: "readiness-env-missing", message: `E2E readiness env missing: ${missing.join(", ")}.`, }); } const missingModes = missingReadinessModeEnv({ env }); if (missingModes.length > 0) { return requiredFailure({ stdout, reason: "readiness-mode-missing", message: `E2E readiness mode missing: ${missingModes.join(", ")}.`, }); } const missingMutatingOptIns = requireMutatingReadinessE2e ? missingReadinessMutatingOptInEnv({ env }) : []; if (missingMutatingOptIns.length > 0) { return requiredFailure({ stdout, reason: "readiness-mutating-opt-in-missing", message: `E2E readiness mutating opt-in missing: ${missingMutatingOptIns.join(", ")}.`, }); } } stdout(`Target ${targetUrl} reachable — running Playwright a11y suite.`); const result = spawn( "pnpm", ["exec", "playwright", "test", "--config=playwright.config.ts"], { stdio: "inherit" }, ); if (result.error) { return { status: "fail", reason: "spawn-error", exitCode: 1 }; } if (typeof result.status === "number" && result.status !== 0) { return { status: "fail", reason: "non-zero-exit", exitCode: result.status }; } return { status: "pass", reason: "ok", exitCode: 0 }; } const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); if (isCli) { const outcome = runPlaywrightA11y(); process.exit(outcome.exitCode); }