476 lines
22 KiB
TypeScript
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;
|
|
}
|