Files
HiveGrid-Wireframes/hivegrid-cloud/tests/e2e/workflow-builder-actions.spec.ts
T

254 lines
12 KiB
TypeScript

import { expect, test, type Locator, type Page } from "./utils/test";
import { getTenantCredentials, loginAsTenantUser } from "./utils/auth";
import { escapeRegExp, postDataIncludesField, skipIfUnavailable } from "./utils/navigation";
import { skipOrFailWhenE2eFixtureMissing, skipOrFailWhenE2eInputsMissing } from "./utils/readiness";
const credentials = getTenantCredentials();
const workflowBuilderPath = process.env.E2E_WORKFLOW_BUILDER_PATH;
type CapturedBuilderPosts = {
submissions: string[];
};
async function openBuilder(page: Page): Promise<void> {
skipOrFailWhenE2eInputsMissing(
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_WORKFLOW_BUILDER_PATH"],
"Workflow builder action smoke inputs not configured"
);
await loginAsTenantUser(page, credentials!);
const builderResponse = await page.goto(workflowBuilderPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, builderResponse, workflowBuilderPath!);
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(workflowBuilderPath!)}(?:[?#].*)?$`, "u"));
await expect(page.locator('[data-ui-id="ui.workflows.builder.canvas"]').first()).toBeVisible({ timeout: 10_000 });
}
async function interceptBuilderPosts(page: Page): Promise<CapturedBuilderPosts> {
const submissions: string[] = [];
const currentUrl = new URL(page.url());
await page.route("**/*", async (route) => {
const request = route.request();
const requestUrl = new URL(request.url());
if (request.method() === "POST" && requestUrl.origin === currentUrl.origin && (requestUrl.pathname === currentUrl.pathname || requestUrl.pathname === `${currentUrl.pathname}.data`)) {
submissions.push(request.postData() ?? "");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: "E2E intercepted workflow builder mutation" }),
});
return;
}
await route.fallback();
});
return { submissions };
}
async function waitForAction({ submissions }: CapturedBuilderPosts, action: string): Promise<string> {
await expect
.poll(
() => submissions.find((postData) => postDataIncludesField(postData, "action", new RegExp(`^${escapeRegExp(action)}$`, "u"))) ?? null,
{ message: `workflow builder ${action} POST payload was captured` }
)
.not.toBeNull();
const submission = submissions.find((postData) => postDataIncludesField(postData, "action", new RegExp(`^${escapeRegExp(action)}$`, "u")));
expect(submission).toBeTruthy();
return submission!;
}
async function selectFirstBuilderBlock(page: Page): Promise<{ nodeId: string; node: Locator }> {
const nodes = page.locator('[data-ui-id="ui.workflows.builder.node"][data-workflow-node-kind="block"]');
// React Flow mounts the canvas immediately but populates node buttons in a
// post-paint effect; wait for the first node to attach before the zero-check
// so a one-shot count() doesn't race the mount and false-trip the guard.
await nodes.first().waitFor({ state: "attached", timeout: 10_000 }).catch(() => undefined);
const renderedNodes = await nodes.count();
if (renderedNodes === 0) {
skipOrFailWhenE2eFixtureMissing("Workflow builder fixture needs at least one persisted block");
}
const node = nodes.first();
await expect(node).toBeVisible();
const nodeId = await node.getAttribute("data-workflow-node-id");
expect(nodeId).toBeTruthy();
await node.click();
await expect(page.locator('[data-ui-id="ui.workflows.builder.nodeSettings"]').first()).toHaveAttribute("data-workflow-node-id", nodeId!);
return { nodeId: nodeId!, node };
}
function getSelectedBlockDeleteButton(page: Page): Locator {
const selectedBlockSettings = page.locator('[data-ui-id="ui.workflows.builder.nodeSettings"]').first();
return selectedBlockSettings
.locator('[data-ui-id="ui.workflows.builder.deleteBlock"]')
.or(selectedBlockSettings.getByRole("button", { name: /^(delete|remove|löschen)$/iu }))
.first();
}
function getStatusToggle(page: Page): Locator {
return page.locator('[data-ui-id="ui.workflows.builder.statusToggle"] button').first();
}
async function getStatusToggleState(toggle: Locator): Promise<boolean> {
const ariaChecked = await toggle.getAttribute("aria-checked");
if (ariaChecked !== "true" && ariaChecked !== "false") {
skipOrFailWhenE2eFixtureMissing("Workflow builder status toggle does not expose aria-checked state");
}
return ariaChecked === "true";
}
async function reloadBuilder(page: Page): Promise<void> {
const response = await page.reload({ waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, workflowBuilderPath!);
await expect(page.locator('[data-ui-id="ui.workflows.builder.canvas"]').first()).toBeVisible({ timeout: 10_000 });
}
test.describe("workflow builder action contracts", () => {
test("posts add-block from the builder block picker without mutating the database", async ({ page }) => {
await openBuilder(page);
const captured = await interceptBuilderPosts(page);
await selectFirstBuilderBlock(page);
const addNextBlock = page.locator('[data-ui-id="ui.workflows.builder.addNextBlock"]').first();
if ((await addNextBlock.count()) === 0) {
skipOrFailWhenE2eFixtureMissing("Workflow builder fixture needs a block that can add a next action block");
}
await expect(addNextBlock).toBeVisible();
await addNextBlock.click();
const logBlockOption = page.locator('[data-ui-id="ui.workflows.builder.blockTypeOption"][data-workflow-block-type="log"]').first();
await expect(logBlockOption).toBeVisible();
await logBlockOption.click();
const submission = await waitForAction(captured, "add-block");
expect(postDataIncludesField(submission, "type", /^log$/u)).toBe(true);
expect(postDataIncludesField(submission, "fromBlockId", /.+/u)).toBe(true);
expect(postDataIncludesField(submission, "csrf", /.+/u)).toBe(true);
});
test("posts update-block and toggle contracts without mutating the database", async ({ page }) => {
await openBuilder(page);
const captured = await interceptBuilderPosts(page);
const { nodeId } = await selectFirstBuilderBlock(page);
const descriptionInput = page.locator('[data-ui-id="ui.workflows.builder.blockDescription"]').first();
await expect(descriptionInput).toBeVisible();
await descriptionInput.fill(`E2E intercepted description ${Date.now()}`);
await descriptionInput.blur();
const updateSubmission = await waitForAction(captured, "update-block");
expect(postDataIncludesField(updateSubmission, "id", new RegExp(`^${escapeRegExp(nodeId)}$`, "u"))).toBe(true);
expect(postDataIncludesField(updateSubmission, "block", /E2E intercepted description/u)).toBe(true);
expect(postDataIncludesField(updateSubmission, "csrf", /.+/u)).toBe(true);
const toggle = getStatusToggle(page);
await expect(toggle).toBeVisible();
if (!(await toggle.isEnabled())) {
skipOrFailWhenE2eFixtureMissing("Workflow builder fixture status toggle is disabled; provide a ready or live workflow");
}
await toggle.click();
const toggleSubmission = await waitForAction(captured, "toggle");
expect(postDataIncludesField(toggleSubmission, "enabled", /^(true|false)$/u)).toBe(true);
expect(postDataIncludesField(toggleSubmission, "csrf", /.+/u)).toBe(true);
});
test("persists workflow status toggles across reload and restores the seeded state", async ({ page }) => {
await openBuilder(page);
// Drive the toggle to `target`, then reload and return the state actually
// persisted (read from the loader-rendered toggle). wf_123 is a single shared
// seed row whose status now genuinely persists, so both the assertion and the
// restore must work from DB-truth rather than a hard-coded value.
async function setToggleAndReload(target: boolean): Promise<boolean> {
const toggle = getStatusToggle(page);
await expect(toggle).toBeVisible();
if (!(await toggle.isEnabled())) {
skipOrFailWhenE2eFixtureMissing("Workflow builder fixture status toggle is disabled; provide a ready or live workflow");
}
if ((await getStatusToggleState(toggle)) !== target) {
// aria-checked flips optimistically on click, before the submit's POST
// commits the status. Wait for the action response so the subsequent
// reload reads the persisted value rather than racing the write.
const togglePosted = page.waitForResponse((response) => response.request().method() === "POST" && response.url().includes(workflowBuilderPath!));
await toggle.click();
await togglePosted;
await expect(toggle).toHaveAttribute("aria-checked", String(target));
}
await reloadBuilder(page);
const reloaded = getStatusToggle(page);
await expect(reloaded).toBeVisible();
return getStatusToggleState(reloaded);
}
// Capture the persisted baseline (whatever a prior run left behind), so the
// assertions are relative to it and never a literal true/false.
let initialState: boolean | null = null;
try {
const toggle = getStatusToggle(page);
await expect(toggle).toBeVisible();
if (!(await toggle.isEnabled())) {
skipOrFailWhenE2eFixtureMissing("Workflow builder fixture status toggle is disabled; provide a ready or live workflow");
}
initialState = await getStatusToggleState(toggle);
const persistedAfterToggle = await setToggleAndReload(!initialState);
expect(persistedAfterToggle).toBe(!initialState);
} finally {
// Always restore to the captured baseline, verified by a reload, so the
// shared row is left exactly as found and consecutive runs are idempotent.
if (initialState !== null && (await getStatusToggle(page).count()) > 0) {
const restored = await setToggleAndReload(initialState);
expect(restored).toBe(initialState);
}
}
});
test("posts delete-block through keyboard interaction without mutating the database", async ({ page }) => {
await openBuilder(page);
const captured = await interceptBuilderPosts(page);
const node = page.locator('[data-ui-id="ui.workflows.builder.node"][data-workflow-node-kind="block"]').first();
// React Flow populates node buttons in a post-paint effect; wait for the first to
// attach before the zero-check so a one-shot count() doesn't race the mount.
await node.waitFor({ state: "attached", timeout: 10_000 }).catch(() => undefined);
if ((await node.count()) === 0) {
skipOrFailWhenE2eFixtureMissing("Workflow builder fixture needs at least one persisted block");
}
await expect(node).toBeVisible();
const nodeId = await node.getAttribute("data-workflow-node-id");
expect(nodeId).toBeTruthy();
await node.focus();
await expect(node).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.locator('[data-ui-id="ui.workflows.builder.nodeSettings"]').first()).toHaveAttribute("data-workflow-node-id", nodeId!);
await node.focus();
await expect(node).toBeFocused();
await page.keyboard.press("Delete");
const submission = await waitForAction(captured, "delete-block");
expect(postDataIncludesField(submission, "id", new RegExp(`^${escapeRegExp(nodeId!)}$`, "u"))).toBe(true);
expect(postDataIncludesField(submission, "csrf", /.+/u)).toBe(true);
await expect(page.locator('[data-ui-id="ui.workflows.builder.canvas"]').first()).toBeVisible();
});
test("posts delete-block through the visible delete button without mutating the database", async ({ page }) => {
await openBuilder(page);
const captured = await interceptBuilderPosts(page);
const { nodeId } = await selectFirstBuilderBlock(page);
const deleteBlock = getSelectedBlockDeleteButton(page);
await expect(deleteBlock).toBeVisible();
await deleteBlock.click();
const submission = await waitForAction(captured, "delete-block");
expect(postDataIncludesField(submission, "id", new RegExp(`^${escapeRegExp(nodeId)}$`, "u"))).toBe(true);
expect(postDataIncludesField(submission, "csrf", /.+/u)).toBe(true);
});
});