Files
HiveGrid-Wireframes/hivegrid-cloud/tests/e2e/utils/workflow.ts
T

122 lines
4.9 KiB
TypeScript

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<string>(["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<string | null> {
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;
}