339 lines
13 KiB
JavaScript
Executable File
339 lines
13 KiB
JavaScript
Executable File
#!/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=<staging-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);
|
|
}
|