import { expect, type Page } from "@playwright/test"; import { missingE2eInputs, skipWhenE2eOperatorOptInMissing } from "./readiness"; /** * Shared helpers for asserting that a workflow execution reaches a terminal * status after the worker drains the outbox. Extracted from the workflow * builder-run spec so other readiness slices (e.g. plugin or subscription * flows that kick off workflows) can reuse the same polling contract. */ export const workflowTerminalStatuses = new Set(["success", "error"]); const workflowExecutionStatusAliases = new Map([ ["success", "success"], ["error", "error"], ["running", "running"], ["waiting", "waiting"], ["waitingblock", "waitingBlock"], ["incomplete", "incomplete"], ]); export const workflowOutboxDrainRequiredInputs = ["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_WORKFLOW_RUN_MANUAL_PATH"] as const; /** * Operator gate for the outbox-drain readiness smoke. Skips when the operator * opt-in is missing, and fails loudly when the opt-in is set but its required * fixture inputs are absent — so a required readiness lane can never go green * by silently skipping. */ export function requireWorkflowOutboxDrainReadiness(env: NodeJS.ProcessEnv = process.env): void { if (env.E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN !== "1") { skipWhenE2eOperatorOptInMissing("E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN", "worker/outbox drain readiness is operator-gated"); return; } const missing = missingE2eInputs(workflowOutboxDrainRequiredInputs, env); if (missing.length > 0) { throw new Error(`E2E workflow outbox-drain readiness opt-in missing ${missing.join(", ")} while E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN=1.`); } } export function getWorkflowOutboxDrainPollTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { const parsed = Number.parseInt(env.E2E_WORKFLOW_OUTBOX_DRAIN_TIMEOUT_MS ?? "", 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : 90_000; } export function getWorkflowOutboxDrainExpectedStatus(env: NodeJS.ProcessEnv = process.env): "success" | "error" { const raw = env.E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS?.trim().toLowerCase() || "success"; if (raw === "success" || raw === "error") return raw; throw new Error("E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS must be either success or error."); } export function normalizeWorkflowExecutionStatus(value: string | undefined): string | null { if (!value) return null; const normalized = value.trim().replace(/\s+/gu, "").toLowerCase(); return workflowExecutionStatusAliases.get(normalized) ?? null; } export function extractWorkflowExecutionStatusFromText(text: string): string | null { const lines = text .split(/\r?\n/u) .map((line) => line.trim()) .filter(Boolean); for (const [index, line] of lines.entries()) { const inlineStatus = line.match(/^status\s+([a-z]+)$/iu)?.[1]; const normalizedInlineStatus = normalizeWorkflowExecutionStatus(inlineStatus); if (normalizedInlineStatus) return normalizedInlineStatus; if (/^status$/iu.test(line)) { for (const candidate of lines.slice(index + 1, index + 4)) { const normalizedCandidate = normalizeWorkflowExecutionStatus(candidate); if (normalizedCandidate) return normalizedCandidate; } } } return null; } async function readWorkflowExecutionStatus(page: Page, executionDetailsHref: string, pollIndex: number): Promise { const pollUrl = new URL(executionDetailsHref, page.url()); pollUrl.searchParams.set("e2ePoll", String(pollIndex)); await page.goto(pollUrl.toString(), { waitUntil: "domcontentloaded" }); await page .getByText(/^status$/i) .first() .waitFor({ state: "visible", timeout: 5_000 }) .catch(() => undefined); return extractWorkflowExecutionStatusFromText(await page.locator("body").innerText()); } /** * Polls the execution details view until the workflow reaches the expected * terminal status (success/error) after the worker drains the outbox, or fails * once the drain timeout elapses. Returns the observed terminal status. */ export async function waitForWorkflowExecutionTerminal(page: Page, executionDetailsHref: string): Promise<"success" | "error"> { const expectedStatus = getWorkflowOutboxDrainExpectedStatus(); let pollIndex = 0; let observedStatus: string | null = null; await expect .poll( async () => { pollIndex += 1; observedStatus = await readWorkflowExecutionStatus(page, executionDetailsHref, pollIndex); return observedStatus ?? "unknown"; }, { message: `workflow execution reached expected ${expectedStatus} status after worker/outbox drain`, timeout: getWorkflowOutboxDrainPollTimeoutMs(), intervals: [1_000, 2_000, 5_000], } ) .toBe(expectedStatus); // The poll only resolves once the observed status equals the expected terminal // status, so the observed value is guaranteed to be a terminal status here. expect(observedStatus).toBe(expectedStatus); return expectedStatus; }