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

1242 lines
58 KiB
TypeScript

import { expect, test, type Frame, 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, postDataIncludesField, singleFetchActionData, skipIfUnavailable } from "./utils/navigation";
import { skipOrFailWhenE2eFixtureMissing, skipOrFailWhenE2eInputsMissing, skipOrFailWhenE2eOptInMissing } from "./utils/readiness";
const credentials = getTenantCredentials();
const pluginSettingsPath = process.env.E2E_PLUGIN_SETTINGS_PATH;
const readinessPluginId = "com.toolctrl.e2e-readiness";
const lifecyclePluginId = "com.toolctrl.e2e-lifecycle";
const readonlyCredentials =
process.env.E2E_PLUGIN_READONLY_EMAIL && process.env.E2E_PLUGIN_READONLY_PASSWORD
? { email: process.env.E2E_PLUGIN_READONLY_EMAIL, password: process.env.E2E_PLUGIN_READONLY_PASSWORD }
: null;
function skipUnlessPluginLifecycleMutationAllowed(reason: string): void {
skipOrFailWhenE2eOptInMissing("E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION", `skipping mutating plugin lifecycle ${reason}`);
}
test.describe("plugin settings mobile a11y", () => {
test.use({ viewport: narrowMobileViewport, isMobile: true, hasTouch: true });
test.beforeEach(() => {
skipOrFailWhenE2eInputsMissing(
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_PLUGIN_SETTINGS_PATH"],
"Plugin settings mobile a11y inputs not configured"
);
});
test("settings page has no mobile overflow or critical a11y violations", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, pluginSettingsPath!);
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(pluginSettingsPath!)}(?:[?#].*)?$`, "u"));
await expect(page.locator("body")).toBeVisible();
const main = page.locator("main").first();
if ((await main.count()) > 0) {
await expect(main).toBeVisible();
}
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
});
test("capability consent review enables approval only after explicit confirmation", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings consent path");
const pluginRow = await readinessPluginRow(page);
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 settings fixture has no missing capabilities to review");
}
const reviewPanelId = await reviewButton.getAttribute("aria-controls");
expect(reviewPanelId).toMatch(/.+/u);
await expect(reviewButton).toHaveAttribute("aria-expanded", "false");
await reviewButton.focus();
await expect(reviewButton).toBeFocused();
await page.keyboard.press("Space");
await expect(reviewButton).toHaveAttribute("aria-expanded", "true");
const reviewRow = reviewButton.locator("xpath=ancestor::li[1]");
const reviewForm = reviewRow.locator('[data-ui-id="ui.plugins.capabilityReview.form"]').first();
await expect(reviewForm).toBeVisible();
await expect(reviewForm).toHaveAttribute("id", reviewPanelId!);
await expect(reviewForm).toHaveAccessibleName(new RegExp(readinessPluginId, "u"));
await expect(reviewForm.locator('input[name="id"]')).toHaveValue(/.+/u);
await expect(reviewForm.locator('input[name="op"]')).toHaveValue(/^(grant|grantAndEnable)$/u);
await expect(reviewForm.locator('input[name="capabilityReviewDigest"]')).toHaveValue(/.+/u);
await expect(reviewForm.locator('input[name="capabilitiesReviewed"]')).toHaveValue("no");
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).toBeDisabled();
const checkboxes = reviewForm.locator('[data-ui-id="ui.plugins.capabilityReview.checkbox"]');
const checkboxCount = await checkboxes.count();
expect(checkboxCount).toBeGreaterThan(0);
for (let index = 0; index < checkboxCount; index += 1) {
await checkboxes.nth(index).check();
}
await expect(reviewForm.locator('input[name="capabilitiesReviewed"]')).toHaveValue("yes");
await expect(approveButton).toBeEnabled();
await checkboxes.first().uncheck();
await expect(reviewForm.locator('input[name="capabilitiesReviewed"]')).toHaveValue("no");
await expect(approveButton).toBeDisabled();
const confirmation = await firstExistingLocator(
checkboxes.first(),
page.getByLabel(/reviewed the requested plugin capabilities|angeforderten plugin-berechtigungen geprüft/i).first(),
);
await expect(confirmation).toBeVisible();
await confirmation.check();
await expect(reviewForm.locator('input[name="capabilitiesReviewed"]')).toHaveValue("yes");
await expect(approveButton).toBeEnabled();
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
const submittedPost = await capturePluginPost(page);
await approveButton.click();
await expect.poll(submittedPost, { message: "capability grant POST payload was captured" }).not.toBeNull();
const postData = submittedPost()?.postData ?? null;
expect(postDataIncludesField(postData, "id", /.+/u)).toBe(true);
expect(postDataIncludesField(postData, "op", /^(grant|grantAndEnable)$/u)).toBe(true);
expect(postDataIncludesField(postData, "capabilityReviewDigest", /.+/u)).toBe(true);
expect(postDataIncludesField(postData, "capabilities[]", /.+/u)).toBe(true);
expect(postDataIncludesField(postData, "capabilitiesReviewed", /^yes$/u)).toBe(true);
});
test("capability review deny clears confirmation without submitting", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings capability deny path");
const pluginRow = await readinessPluginRow(page);
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 settings fixture has no missing capabilities to deny");
}
const submittedPost = await capturePluginPost(page);
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 checkboxes = reviewForm.locator('[data-ui-id="ui.plugins.capabilityReview.checkbox"]:not([disabled])');
if ((await checkboxes.count()) === 0) {
skipOrFailWhenE2eFixtureMissing("Plugin settings fixture has no deny-able available capability checkbox");
}
const approveButton = reviewForm.locator('[data-ui-id="ui.plugins.capabilityReview.button"]').first();
const denyButton = reviewForm.locator('[data-ui-id="ui.plugins.capabilityReview.deny"]').first();
const checkboxCount = await checkboxes.count();
for (let index = 0; index < checkboxCount; index += 1) {
await checkboxes.nth(index).check();
}
await expect(reviewForm.locator('input[name="capabilitiesReviewed"]')).toHaveValue("yes");
await expect(approveButton).toBeEnabled();
await denyButton.click();
await expect(reviewButton).toHaveAttribute("aria-expanded", "false");
await expect(reviewForm).toHaveCount(0);
await expect.poll(submittedPost, { message: "capability deny stays non-mutating", timeout: 500 }).toBeNull();
await reviewButton.click();
await expect(reviewForm).toBeVisible();
await expect(reviewForm.locator('[data-ui-id="ui.plugins.capabilityReview.checkbox"]').first()).not.toBeChecked();
await expect(reviewForm.locator('input[name="capabilitiesReviewed"]')).toHaveValue("no");
await expect(reviewForm.locator('[data-ui-id="ui.plugins.capabilityReview.button"]').first()).toBeDisabled();
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
});
test("stale capability review error stays announced and keeps review controls usable", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings stale capability review path");
const pluginRow = await readinessPluginRow(page);
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 settings fixture has no missing capabilities to review");
}
await reviewButton.click();
await expect(reviewButton).toHaveAttribute("aria-expanded", "true");
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 approveButton = await firstExistingLocator(
reviewForm.locator('[data-ui-id="ui.plugins.capabilityReview.button"]').first(),
page.getByRole("button", { name: /approve|freigeben/i }).last()
);
await expect(approveButton).toBeDisabled();
// The readiness plugin has more than one missing capability (e.g. audit +
// a hook subscription), and approval requires EVERY missing capability
// checkbox to be confirmed (mirrors the consent test above). Check them all.
const confirmations = reviewForm.locator('[data-ui-id="ui.plugins.capabilityReview.checkbox"]:not([disabled])');
const confirmationCount = await confirmations.count();
expect(confirmationCount).toBeGreaterThan(0);
for (let index = 0; index < confirmationCount; index += 1) {
await confirmations.nth(index).check();
}
await expect(reviewForm.locator('input[name="capabilitiesReviewed"]')).toHaveValue("yes");
await expect(approveButton).toBeEnabled();
const expectedPathname = new URL(page.url()).pathname;
// The approve <Form> submits via RR7 single-fetch to `<path>.data`, and the
// stale alert renders from `useActionData().message` — so the response must
// decode as turbo-stream. A faked `application/json` body breaks RR7's
// decoder and the alert never renders; return the real single-fetch envelope
// for `Response.json({ ok:false, message }, { status:409 })`.
const staleReviewMessage = "Capability review is stale. Reload the plugin manifest and try again.";
await page.route("**/*", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (request.method() === "POST" && (pathname === expectedPathname || pathname === `${expectedPathname}.data`)) {
await route.fulfill(await singleFetchActionData({ ok: false, message: staleReviewMessage }, 409));
return;
}
await route.continue();
});
await approveButton.click();
const alert = page.getByRole("alert").filter({ hasText: /capability review is stale|berechtigungsprüfung.*veraltet/i });
await expect(alert).toBeVisible();
await expect(reviewButton).toHaveAttribute("aria-expanded", "true");
await expect(reviewForm).toBeVisible();
await expect(approveButton).toBeEnabled();
await reviewButton.focus();
await expect(reviewButton).toBeFocused();
await page.keyboard.press("Enter");
await expect(reviewButton).toHaveAttribute("aria-expanded", "false");
await page.keyboard.press("Enter");
await expect(reviewButton).toHaveAttribute("aria-expanded", "true");
await expect(reviewForm).toBeVisible();
});
test("capability grant and revoke persist for the seeded readiness plugin", async ({ page }) => {
skipUnlessPluginLifecycleMutationAllowed("capability grant/revoke persistence flow");
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings capability roundtrip path");
const initialState = await snapshotPluginCapabilityState(page, readinessPluginId, "audit");
try {
let pluginRow = await readinessPluginRow(page);
await revokeCapabilityIfGranted(pluginRow, "audit");
await page.reload({ waitUntil: "domcontentloaded" });
pluginRow = await readinessPluginRow(page);
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 missing audit capability to grant");
}
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();
await expect(reviewForm.locator('input[name="capabilities[]"][value="audit"]')).toHaveCount(1);
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).toBeDisabled();
// A plugin missing more than one capability renders multiple review
// checkboxes; check each (a bare .check() on the multi-match locator throws
// a strict-mode error). Approve enables once every box is reviewed.
const reviewCheckboxes = reviewForm.locator('[data-ui-id="ui.plugins.capabilityReview.checkbox"]:not([disabled])');
const reviewCheckboxCount = await reviewCheckboxes.count();
for (let index = 0; index < reviewCheckboxCount; index += 1) {
await reviewCheckboxes.nth(index).check();
}
await expect(approveButton).toBeEnabled();
await approveButton.click();
// The review form is a single-fetch POST; RR7 runs the grant action and
// revalidates the page in place. Wait for that revalidation to surface the
// audit revoke form BEFORE issuing the explicit reload — a bare
// page.reload() fired immediately after the click races the in-flight grant
// fetch, so the reloaded loader can read stale (pre-grant) state and the
// revoke form never appears.
pluginRow = await readinessPluginRow(page);
await expect(capabilityRevokeForm(pluginRow, "audit")).toBeVisible();
await page.reload({ waitUntil: "domcontentloaded" });
pluginRow = await readinessPluginRow(page);
const auditRevokeForm = capabilityRevokeForm(pluginRow, "audit");
await expect(auditRevokeForm).toBeVisible();
await auditRevokeForm.locator('[data-ui-id="ui.plugins.capability.revoke.button"]').click();
// Same race on revoke: let the revalidation drop the revoke form before the
// explicit reload re-asserts persistence.
await expect(capabilityRevokeForm(await readinessPluginRow(page), "audit")).toHaveCount(0);
await page.reload({ waitUntil: "domcontentloaded" });
pluginRow = await readinessPluginRow(page);
await expect(capabilityRevokeForm(pluginRow, "audit")).toHaveCount(0);
const restoredReviewButton = await firstExistingLocator(
pluginRow.locator('[data-ui-id="ui.plugins.capabilityReview.toggle"]').first(),
pluginRow.getByRole("button", { name: /review capabilities|berechtigungen prüfen/i }).first()
);
await expect(restoredReviewButton).toBeVisible();
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
} finally {
await restorePluginCapabilityState(page, initialState);
}
});
test("manifest disclosure stays keyboard reachable on mobile", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings manifest path");
const pluginRow = await readinessPluginRow(page);
const manifestButton = await firstExistingLocator(
pluginRow.locator('[data-ui-id="ui.plugins.manifest.toggle"]').first(),
pluginRow.getByRole("button", { name: /view manifest|hide manifest|manifest|manifest anzeigen|manifest ausblenden/i }).first(),
);
if ((await manifestButton.count()) === 0 || !(await manifestButton.isVisible())) {
skipOrFailWhenE2eFixtureMissing("Plugin settings fixture has no manifest disclosure");
}
await manifestButton.focus();
await expect(manifestButton).toBeFocused();
const manifestPanelId = await manifestButton.getAttribute("aria-controls");
expect(manifestPanelId).toMatch(/.+/u);
await expect(manifestButton).toHaveAttribute("aria-expanded", "false");
await page.keyboard.press("Enter");
await expect(manifestButton).toHaveAttribute("aria-expanded", "true");
const manifestRow = manifestButton.locator("xpath=ancestor::li[1]");
const manifestPanel = await firstExistingLocator(manifestRow.locator('[data-ui-id="ui.plugins.manifest.panel"]').first(), pluginRow.locator(`[id="${manifestPanelId}"]`).first());
await expect(manifestPanel).toBeVisible();
await expect(manifestPanel).toContainText(readinessPluginId);
await expect(manifestPanel).toContainText(/"capabilities"/u);
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
});
test("lifecycle controls submit through a non-mutating interception contract", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings lifecycle path");
const 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 fixture has no manageable lifecycle controls");
}
const lifecycleButton = lifecycleForm.locator('[data-ui-id="ui.plugins.lifecycle.button"]').first();
const form = lifecycleForm;
await expect(form).toHaveCount(1);
await expect(form.locator('input[name="id"]')).toHaveValue(/.+/u);
await expect(form.locator('input[name="op"]')).toHaveValue(/^(enable|disable)$/u);
const csrf = form.locator('input[name="csrf"]');
if ((await csrf.count()) > 0) {
await expect(csrf).toHaveValue(/.+/u);
}
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
const submittedPost = await capturePluginPost(page);
await lifecycleButton.click();
await expect.poll(submittedPost, { message: "lifecycle POST payload was captured" }).not.toBeNull();
const postData = submittedPost()?.postData ?? null;
expect(postDataIncludesField(postData, "id", /.+/u)).toBe(true);
expect(postDataIncludesField(postData, "op", /^(enable|disable)$/u)).toBe(true);
if ((await csrf.count()) > 0) {
expect(postDataIncludesField(postData, "csrf", /.+/u)).toBe(true);
}
});
test("lifecycle errors stay announced and keep controls retryable", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings lifecycle error path");
const 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 fixture has no manageable lifecycle controls");
}
const lifecycleButton = lifecycleForm.locator('[data-ui-id="ui.plugins.lifecycle.button"]').first();
const initialOperation = await lifecycleForm.locator('input[name="op"]').inputValue();
expect(initialOperation).toMatch(/^(enable|disable)$/u);
const expectedPathname = new URL(page.url()).pathname;
// The lifecycle <Form> submits via RR7 single-fetch to `<path>.data`, and the
// error alert renders from `useActionData().message` — so the response must
// decode as turbo-stream. A faked `application/json` body breaks RR7's
// decoder and the alert never renders; return the real single-fetch envelope
// for `Response.json({ ok:false, message }, { status:400 })`.
const lifecycleErrorMessage = "Plugin lifecycle temporarily unavailable.";
let attempts = 0;
await page.route("**/*", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (request.method() === "POST" && (pathname === expectedPathname || pathname === `${expectedPathname}.data`)) {
attempts += 1;
await route.fulfill(await singleFetchActionData({ ok: false, message: lifecycleErrorMessage }, 400));
return;
}
await route.continue();
});
await lifecycleButton.focus();
await expect(lifecycleButton).toBeFocused();
await page.keyboard.press("Enter");
const alert = page.getByRole("alert").filter({ hasText: /plugin lifecycle temporarily unavailable/i });
await expect(alert).toBeVisible();
await expect.poll(() => attempts, { message: "lifecycle error POST was intercepted" }).toBe(1);
await expect(lifecycleForm.locator('input[name="op"]')).toHaveValue(initialOperation);
await expect(lifecycleButton).toBeEnabled();
await lifecycleButton.focus();
await expect(lifecycleButton).toBeFocused();
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
});
test("lifecycle controls persist enable and disable state for the seeded lifecycle plugin", async ({ page }) => {
skipUnlessPluginLifecycleMutationAllowed("enable/disable persistence flow");
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings persisted lifecycle path");
const pluginRow = await pluginRowById(page, lifecyclePluginId);
const lifecycleForm = pluginRow.locator('[data-ui-id="ui.plugins.lifecycle.form"]').first();
if ((await lifecycleForm.count()) === 0 || !(await lifecycleForm.isVisible())) {
skipOrFailWhenE2eFixtureMissing("Lifecycle plugin fixture has no manageable lifecycle controls");
}
const initialState = await snapshotPluginLifecycleState(page, lifecyclePluginId);
try {
await expect(lifecycleForm.locator('input[name="op"]')).toHaveValue("disable");
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
await lifecycleForm.locator('[data-ui-id="ui.plugins.lifecycle.button"]').click();
await expect(pluginRow.locator('input[name="op"]').first()).toHaveValue("enable");
await page.reload({ waitUntil: "domcontentloaded" });
const disabledPluginRow = await pluginRowById(page, lifecyclePluginId);
await expect(disabledPluginRow.locator('input[name="op"]').first()).toHaveValue("enable");
const enableForm = disabledPluginRow.locator('[data-ui-id="ui.plugins.lifecycle.form"]').first();
await enableForm.locator('[data-ui-id="ui.plugins.lifecycle.button"]').click();
await expect(disabledPluginRow.locator('input[name="op"]').first()).toHaveValue("disable");
await page.reload({ waitUntil: "domcontentloaded" });
const enabledPluginRow = await pluginRowById(page, lifecyclePluginId);
await expect(enabledPluginRow.locator('input[name="op"]').first()).toHaveValue("disable");
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
} finally {
await restorePluginLifecycleState(page, initialState);
}
});
test("settings iframe exposes sandbox and CSP contract when available", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings iframe path");
const pluginRow = await readinessPluginRow(page);
const settingsLink = pluginRow.locator('[data-ui-id="ui.plugins.settings.open"]').first();
if ((await settingsLink.count()) > 0 && (await settingsLink.isVisible())) {
await settingsLink.click();
await expect(page).toHaveURL(/\/settings\/plugins\/[^/?#]+(?:[?#].*)?$/u);
}
await expect(page.locator('[data-ui-id="ui.plugins.settings.page"]').first()).toBeVisible();
const frame = page.locator('[data-ui-id="ui.plugins.settings.frame"]').first();
if ((await frame.count()) === 0 || !(await frame.isVisible())) {
skipOrFailWhenE2eFixtureMissing("Plugin fixture has no embedded settings frame");
}
const frameTitle = await frame.getAttribute("title");
expect(frameTitle?.trim()).toBeTruthy();
await expect(frame).toHaveAttribute("sandbox", /allow-scripts/u);
await expect(frame).not.toHaveAttribute("sandbox", /allow-forms/u);
await expect(frame).not.toHaveAttribute("sandbox", /allow-same-origin/u);
await expect(frame).not.toHaveAttribute("src", /.+/u);
await expect(frame).toHaveAttribute("srcdoc", /Content-Security-Policy/u);
await expect(frame).toHaveAttribute("srcdoc", /navigate-to 'none'/u);
await expect(frame).toHaveAttribute("srcdoc", /plugin-settings-protocol/u);
await expect(frame).toHaveAttribute("data-plugin-id", readinessPluginId);
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
const submittedPost = await capturePluginPost(page);
const embeddedFrame = await frame.elementHandle();
const contentFrame = await embeddedFrame?.contentFrame();
if (!contentFrame) {
skipOrFailWhenE2eFixtureMissing("Plugin settings frame content is not available in this environment");
}
const channelId = await getPluginSettingsFrameChannelId(frame);
const cspProbeRequests: string[] = [];
await page.route("**/__plugin-settings-csp-probe**", async (route) => {
cspProbeRequests.push(route.request().url());
await route.fulfill({ status: 204, body: "" });
});
await contentFrame.evaluate(() => {
void fetch("/__plugin-settings-csp-probe?kind=fetch").catch(() => undefined);
const image = new Image();
image.src = "/__plugin-settings-csp-probe?kind=image";
navigator.sendBeacon?.("/__plugin-settings-csp-probe?kind=beacon", "probe");
});
await expect
.poll(() => cspProbeRequests.length, {
message: "plugin settings iframe CSP blocks network egress",
timeout: 500,
})
.toBe(0);
await contentFrame.evaluate(() => {
window.parent.postMessage(
{
type: "plugin.settings.deleteAll",
version: 1,
requestId: "e2e-invalid-settings-write",
},
"*",
);
});
// An invalid iframe payload must not perform a settings write. The parent
// legitimately rejects it and emits a non-mutating reportFrameViolation audit
// POST — that is the security feature working, not a contract breach. Only a
// real settings-write POST counts as a violation.
await expect
.poll(
() => {
const post = submittedPost();
if (!post || /reportFrameViolation/u.test(post.postData ?? "")) {
return null;
}
return post;
},
{ message: "invalid settings iframe messages do not perform a settings write", timeout: 500 }
)
.toBeNull();
await contentFrame.evaluate(
({ pluginId, frameChannelId }) => {
window.parent.postMessage(
{
type: "plugin.settings.set",
version: 1,
pluginId,
channelId: frameChannelId,
requestId: "e2e-settings-write",
key: "e2eSetting",
value: "enabled",
},
"*",
);
},
{ pluginId: readinessPluginId, frameChannelId: channelId },
);
await expect.poll(submittedPost, { message: "settings iframe POST payload was captured" }).not.toBeNull();
const captured = submittedPost();
expect(jsonPostDataIncludesField(captured?.postData ?? null, "op", /^setSetting$/u)).toBe(true);
expect(jsonPostDataIncludesField(captured?.postData ?? null, "key", /^e2eSetting$/u)).toBe(true);
expect(jsonPostDataIncludesField(captured?.postData ?? null, "value", /^enabled$/u)).toBe(true);
const csrfHeader = captured?.headers["x-csrf-token"];
if (csrfHeader !== undefined) {
expect(csrfHeader).toMatch(/.+/u);
}
});
test("settings iframe persists values across reload when write slice is enabled", async ({ page }) => {
skipOrFailWhenE2eOptInMissing("E2E_ALLOW_PLUGIN_SETTINGS_WRITE", "skipping mutating plugin settings persistence flow");
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings persistence path");
const requestSuffix = Date.now().toString(36);
const settingKey = "e2ePersistedSetting";
const settingValue = `enabled-${requestSuffix}`;
const initialFrame = await openReadinessPluginSettingsFrame(page, "Plugin settings persistence frame");
// Snapshot the prior persisted value through the plugin settings protocol
// itself (the API under test), not a direct DB connection: the test runner
// is not guaranteed to share a database with the target deployment (e.g. a
// remote readiness target), so a DB-based snapshot/restore is unreachable
// there. The iframe `get` reads the same persisted config the `set` writes.
const initialConfig = await snapshotReadinessPluginSettingViaFrame(initialFrame, settingKey, requestSuffix);
try {
const setResult = await postPluginSettingsFrameMessage(initialFrame.contentFrame, {
type: "plugin.settings.set",
version: 1,
pluginId: readinessPluginId,
channelId: initialFrame.channelId,
requestId: `set-${requestSuffix}`,
key: settingKey,
value: settingValue,
});
expect(setResult).toMatchObject({
type: "plugin.settings.result",
version: 1,
channelId: initialFrame.channelId,
requestId: `set-${requestSuffix}`,
ok: true,
key: settingKey,
value: settingValue,
});
const settingsDetailPath = new URL(page.url()).pathname;
const reloadResponse = await page.reload({ waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, reloadResponse, "Plugin settings persistence reload path");
// Confirm the write survived the reload by reading it back from the settings
// route's single-fetch payload — the exact `settings` the embedded frame is
// hydrated with and serves over its `get` channel. (A direct post-reload
// iframe `get` is non-deterministic: the host frame quarantines its window
// and rotates the channel on a late `load`, silently dropping messages. The
// loader read-back is the same source of truth without that fragility.)
await expect
.poll(
async () =>
page.evaluate(async (path) => {
const response = await fetch(`${path}.data`, { headers: { accept: "text/x-script" } });
return response.ok ? await response.text() : "";
}, settingsDetailPath),
{ message: "settings write persisted across reload (visible in the settings loader payload)", timeout: 15_000, intervals: [500, 1_000] }
)
.toContain(`${settingKey}","${settingValue}`);
} finally {
await restoreReadinessPluginSettingViaFrame(page, settingKey, initialConfig, requestSuffix);
}
});
test("capability revoke controls expose a non-mutating form contract", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings revoke path");
const pluginRow = await readinessPluginRow(page);
const form = pluginRow.locator('[data-ui-id="ui.plugins.capability.revoke.form"]').first();
if ((await form.count()) === 0 || !(await form.isVisible())) {
skipOrFailWhenE2eFixtureMissing("Plugin fixture has no granted capability revoke controls");
}
await expect(form.locator('input[name="id"]')).toHaveValue(/.+/u);
await expect(form.locator('input[name="pluginId"]')).toHaveValue(readinessPluginId);
await expect(form.locator('input[name="op"]')).toHaveValue("revoke");
await expect(form.locator('input[name="capabilityNames[]"]')).toHaveValue(/.+/u);
await expect(form.locator('[data-ui-id="ui.plugins.capability.revoke.button"]')).toBeVisible();
const csrf = form.locator('input[name="csrf"]');
if ((await csrf.count()) > 0) {
await expect(csrf).toHaveValue(/.+/u);
}
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
const submittedPost = await capturePluginPost(page);
await form.locator('[data-ui-id="ui.plugins.capability.revoke.button"]').click();
await expect.poll(submittedPost, { message: "capability revoke POST payload was captured" }).not.toBeNull();
const postData = submittedPost()?.postData ?? null;
expect(postDataIncludesField(postData, "id", /.+/u)).toBe(true);
expect(postDataIncludesField(postData, "pluginId", new RegExp(`^${escapeRegExp(readinessPluginId)}$`, "u"))).toBe(true);
expect(postDataIncludesField(postData, "op", /^revoke$/u)).toBe(true);
expect(postDataIncludesField(postData, "capabilityNames[]", /.+/u)).toBe(true);
if ((await csrf.count()) > 0) {
expect(postDataIncludesField(postData, "csrf", /.+/u)).toBe(true);
}
});
});
test.describe("plugin settings read-only permissions", () => {
test.use({ viewport: narrowMobileViewport, isMobile: true, hasTouch: true });
test.beforeEach(() => {
skipOrFailWhenE2eInputsMissing(
["E2E_PLUGIN_READONLY_EMAIL", "E2E_PLUGIN_READONLY_PASSWORD", "E2E_PLUGIN_SETTINGS_PATH"],
"Plugin settings read-only permission inputs not configured"
);
});
test("app.plugins.use users can inspect plugins without seeing mutation controls", async ({ page }) => {
await loginAsTenantUser(page, readonlyCredentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings read-only permission path");
await expect(page.locator('[data-ui-id="ui.plugins.page"]').first()).toBeVisible();
const pluginRow = await readinessPluginRow(page);
await expect(pluginRow).toBeVisible();
await expect(pluginRow.locator('[data-ui-id="ui.plugins.manifest.toggle"]').first()).toBeVisible();
await expect(page.locator('[data-ui-id="ui.plugins.lifecycle.form"]')).toHaveCount(0);
await expect(page.locator('[data-ui-id="ui.plugins.capability.revoke.form"]')).toHaveCount(0);
await expect(page.locator('[data-ui-id="ui.plugins.capabilityReview.toggle"]')).toHaveCount(0);
await expect(page.locator('[data-ui-id="ui.plugins.capabilityReview.form"]')).toHaveCount(0);
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
});
test("read-only users can inspect the manifest without lifecycle mutations appearing", async ({ page }) => {
await loginAsTenantUser(page, readonlyCredentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings read-only manifest path");
await expect(page.locator('[data-ui-id="ui.plugins.page"]').first()).toBeVisible();
const pluginRow = await readinessPluginRow(page);
await expect(pluginRow).toBeVisible();
const manifestButton = await firstExistingLocator(
pluginRow.locator('[data-ui-id="ui.plugins.manifest.toggle"]').first(),
pluginRow.getByRole("button", { name: /view manifest|hide manifest|manifest|manifest anzeigen|manifest ausblenden/i }).first()
);
if ((await manifestButton.count()) === 0 || !(await manifestButton.isVisible())) {
skipOrFailWhenE2eFixtureMissing("Plugin read-only fixture has no manifest disclosure");
}
const submittedPost = await capturePluginPost(page);
await manifestButton.focus();
await expect(manifestButton).toBeFocused();
await expect(manifestButton).toHaveAttribute("aria-expanded", "false");
await page.keyboard.press("Enter");
await expect(manifestButton).toHaveAttribute("aria-expanded", "true");
const manifestPanelId = await manifestButton.getAttribute("aria-controls");
expect(manifestPanelId).toMatch(/.+/u);
const manifestRow = manifestButton.locator("xpath=ancestor::li[1]");
const manifestPanel = await firstExistingLocator(
manifestRow.locator('[data-ui-id="ui.plugins.manifest.panel"]').first(),
pluginRow.locator(`[id="${manifestPanelId}"]`).first()
);
await expect(manifestPanel).toBeVisible();
await expect(manifestPanel).toContainText(readinessPluginId);
await expect(page.locator('[data-ui-id="ui.plugins.lifecycle.form"]')).toHaveCount(0);
await expect(page.locator('[data-ui-id="ui.plugins.capability.revoke.form"]')).toHaveCount(0);
await expect(page.locator('[data-ui-id="ui.plugins.capabilityReview.toggle"]')).toHaveCount(0);
await expect(page.locator('[data-ui-id="ui.plugins.capabilityReview.form"]')).toHaveCount(0);
await expect.poll(submittedPost, { message: "manifest disclosure stays non-mutating", timeout: 500 }).toBeNull();
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
});
test("app.plugins.use users cannot submit forged lifecycle mutations without manage permission", async ({ page }) => {
await loginAsTenantUser(page, readonlyCredentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings read-only forged lifecycle path");
await expect(page.locator('[data-ui-id="ui.plugins.page"]').first()).toBeVisible();
await expect(await readinessPluginRow(page)).toBeVisible();
await expect(page.locator('[data-ui-id="ui.plugins.lifecycle.form"]')).toHaveCount(0);
await expect(page.locator('[data-ui-id="ui.plugins.capabilityReview.form"]')).toHaveCount(0);
const expectedRedirectPath = new URL(page.url()).pathname;
await Promise.all([
page.waitForURL(/\/unauthorized\/app\.plugins\.manage\/[^/?#]+(?:\?[^#]*)?$/u),
page.evaluate(() => {
const form = document.createElement("form");
form.method = "POST";
form.action = window.location.href;
for (const [name, value] of [
["op", "enable"],
["id", "e2e-forged-plugin-row"],
] as const) {
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = value;
form.append(input);
}
document.body.append(form);
form.requestSubmit();
}),
]);
const deniedUrl = new URL(page.url());
expect(deniedUrl.pathname).toMatch(/\/unauthorized\/app\.plugins\.manage\/[^/]+$/u);
expect(deniedUrl.searchParams.get("redirect")).toBe(expectedRedirectPath);
});
});
test.describe("plugin settings 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 settings compact mobile a11y inputs not configured"
);
});
test("keeps the plugin row and manifest disclosure usable at 320px", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const response = await page.goto(pluginSettingsPath!, { waitUntil: "domcontentloaded" });
await skipIfUnavailable(page, response, "Plugin settings compact manifest path");
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(pluginSettingsPath!)}(?:[?#].*)?$`, "u"));
const pluginRow = await readinessPluginRow(page);
await expect(pluginRow).toBeVisible();
const manifestButton = await firstExistingLocator(
pluginRow.locator('[data-ui-id="ui.plugins.manifest.toggle"]').first(),
pluginRow.getByRole("button", { name: /view manifest|hide manifest|manifest|manifest anzeigen|manifest ausblenden/i }).first()
);
if ((await manifestButton.count()) === 0 || !(await manifestButton.isVisible())) {
skipOrFailWhenE2eFixtureMissing("Plugin settings fixture has no manifest disclosure");
}
await manifestButton.focus();
await expect(manifestButton).toBeFocused();
await page.keyboard.press("Enter");
await expect(manifestButton).toHaveAttribute("aria-expanded", "true");
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
});
});
async function readinessPluginRow(page: Page): Promise<Locator> {
return pluginRowById(page, readinessPluginId);
}
type PluginSettingsFrameContext = {
channelId: string;
contentFrame: Frame;
};
type PluginSettingsFrameResult = {
type: "plugin.settings.result";
version: number;
channelId: string;
requestId: string;
ok: boolean;
key?: string;
value?: unknown;
error?: string;
};
type PluginLifecycleState = {
enabled: boolean;
pluginId: string;
};
type PluginCapabilityState = PluginLifecycleState & {
capabilityName: string;
granted: boolean;
};
type PluginUserConfigSnapshot = {
value: unknown;
existed: boolean;
};
async function openReadinessPluginSettingsFrame(page: Page, reason: string): Promise<PluginSettingsFrameContext> {
const settingsPage = page.locator('[data-ui-id="ui.plugins.settings.page"]').first();
if ((await settingsPage.count()) === 0 || !(await settingsPage.isVisible())) {
const pluginRow = await readinessPluginRow(page);
const settingsLink = pluginRow.locator('[data-ui-id="ui.plugins.settings.open"]').first();
if ((await settingsLink.count()) === 0 || !(await settingsLink.isVisible())) {
skipOrFailWhenE2eFixtureMissing(`${reason}: plugin fixture has no settings link`);
}
await settingsLink.click();
await expect(page).toHaveURL(/\/settings\/plugins\/[^/?#]+(?:[?#].*)?$/u);
}
await expect(page.locator('[data-ui-id="ui.plugins.settings.page"]').first()).toBeVisible();
const frame = page.locator('[data-ui-id="ui.plugins.settings.frame"]').first();
if ((await frame.count()) === 0 || !(await frame.isVisible())) {
skipOrFailWhenE2eFixtureMissing(`${reason}: plugin fixture has no embedded settings frame`);
}
await expect(frame).toHaveAttribute("data-plugin-id", readinessPluginId);
const embeddedFrame = await frame.elementHandle();
const contentFrame = await embeddedFrame?.contentFrame();
if (!contentFrame) {
skipOrFailWhenE2eFixtureMissing(`${reason}: plugin settings frame content is not available in this environment`);
}
return {
channelId: await getPluginSettingsFrameChannelId(frame),
contentFrame,
};
}
async function getPluginSettingsFrameChannelId(frame: Locator): Promise<string> {
const srcDoc = await frame.getAttribute("srcdoc");
const channelId = srcDoc?.match(/name="plugin-settings-channel" content="([^"]+)"/u)?.[1];
if (!channelId) {
skipOrFailWhenE2eFixtureMissing("Plugin settings frame channel is not available");
}
return channelId;
}
async function postPluginSettingsFrameMessage(
contentFrame: Frame,
message: Record<string, unknown>
): Promise<PluginSettingsFrameResult> {
return (await contentFrame.evaluate((frameMessage) => {
return new Promise((resolve, reject) => {
const requestId = typeof frameMessage.requestId === "string" ? frameMessage.requestId : null;
const timeout = window.setTimeout(() => {
cleanup();
reject(new Error(`Timed out waiting for plugin settings frame result ${requestId ?? ""}`.trim()));
}, 5_000);
const cleanup = () => {
window.clearTimeout(timeout);
window.removeEventListener("message", handleMessage);
};
const handleMessage = (event: MessageEvent) => {
const data = event.data as { type?: unknown; requestId?: unknown };
if (data?.type !== "plugin.settings.result" || data.requestId !== requestId) return;
cleanup();
resolve(event.data);
};
window.addEventListener("message", handleMessage);
window.parent.postMessage(frameMessage, "*");
});
}, message)) as PluginSettingsFrameResult;
}
async function pluginRowById(page: Page, pluginId: string): Promise<Locator> {
const row = page.locator(`[data-ui-id="ui.plugins.item"][data-plugin-id="${pluginId}"]`).first();
if ((await row.count()) === 0 || !(await row.isVisible())) {
skipOrFailWhenE2eFixtureMissing(`Plugin settings fixture has no ${pluginId} seed plugin row`);
}
return row;
}
async function snapshotPluginLifecycleState(page: Page, pluginId: string): Promise<PluginLifecycleState> {
const row = await pluginRowById(page, pluginId);
return {
enabled: (await getPluginLifecycleOperation(row)) === "disable",
pluginId,
};
}
async function restorePluginLifecycleState(page: Page, state: PluginLifecycleState): Promise<void> {
if (page.isClosed()) return;
await openPluginSettingsIndex(page);
await ensurePluginEnabledState(page, state.pluginId, state.enabled);
}
async function snapshotPluginCapabilityState(page: Page, pluginId: string, capabilityName: string): Promise<PluginCapabilityState> {
const lifecycleState = await snapshotPluginLifecycleState(page, pluginId);
const row = await pluginRowById(page, pluginId);
return {
...lifecycleState,
capabilityName,
granted: await isCapabilityGranted(row, capabilityName),
};
}
async function restorePluginCapabilityState(page: Page, state: PluginCapabilityState): Promise<void> {
if (page.isClosed()) return;
await openPluginSettingsIndex(page);
// The seed baseline (plugin ENABLED while a required capability is MISSING) is
// unreachable through the UI once the plugin is disabled: revoking a capability
// auto-disables the plugin (PluginLifecycleService.revokePluginCapabilities) and the
// bare enable form is hidden while a capability is missing (review-before-enable).
// So "enabled + missing" cannot be restored — prioritise leaving the plugin ENABLED
// and consistent for the serial tests that follow (granting the missing capability
// via the review→grantAndEnable flow if that is the only way to enable). A fresh
// reseed restores the true baseline between runs.
await ensurePluginEnabledState(page, state.pluginId, state.enabled);
// When the baseline is DISABLED the exact capability state IS reachable, so restore it.
if (!state.enabled) {
const row = await pluginRowById(page, state.pluginId);
const granted = await isCapabilityGranted(row, state.capabilityName);
if (granted && !state.granted) {
await revokeCapabilityIfGranted(row, state.capabilityName);
await page.reload({ waitUntil: "domcontentloaded" });
} else if (!granted && state.granted) {
await grantMissingCapabilities(page, row);
await page.reload({ waitUntil: "domcontentloaded" });
}
}
}
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" });
}
async function ensurePluginEnabledState(page: Page, pluginId: string, enabled: boolean): Promise<void> {
const row = await pluginRowById(page, pluginId);
const operation = await getPluginLifecycleOperation(row);
const currentEnabled = operation === "disable";
if (currentEnabled === enabled) return;
if (operation === null) {
// A disabled plugin with missing capabilities hides the bare enable form
// (review-before-enable gate). Revoking a required capability auto-disables the
// plugin (PluginLifecycleService.revokePluginCapabilities), so a grant→revoke
// test lands here; re-enable through the review→grantAndEnable flow, which
// grants the missing capabilities and enables in one submit.
if (enabled) {
await grantMissingCapabilities(page, row);
await page.reload({ waitUntil: "domcontentloaded" });
const reEnabledOp = await getPluginLifecycleOperation(await pluginRowById(page, pluginId));
if (reEnabledOp !== "disable") {
throw new Error(`Unable to re-enable ${pluginId}: still disabled after granting missing capabilities`);
}
return;
}
throw new Error(`Unable to restore ${pluginId} lifecycle state because lifecycle controls are unavailable`);
}
const form = row.locator('[data-ui-id="ui.plugins.lifecycle.form"]').first();
await form.locator('[data-ui-id="ui.plugins.lifecycle.button"]').click();
await expect(row.locator('input[name="op"]').first()).toHaveValue(enabled ? "disable" : "enable");
await page.reload({ waitUntil: "domcontentloaded" });
await expect((await pluginRowById(page, pluginId)).locator('input[name="op"]').first()).toHaveValue(enabled ? "disable" : "enable");
}
async function getPluginLifecycleOperation(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;
}
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 revokeCapabilityIfGranted(pluginRow: Locator, capabilityName: string): Promise<void> {
const form = capabilityRevokeForm(pluginRow, capabilityName);
if ((await form.count()) === 0 || !(await form.isVisible())) {
return;
}
await form.locator('[data-ui-id="ui.plugins.capability.revoke.button"]').click();
}
async function isCapabilityGranted(pluginRow: Locator, capabilityName: string): Promise<boolean> {
const form = capabilityRevokeForm(pluginRow, capabilityName);
return (await form.count()) > 0 && (await form.isVisible());
}
async function grantMissingCapabilities(page: Page, pluginRow: Locator): Promise<void> {
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())) {
throw new Error("Unable to restore plugin capability grants because review controls are unavailable");
}
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 checkboxes = reviewForm.locator('[data-ui-id="ui.plugins.capabilityReview.checkbox"]:not([disabled])');
const checkboxCount = await checkboxes.count();
if (checkboxCount === 0) {
throw new Error("Unable to restore plugin capability grants because no review checkbox is available");
}
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();
// Wait for RR7 to finish the grant/grantAndEnable action and revalidate the
// page before the caller issues an explicit reload: granting every missing
// capability clears the review block, so its disappearance marks the action as
// applied. Returning early lets page.reload() race the in-flight single-fetch
// POST and read stale (still-disabled / still-missing) state.
await expect(reviewForm).toHaveCount(0);
}
async function snapshotReadinessPluginSettingViaFrame(
frame: PluginSettingsFrameContext,
key: string,
requestSuffix: string
): Promise<PluginUserConfigSnapshot> {
const result = await postPluginSettingsFrameMessage(frame.contentFrame, {
type: "plugin.settings.get",
version: 1,
pluginId: readinessPluginId,
channelId: frame.channelId,
requestId: `snapshot-${requestSuffix}`,
key,
});
const existed = result.ok && result.value !== undefined && result.value !== null;
return { value: existed ? result.value : null, existed };
}
async function restoreReadinessPluginSettingViaFrame(
page: Page,
key: string,
snapshot: PluginUserConfigSnapshot,
requestSuffix: string
): Promise<void> {
if (page.isClosed()) return;
// Restore through the same protocol the test mutated. There is no delete verb
// in the settings channel, so an originally-absent key is reset to an empty
// string — harmless for idempotency (a reseed clears the config and each run
// writes a fresh unique value anyway), and it keeps the cleanup runnable
// wherever the iframe is reachable (no shared-database requirement).
//
// Best-effort: the persistence contract is already asserted in the test body.
// A write re-renders the settings frame (new channel), so a restore that races
// that remount can time out without the assertion ever being at risk — and a
// reseed re-establishes the true baseline regardless. Never fail the test on
// cleanup.
try {
const frame = await openReadinessPluginSettingsFrame(page, "Plugin settings restore frame");
await postPluginSettingsFrameMessage(frame.contentFrame, {
type: "plugin.settings.set",
version: 1,
pluginId: readinessPluginId,
channelId: frame.channelId,
requestId: `restore-${requestSuffix}`,
key,
value: snapshot.existed ? snapshot.value : "",
});
} catch {
// Cleanup is hygiene only; the reseed before each run restores the baseline.
}
}
async function firstExistingLocator(primary: Locator, fallback: Locator): Promise<Locator> {
return (await primary.count()) > 0 ? primary : fallback;
}
type CapturedPluginPost = {
headers: Record<string, string>;
postData: string | null;
url: string;
};
async function capturePluginPost(page: Page): Promise<() => CapturedPluginPost | null> {
const expectedPathname = new URL(page.url()).pathname;
// React Router v7 single-fetch <Form> actions POST to `<path>.data`; the
// settings iframe instead does a raw `fetch(".")`, which resolves to the
// current document's *parent directory* (the `:id` segment is dropped). A
// bare-pathname match misses both — accept the bare path, its `.data`
// single-fetch endpoint, and the parent directory (with and without `.data`).
const directoryPathname = expectedPathname.replace(/[^/]+$/u, "");
const matchedPathnames = new Set([expectedPathname, `${expectedPathname}.data`, directoryPathname, `${directoryPathname}.data`]);
let submittedPost: CapturedPluginPost | null = null;
await page.route("**/*", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (request.method() === "POST" && matchedPathnames.has(pathname)) {
submittedPost = {
headers: request.headers(),
postData: request.postData(),
url: request.url(),
};
// `.data` single-fetch endpoints must decode as turbo-stream — fulfilling
// them with `application/json` makes RR7's decoder throw (error boundary).
// The raw-fetch iframe path (`fetch(".")`) reads `response.json()`, so it
// still needs `application/json`.
if (pathname.endsWith(".data")) {
await route.fulfill(await singleFetchActionData({ ok: true }));
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
ok: true,
key: "e2eSetting",
value: "enabled",
settings: { e2eSetting: "enabled" },
}),
});
return;
}
await route.continue();
});
return () => submittedPost;
}
function jsonPostDataIncludesField(postData: string | null, name: string, valuePattern: RegExp): boolean {
if (!postData) return false;
try {
const payload = JSON.parse(postData) as Record<string, unknown>;
const value = payload[name];
if (typeof value === "string") return valuePattern.test(value);
if (value === null || value === undefined) return false;
return valuePattern.test(JSON.stringify(value));
} catch {
return false;
}
}