254 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|