Files
HiveGrid-Wireframes/hivegrid-cloud/tests/e2e/plugin-hook-lifecycle.mobile-a11y.spec.ts
T

476 lines
22 KiB
TypeScript

import { expect, test, type Locator, type Page } from "./utils/test";
import { expectNoCriticalA11yViolations } from "./utils/axe";
import { getTenantCredentials, loginAsTenantUser } from "./utils/auth";
import { compactMobileViewport, expectNoHorizontalOverflow, narrowMobileViewport } from "./utils/mobile";
import { escapeRegExp, skipIfUnavailable } from "./utils/navigation";
import {
isE2eOperatorOptInEnabled,
missingE2eInputs,
skipOrFailWhenE2eFixtureMissing,
skipOrFailWhenE2eInputsMissing,
skipOrFailWhenE2eOptInMissing,
skipWhenE2eOperatorOptInMissing,
} from "./utils/readiness";
import { requireWorkflowOutboxDrainReadiness, waitForWorkflowExecutionTerminal } from "./utils/workflow";
/**
* Plugin hook lifecycle mobile a11y — proves the seeded readiness plugin's
* `workflow.executed` hook (export `onWorkflowExecuted`) records a
* capability-use audit row after a manual workflow runs and the worker drains
* the outbox, then exercises deactivate→re-enable lifecycle controls.
*
* Safe-by-default gating:
* - Login + read-only assertions only need tenant credentials + the plugin
* settings path (skips unless REQUIRE_E2E_READINESS=1).
* - The hook-audit observation is OPERATOR-gated behind
* E2E_REQUIRE_PLUGIN_HOOK_AUDIT=1 because it needs a running general worker,
* plugin runtime, and outbox drain. It also reuses the workflow outbox-drain
* readiness gate so it only runs when the worker is actually draining.
* - Granting capabilities and toggling enable/disable are MUTATIONS, gated
* additionally behind E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION=1.
*
* Mutating tests restore the seeded baseline (plugin ENABLED, `log` granted,
* `audit` NOT granted) in a finally block so reruns stay idempotent. Note that
* re-running the readiness seed also resets capability state; here we revoke
* the `audit` grant explicitly through the revoke form to restore baseline.
*/
const credentials = getTenantCredentials();
const pluginSettingsPath = process.env.E2E_PLUGIN_SETTINGS_PATH;
const workflowRunManualPath = process.env.E2E_WORKFLOW_RUN_MANUAL_PATH;
// FIXTURE SEPARATION: these lifecycle/audit tests MUTATE the plugin they target
// (grant audit, deactivate/re-enable). They must NOT touch the shared
// `com.toolctrl.e2e-readiness` plugin, whose "enabled + audit-missing" seed
// baseline the plugin-settings review/grant tests depend on and which is
// UI-unrestorable once mutated (revoke auto-disables; the disabled+missing-cap
// row hides its enable form). The seeded `com.toolctrl.e2e-lifecycle` plugin is
// fully provisioned (all capabilities + hook consent granted), so granting audit
// is a no-op and deactivate→re-enable never hits the review-before-enable gate —
// the perfect dedicated fixture for these mutating tests.
const lifecyclePluginId = "com.toolctrl.e2e-lifecycle";
const pluginHookAuditRequiredInputs = [
"E2E_TENANT_EMAIL",
"E2E_TENANT_PASSWORD",
"E2E_PLUGIN_SETTINGS_PATH",
"E2E_WORKFLOW_RUN_MANUAL_PATH",
] as const;
// Audit row outcome label (en/de) and the readiness plugin's hook-driven
// capabilities. The seed grants `log`; consent grants `audit`. Either one
// producing a success row after the hook fires proves the lifecycle.
const auditSuccessOutcomePattern = /success|erfolgreich/i;
const hookCapabilityPattern = /^(audit|log)$/u;
/**
* Operator gate for the hook-audit observation lane. Skips when the operator
* opt-in is missing, and fails loudly when the opt-in is set but its required
* fixture inputs are absent — mirroring requireWorkflowOutboxDrainReadiness so a
* required readiness lane can never go green by silently skipping.
*/
function requirePluginHookAuditReadiness(env: NodeJS.ProcessEnv = process.env): void {
if (!isE2eOperatorOptInEnabled("E2E_REQUIRE_PLUGIN_HOOK_AUDIT", env)) {
skipWhenE2eOperatorOptInMissing(
"E2E_REQUIRE_PLUGIN_HOOK_AUDIT",
"plugin workflow.executed hook capability-use audit observation is operator-gated"
);
return;
}
const missing = missingE2eInputs(pluginHookAuditRequiredInputs, env);
if (missing.length > 0) {
throw new Error(`E2E plugin hook-audit readiness opt-in missing ${missing.join(", ")} while E2E_REQUIRE_PLUGIN_HOOK_AUDIT=1.`);
}
// The hook only fires + drains when the worker/outbox path is running, so the
// observation lane additionally requires the workflow outbox-drain readiness.
requireWorkflowOutboxDrainReadiness(env);
}
function getPluginHookAuditPollTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
const parsed = Number.parseInt(env.E2E_PLUGIN_HOOK_AUDIT_TIMEOUT_MS ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 90_000;
}
function skipUnlessPluginLifecycleMutationAllowed(reason: string): void {
skipOrFailWhenE2eOptInMissing("E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION", `skipping mutating plugin lifecycle ${reason}`);
}
test.describe("plugin hook lifecycle mobile a11y", () => {
test.use({ viewport: narrowMobileViewport, isMobile: true, hasTouch: true });
test.beforeEach(() => {
skipOrFailWhenE2eInputsMissing(
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_PLUGIN_SETTINGS_PATH"],
"Plugin hook lifecycle mobile a11y inputs not configured"
);
});
test("audit trail view for the readiness plugin stays readable on mobile without mutations", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin hook lifecycle audit view path");
const pluginRow = await readinessPluginRow(page);
const auditLink = pluginRow.locator('[data-ui-id="ui.plugins.audit.open"]').first();
if ((await auditLink.count()) === 0 || !(await auditLink.isVisible())) {
skipOrFailWhenE2eFixtureMissing("Plugin readiness fixture has no audit trail link (audit-trails view permission missing)");
}
// The audit page is read-only; assert no mutating request is issued while
// navigating + inspecting it.
const mutatingRequests: string[] = [];
await page.route("**/*", async (route) => {
const method = route.request().method();
// Observability beacons (Sentry/OTel) are not app mutations; the live
// boiler target has them enabled, so exclude them from the guard.
const isTelemetry = /sentry|otel|otlp/iu.test(route.request().url());
if (!isTelemetry && (method === "POST" || method === "PATCH" || method === "DELETE")) {
mutatingRequests.push(`${method} ${route.request().url()}`);
await route.abort();
return;
}
await route.continue();
});
await auditLink.click();
await expect(page).toHaveURL(/\/settings\/plugins\/[^/?#]+\/audit(?:[?#].*)?$/u);
// No data-ui-ids exist on the audit route; assert via real rendered text.
await expect(page.getByRole("heading", { name: new RegExp(escapeRegExp(lifecyclePluginId), "u") }).first()).toBeVisible();
await expect(page.getByText(/plugin capability invocations|aufrufe von plugin-berechtigungen/i).first()).toBeVisible();
// Either a populated table (Timestamp/Outcome headers) or the empty-state copy.
await expect(
page
.getByText(/timestamp|zeitstempel|outcome|ergebnis|no capability invocations|keine .*aufrufe/i)
.first()
).toBeVisible();
expect(mutatingRequests, `Unexpected mutating requests on audit view: ${mutatingRequests.join(", ")}`).toEqual([]);
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
});
test("workflow.executed hook records a capability-use audit row after the worker drains", async ({ page }) => {
requirePluginHookAuditReadiness();
skipUnlessPluginLifecycleMutationAllowed("workflow.executed hook capability-use observation");
await loginAsTenantUser(page, credentials!);
const settingsResponse = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, settingsResponse, "Plugin hook lifecycle settings path");
const capabilityRestore = await snapshotCapabilityGrant(page, "audit");
try {
// Grant `audit` so the hook can record an audit capability-use row. The
// seed baseline leaves `audit` ungranted; we restore it in finally.
await ensureCapabilityGranted(page, "audit");
const auditPath = await readinessAuditPath(page);
// Trigger the seeded manual workflow run; this fires `workflow.executed`.
const runResponse = await page.goto(workflowRunManualPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, runResponse, "Plugin hook lifecycle manual run path");
const runButton = page.locator('[data-ui-id="ui.workflows.run.manual.submit"], button.ui-workflows-run-manual-submit').first();
await expect(runButton).toBeVisible();
if (!(await runButton.isEnabled())) {
skipOrFailWhenE2eFixtureMissing("Workflow manual run button is disabled; provide a live deterministic workflow path to drive the plugin hook");
}
await runButton.click();
const executionResult = page.locator('[data-ui-id="ui.workflows.run.manual.executionResult"]').first();
await expect(executionResult).toBeVisible({ timeout: 15_000 });
const detailLink = executionResult.locator('[data-ui-id="ui.workflows.run.manual.viewExecutionFlow"]').first();
await expect(detailLink).toBeVisible();
const executionDetailsHref = await detailLink.getAttribute("href");
expect(executionDetailsHref).toMatch(/\/workflow-engine\/workflows\/[^/?#]+\/executions\?executionId=[^&#]+/u);
// Wait for the worker to drain the outbox and the execution to terminate.
await waitForWorkflowExecutionTerminal(page, executionDetailsHref!);
// Poll the audit trail until a success capability-use row appears for the
// readiness plugin's hook-driven `audit`/`log` capability.
await expect
.poll(async () => readinessPluginHasSuccessfulHookAuditRow(page, auditPath), {
message: "readiness plugin workflow.executed hook recorded a success capability-use audit row",
timeout: getPluginHookAuditPollTimeoutMs(),
intervals: [1_000, 2_000, 5_000],
})
.toBe(true);
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
} finally {
await restoreCapabilityGrant(page, capabilityRestore);
}
});
test("deactivate then re-enable restores the readiness plugin lifecycle baseline", async ({ page }) => {
skipUnlessPluginLifecycleMutationAllowed("deactivate/re-enable lifecycle flow");
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin hook lifecycle deactivate/re-enable path");
let pluginRow = await readinessPluginRow(page);
const lifecycleForm = pluginRow.locator('[data-ui-id="ui.plugins.lifecycle.form"]').first();
if ((await lifecycleForm.count()) === 0 || !(await lifecycleForm.isVisible())) {
skipOrFailWhenE2eFixtureMissing("Plugin readiness fixture has no manageable lifecycle controls");
}
const initialOperation = await lifecycleOperation(pluginRow);
// Seed baseline has the plugin ENABLED, so the lifecycle action is "disable".
expect(initialOperation).toBe("disable");
try {
// The readiness plugin's seed baseline leaves `audit` ungranted, which hides the
// bare enable form once the plugin is disabled (revoke/deactivate + a missing
// capability ⇒ review-before-enable). Grant the missing capability first so the
// deactivate→re-enable lifecycle toggle is exercised on a fully-provisioned plugin
// (a fresh reseed restores the audit-ungranted baseline between runs).
await ensureCapabilityGranted(page, "audit");
pluginRow = await readinessPluginRow(page);
// Deactivate.
await pluginRow.locator('[data-ui-id="ui.plugins.lifecycle.form"] [data-ui-id="ui.plugins.lifecycle.button"]').first().click();
await expect(pluginRow.locator('input[name="op"]').first()).toHaveValue("enable");
await page.reload({ waitUntil: "domcontentloaded" });
pluginRow = await readinessPluginRow(page);
await expect(pluginRow.locator('[data-ui-id="ui.plugins.lifecycle.form"] input[name="op"]').first()).toHaveValue("enable");
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
// Re-enable.
const enableForm = pluginRow.locator('[data-ui-id="ui.plugins.lifecycle.form"]').first();
await enableForm.locator('[data-ui-id="ui.plugins.lifecycle.button"]').click();
await expect(pluginRow.locator('input[name="op"]').first()).toHaveValue("disable");
await page.reload({ waitUntil: "domcontentloaded" });
pluginRow = await readinessPluginRow(page);
await expect(pluginRow.locator('[data-ui-id="ui.plugins.lifecycle.form"] input[name="op"]').first()).toHaveValue("disable");
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
} finally {
await restoreLifecycleEnabled(page, initialOperation === "disable");
}
});
});
test.describe("plugin hook lifecycle compact mobile a11y", () => {
test.use({ viewport: compactMobileViewport, isMobile: true, hasTouch: true });
test.beforeEach(() => {
skipOrFailWhenE2eInputsMissing(
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_PLUGIN_SETTINGS_PATH"],
"Plugin hook lifecycle compact mobile a11y inputs not configured"
);
});
test("keeps the readiness plugin audit link reachable at 320px", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin hook lifecycle compact audit path");
const pluginRow = await readinessPluginRow(page);
await expect(pluginRow).toBeVisible();
const auditLink = pluginRow.locator('[data-ui-id="ui.plugins.audit.open"]').first();
if ((await auditLink.count()) === 0 || !(await auditLink.isVisible())) {
skipOrFailWhenE2eFixtureMissing("Plugin readiness fixture has no audit trail link at compact width");
}
await auditLink.focus();
await expect(auditLink).toBeFocused();
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
});
});
async function readinessPluginRow(page: Page): Promise<Locator> {
const row = page.locator(`[data-ui-id="ui.plugins.item"][data-plugin-id="${lifecyclePluginId}"]`).first();
if ((await row.count()) === 0 || !(await row.isVisible())) {
skipOrFailWhenE2eFixtureMissing(`Plugin settings fixture has no ${lifecyclePluginId} seed plugin row`);
}
return row;
}
/**
* Resolve the readiness plugin audit route path from its per-row audit link so
* the polling helper can re-navigate without re-reading the link each time.
*/
async function readinessAuditPath(page: Page): Promise<string> {
await openPluginSettingsIndex(page);
const pluginRow = await readinessPluginRow(page);
const auditLink = pluginRow.locator('[data-ui-id="ui.plugins.audit.open"]').first();
if ((await auditLink.count()) === 0 || !(await auditLink.isVisible())) {
skipOrFailWhenE2eFixtureMissing("Plugin readiness fixture has no audit trail link (audit-trails view permission missing)");
}
const href = await auditLink.getAttribute("href");
if (!href) {
skipOrFailWhenE2eFixtureMissing("Plugin readiness audit link has no resolvable href");
}
return new URL(href!, page.url()).pathname;
}
/**
* Navigate to the audit route and return whether a success-outcome row exists
* for the readiness plugin's hook-driven `audit`/`log` capability. The audit
* route renders no data-ui-ids, so this inspects the rendered table rows.
*/
async function readinessPluginHasSuccessfulHookAuditRow(page: Page, auditPath: string, pollIndex = Date.now()): Promise<boolean> {
const pollUrl = new URL(auditPath, page.url());
pollUrl.searchParams.set("e2ePoll", String(pollIndex));
await page.goto(pollUrl.toString(), { waitUntil: "domcontentloaded" });
const rows = page.locator("table tbody tr");
const rowCount = await rows.count();
for (let index = 0; index < rowCount; index += 1) {
const row = rows.nth(index);
const text = (await row.innerText()).replace(/\s+/gu, " ").trim();
// Capability is rendered in its own monospace cell; outcome label sits in a
// status badge. Require both a hook capability name and a success label.
const capabilityCell = (await row.locator("td").nth(1).innerText()).trim();
if (hookCapabilityPattern.test(capabilityCell) && auditSuccessOutcomePattern.test(text)) {
return true;
}
}
return false;
}
async function openPluginSettingsIndex(page: Page): Promise<void> {
if (!pluginSettingsPath) return;
const currentPath = new URL(page.url()).pathname;
if (currentPath !== pluginSettingsPath) {
await page.goto(pluginSettingsPath, { waitUntil: "domcontentloaded" });
return;
}
await page.reload({ waitUntil: "domcontentloaded" });
}
type CapabilityGrantSnapshot = {
capabilityName: string;
granted: boolean;
};
function capabilityRevokeForm(pluginRow: Locator, capabilityName: string): Locator {
return pluginRow
.locator(`[data-ui-id="ui.plugins.capability.revoke.form"]:has(input[name="capabilityNames[]"][value="${capabilityName}"])`)
.first();
}
async function isCapabilityGranted(pluginRow: Locator, capabilityName: string): Promise<boolean> {
const form = capabilityRevokeForm(pluginRow, capabilityName);
return (await form.count()) > 0 && (await form.isVisible());
}
async function snapshotCapabilityGrant(page: Page, capabilityName: string): Promise<CapabilityGrantSnapshot> {
const pluginRow = await readinessPluginRow(page);
return { capabilityName, granted: await isCapabilityGranted(pluginRow, capabilityName) };
}
async function ensureCapabilityGranted(page: Page, capabilityName: string): Promise<void> {
await openPluginSettingsIndex(page);
let pluginRow = await readinessPluginRow(page);
if (await isCapabilityGranted(pluginRow, capabilityName)) return;
const reviewButton = await firstExistingLocator(
pluginRow.locator('[data-ui-id="ui.plugins.capabilityReview.toggle"]').first(),
pluginRow.getByRole("button", { name: /review capabilities|berechtigungen prüfen/i }).first()
);
if ((await reviewButton.count()) === 0 || !(await reviewButton.isVisible())) {
skipOrFailWhenE2eFixtureMissing(`Plugin readiness fixture has no review controls to grant ${capabilityName}`);
}
await reviewButton.click();
const reviewRow = reviewButton.locator("xpath=ancestor::li[1]");
const reviewForm = reviewRow.locator('[data-ui-id="ui.plugins.capabilityReview.form"]').first();
await expect(reviewForm).toBeVisible();
const capabilityCheckbox = reviewForm.locator(`input[name="capabilities[]"][value="${capabilityName}"]`).first();
if ((await capabilityCheckbox.count()) === 0) {
skipOrFailWhenE2eFixtureMissing(`Plugin readiness fixture review form has no ${capabilityName} capability to grant`);
}
const checkboxes = reviewForm.locator('[data-ui-id="ui.plugins.capabilityReview.checkbox"]:not([disabled])');
const checkboxCount = await checkboxes.count();
for (let index = 0; index < checkboxCount; index += 1) {
await checkboxes.nth(index).check();
}
const approveButton = await firstExistingLocator(
reviewForm.locator('[data-ui-id="ui.plugins.capabilityReview.button"]').first(),
page.getByRole("button", { name: /approve|freigeben/i }).last()
);
await expect(approveButton).toBeEnabled();
await approveButton.click();
await page.reload({ waitUntil: "domcontentloaded" });
// Grant + (when the plugin was disabled) enable lands server-side, but the revoke
// form that isCapabilityGranted reads renders after the reload settles — poll the
// freshly-resolved row rather than reading it once and racing the render.
await expect
.poll(async () => isCapabilityGranted(await readinessPluginRow(page), capabilityName), {
message: `granted ${capabilityName} capability for ${lifecyclePluginId}`,
timeout: 10_000,
intervals: [250, 500, 1_000],
})
.toBe(true);
}
async function restoreCapabilityGrant(page: Page, snapshot: CapabilityGrantSnapshot): Promise<void> {
if (page.isClosed()) return;
await openPluginSettingsIndex(page);
let pluginRow = await readinessPluginRow(page);
const granted = await isCapabilityGranted(pluginRow, snapshot.capabilityName);
if (granted && !snapshot.granted) {
await capabilityRevokeForm(pluginRow, snapshot.capabilityName).locator('[data-ui-id="ui.plugins.capability.revoke.button"]').click();
await page.reload({ waitUntil: "domcontentloaded" });
pluginRow = await readinessPluginRow(page);
} else if (!granted && snapshot.granted) {
await ensureCapabilityGranted(page, snapshot.capabilityName);
pluginRow = await readinessPluginRow(page);
}
if ((await isCapabilityGranted(pluginRow, snapshot.capabilityName)) !== snapshot.granted) {
throw new Error(
`Unable to restore ${lifecyclePluginId} capability ${snapshot.capabilityName} to ${snapshot.granted ? "granted" : "missing"}`
);
}
}
async function lifecycleOperation(pluginRow: Locator): Promise<"enable" | "disable" | null> {
const op = pluginRow.locator('[data-ui-id="ui.plugins.lifecycle.form"] input[name="op"]').first();
if ((await op.count()) === 0) return null;
const value = await op.inputValue();
return value === "enable" || value === "disable" ? value : null;
}
async function restoreLifecycleEnabled(page: Page, enabled: boolean): Promise<void> {
if (page.isClosed()) return;
await openPluginSettingsIndex(page);
const pluginRow = await readinessPluginRow(page);
const operation = await lifecycleOperation(pluginRow);
if (operation === null) {
throw new Error(`Unable to restore ${lifecyclePluginId} lifecycle state because lifecycle controls are unavailable`);
}
const currentEnabled = operation === "disable";
if (currentEnabled === enabled) return;
await pluginRow.locator('[data-ui-id="ui.plugins.lifecycle.button"]').first().click();
await expect(pluginRow.locator('input[name="op"]').first()).toHaveValue(enabled ? "disable" : "enable");
await page.reload({ waitUntil: "domcontentloaded" });
await expect((await readinessPluginRow(page)).locator('[data-ui-id="ui.plugins.lifecycle.form"] input[name="op"]').first()).toHaveValue(
enabled ? "disable" : "enable"
);
}
async function firstExistingLocator(primary: Locator, fallback: Locator): Promise<Locator> {
return (await primary.count()) > 0 ? primary : fallback;
}