Files
computer-playbook/roles/web-app-decidim/files/playwright/playwright.spec.js
kevinveenbirkenbachandClaude Opus 5 e0eda30fbe style(e2e): drop four more timeout arguments isVisible discards
Same class as 642055a20, missed by the filter that selected it: files were grouped by whether they CONTAIN a waitFor state call rather than by whether the diff ADDS one, so four whose change is a pure deletion were sorted into the waiting-semantics bundle instead. Their diffs alter nothing but the discarded argument, and every added line here is a bare isVisible().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:59:15 +02:00

210 lines
10 KiB
JavaScript

// @ts-check
const { test, expect } = require('@playwright/test');
const { resolveTimeout } = require("./timeouts");
const { decodeDotenvQuotedValue, performKeycloakLoginForm, runAdminFlow, runBiberFlow, runGuestFlow, gotoOnion } = require("./personas");
const { skipUnlessServiceEnabled } = require("./service-gating");
test.use({
ignoreHTTPSErrors: true
});
const baseUrl = decodeDotenvQuotedValue(process.env.DECIDIM_BASE_URL || process.env.APP_BASE_URL);
const adminEmail = decodeDotenvQuotedValue(process.env.ADMIN_EMAIL);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
const biberUsername = decodeDotenvQuotedValue(process.env.BIBER_USERNAME);
const biberPassword = decodeDotenvQuotedValue(process.env.BIBER_PASSWORD);
const biberEmail = decodeDotenvQuotedValue(process.env.BIBER_EMAIL);
const oidcIssuerUrl = decodeDotenvQuotedValue(process.env.OIDC_ISSUER_URL);
test.beforeEach(() => {
expect(baseUrl, "DECIDIM_BASE_URL must be set in the Playwright env file").toBeTruthy();
expect(adminEmail, "ADMIN_EMAIL must be set in the Playwright env file").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set in the Playwright env file").toBeTruthy();
expect(biberUsername, "BIBER_USERNAME must be set in the Playwright env file").toBeTruthy();
expect(biberPassword, "BIBER_PASSWORD must be set in the Playwright env file").toBeTruthy();
expect(biberEmail, "BIBER_EMAIL must be set in the Playwright env file").toBeTruthy();
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set in the Playwright env file").toBeTruthy();
});
// Helper: local form login (admin only)
async function login(page, email, password) {
await gotoOnion(page, `${baseUrl}/users/sign_in`);
await page.waitForLoadState("networkidle");
const cookieBanner = page.locator("#dc-dialog-wrapper, .cookies__container, [data-cookie-consent]");
if (await cookieBanner.isVisible().catch(() => false)) {
const acceptBtn = page.locator("button[data-dc-accept], button.cookies__accept, button:has-text('Accept')").first();
if (await acceptBtn.isVisible().catch(() => false)) {
await acceptBtn.click();
await page.waitForLoadState("networkidle");
}
}
const emailInput = page.getByLabel(/email/i).first();
await emailInput.waitFor({ state: "attached", timeout: resolveTimeout(60000) });
await emailInput.fill(email);
await page.locator("input[type='password']").first().fill(password);
await page.getByRole('button', { name: /log in|sign in/i }).first().click({ timeout: resolveTimeout(30_000) });
await page.waitForLoadState("networkidle");
}
// Helper: dismiss the cookie consent banner if present. Decidim renders a
// modal overlay that steals pointer events from form buttons — without this
// the "Create an account" submit on the OIDC registration form is not hittable.
async function dismissCookieBanner(page) {
const acceptBtn = page.getByRole("button", { name: /accept all|accept only essential|accept/i }).first();
if (await acceptBtn.isVisible().catch(() => false)) {
await acceptBtn.click().catch(() => {});
await page.waitForLoadState("networkidle", { timeout: resolveTimeout(5000) }).catch(() => {});
}
}
// Helper: OIDC login via Keycloak
async function oidcLogin(page, username, password) {
await gotoOnion(page, `${baseUrl}/users/sign_in`);
await page.waitForLoadState("networkidle");
await dismissCookieBanner(page);
const ssoButton = page.locator("a[href*='openid_connect']").first();
const ssoVisible = await ssoButton.isVisible().catch(() => false);
if (!ssoVisible) {
return null; // OIDC not configured
}
// Submit form manually replicating Rails UJS data-method="post"
const ssoHref = await ssoButton.getAttribute("href");
console.log("SSO href:", ssoHref);
await page.waitForFunction(() => document.readyState === "complete");
const oidcAuthHost = new URL(oidcIssuerUrl).host;
const navigated = page.waitForURL((url) => url.host === oidcAuthHost, { timeout: resolveTimeout(30000) });
await page.evaluate((href) => {
const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || "";
const form = document.createElement("form");
form.setAttribute("method", "post");
form.setAttribute("action", href);
const token = document.createElement("input");
token.setAttribute("type", "hidden");
token.setAttribute("name", "authenticity_token");
token.setAttribute("value", csrfToken);
form.appendChild(token);
document.body.appendChild(form);
form.submit();
}, ssoHref);
await navigated.catch(e => console.log("nav error:", e.message, "url:", page.url()));
console.log("After form submit URL:", page.url());
await performKeycloakLoginForm(page, username, password);
console.log("Login clicked, waiting for redirect back...");
await page.waitForLoadState("networkidle", { timeout: resolveTimeout(30000) }).catch(e => console.log("networkidle error:", e.message));
console.log("After login URL:", page.url());
await dismissCookieBanner(page);
// First-time OIDC users land on Decidim's registration form (TOS + nickname
// confirmation). Returning users skip this and sign in directly — detection
// is by form presence, not URL, so the flow is idempotent across runs.
const tosCheckbox = page.locator("input[type='checkbox'][name='user[tos_agreement]']").first();
const onRegistrationForm = await tosCheckbox.isVisible().catch(() => false);
if (onRegistrationForm) {
console.log("First-time OIDC registration form detected, accepting TOS...");
await tosCheckbox.check({ force: true });
// Decidim's `user-registration-form` Stimulus controller cancels submit and
// opens a newsletter modal when `user[newsletter]` is unchecked. Click the
// decline button on that modal to proceed without subscribing — that path
// sets the continue flag and re-submits the form.
await page.evaluate(() => document.getElementById("omniauth-register-form").requestSubmit());
const declineBtn = page.locator("#sign-up-newsletter-modal [data-check='false']").first();
await declineBtn.waitFor({ state: "visible", timeout: resolveTimeout(10000) });
await Promise.all([
page.waitForURL((url) => !/auth\/openid_connect\/callback/.test(url.toString()), { timeout: resolveTimeout(30000) }).catch(e => console.log("post-submit nav error:", e.message)),
declineBtn.click({ force: true, timeout: resolveTimeout(30_000) }),
]);
console.log("After registration URL:", page.url());
await page.waitForURL((url) => url.host === new URL(baseUrl).host, { timeout: resolveTimeout(30000) }).catch(() => {});
await page.waitForLoadState("load", { timeout: resolveTimeout(30000) }).catch(() => {});
}
}
// Scenario I: Homepage loads
test("homepage loads and shows Decidim", async ({ page }) => {
await gotoOnion(page, baseUrl);
await expect(page).not.toHaveTitle("");
await expect(page.locator("body")).toBeVisible();
});
// Scenario II: Admin local login and logout
test("admin can log in and out", async ({ page }) => {
await login(page, adminEmail, adminPassword);
await expect(page).not.toHaveURL(/sign_in/);
await expect(page.locator("body")).toBeVisible();
await gotoOnion(page, `${baseUrl}/users/sign_out`);
await page.waitForLoadState("networkidle");
await expect(page).not.toHaveURL(/sign_in/);
});
// Scenario III: Biber OIDC login and logout
test("biber can log in via OIDC and log out", async ({ page }) => {
const result = await oidcLogin(page, biberUsername, biberPassword);
if (result === null) { test.skip(); return; }
await expect(page).not.toHaveURL(/sign_in/);
await expect(page.locator("body")).toBeVisible();
await gotoOnion(page, `${baseUrl}/users/sign_out`);
await page.waitForLoadState("networkidle");
await expect(page).not.toHaveURL(/sign_in/);
});
// Scenario IV: Admin and biber can communicate via conversations
test("biber can access profile after OIDC login", async ({ page }) => {
// Biber logs in via OIDC
const result = await oidcLogin(page, biberUsername, biberPassword);
if (result === null) { test.skip(); return; }
await expect(page).not.toHaveURL(/sign_in/);
// Biber accesses their account page
await gotoOnion(page, `${baseUrl}/account`);
await page.waitForLoadState("networkidle");
await page.waitForURL(/\/account/, { timeout: resolveTimeout(30000) }).catch(() => {});
await expect(page).not.toHaveURL(/sign_in/);
await expect(page.locator("h1, h2").first()).toBeVisible();
});
// Scenario V: SSO button visible when OIDC is enabled
test("SSO login button is visible when OIDC is enabled", async ({ page }) => {
skipUnlessServiceEnabled("sso");
await gotoOnion(page, `${baseUrl}/users/sign_in`);
await page.waitForLoadState("networkidle");
const ssoButton = page.locator("a[href*='openid_connect']").first();
await expect(ssoButton).toBeVisible();
});
// Persona scenarios.
// Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js
// so every role's persona flow stays consistent.
test("biber: app → universal logout", async ({ page }) => {
await runBiberFlow(page);
});
test("administrator: app → universal logout", async ({ page }) => {
await runAdminFlow(page, {
adminInteraction: async (interactivePage) => {
// web-app-decidim admin-only interaction: open a management surface.
const link = interactivePage
.getByRole("link", { name: /^(admin|administration|configuration|participants)$/i })
.first();
if (await link.isVisible().catch(() => false)) {
await link.click({ timeout: resolveTimeout(30_000) }).catch(() => {});
await interactivePage.waitForLoadState("domcontentloaded", { timeout: resolveTimeout(30_000) }).catch(() => {});
await expect(interactivePage.locator("body")).toContainText(
/admin|administration|participants|processes|assemblies|moderation/i,
{ timeout: resolveTimeout(30_000) },
);
}
},
});
});
// Persona scenarios.
// Bodies live in the shared persona helpers under
// roles/test-e2e-playwright/files/personas/{guest,biber,admin}.js.
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
await runGuestFlow(page);
});
require("./test-seaweedfs");