1242 lines
58 KiB
TypeScript
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;
|
|
}
|
|
}
|