Files
computer-playbook/roles/web-app-penpot/files/playwright/_shared.js
kevinveenbirkenbachandClaude Opus 5 42449ebf0a fix(e2e): let the checks that needed a wait actually wait
Sixteen role specs decided something immediately after an action that had not settled, because locator.isVisible() discards the timeout it is handed. Three shapes: an assertion that could never fire, a login outcome read off the DOM before the app re-rendered, and a branch that selected a locator by whether an iframe had mounted yet.

The ones that could never fire are the worst of them. taiga test-seaweedfs polled for a rejection toast right after saveAction.click(), so a real upload rejection was swallowed and misreported as a SeaweedFS storage failure; friendica ldapauth decided whether to run its wrong-password test at the instant a fresh context finished goto, so a slow render skipped a security check silently. wordpress test-discourse-roundtrip picked titleBox by whether the Gutenberg iframe had mounted and, when it had not, bound the wrong one and then burned the full budget failing with a misleading message.

Every conversion keeps the control flow: the .catch(() => false) fallback stays, so a waitFor that throws still yields the boolean the caller expects. Twenty of the twenty-two waits use base 2000, one 3000, one 5000. resolveTimeout multiplies by five on an onion target, so the common case is ten seconds there. Across thirteen onion traces the largest wait of any kind that ever succeeded was 790 ms, so ten seconds is a twelvefold margin; the bases started higher and were pulled down because a swallowed deadline is a duration in disguise - where the optional element is normally absent, the full timeout is paid on every run, including green ones.

Not exercised: no spec was run. Three of these make a test stricter, so a job that turns red on them is surfacing a real failure that was previously hidden, not regressing.

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

190 lines
7.6 KiB
JavaScript

// Shared state + login helpers for the Penpot Playwright suite.
// playwright.spec.js wires the lifecycle hook and `require()`s one test-*.js
const { resolveTimeout } = require("./timeouts");
// companion per login surface (native / oidc / ldap), passing this module as
// `shared` so each scenario stays atomar and individually inspectable.
const zlib = require("zlib");
const { test, expect } = require("@playwright/test");
const { skipUnlessServiceEnabled, isServiceEnabled } = require("./service-gating");
const {
decodeDotenvQuotedValue,
normalizeBaseUrl,
performKeycloakLoginForm,
runAdminFlow,
runBiberFlow,
runGuestFlow,
gotoOnion,
} = require("./personas");
const VALID_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAKklEQVR4nGPQqDhBU8QwasGoBaMWjFowasGoBaMWjFowasGoBaMWDBULAIuXoEzkdmPIAAAAAElFTkSuQmCC";
function validImagePng() {
return Buffer.from(VALID_PNG_BASE64, "base64");
}
function _pngCrc32(buf) {
let c = ~0;
for (let i = 0; i < buf.length; i++) {
c ^= buf[i];
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
}
return (~c) >>> 0;
}
function _pngChunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(_pngCrc32(body), 0);
return Buffer.concat([len, body, crc]);
}
function uniqueImagePng(seed) {
const w = 16;
const h = 16;
const r = seed & 0xff;
const g = (seed >>> 8) & 0xff;
const b = (seed >>> 16) & 0xff;
const raw = Buffer.alloc(h * (1 + w * 3));
for (let y = 0; y < h; y++) {
const row = y * (1 + w * 3);
raw[row] = 0;
for (let x = 0; x < w; x++) {
const p = row + 1 + x * 3;
raw[p] = (r + x) & 0xff;
raw[p + 1] = (g + y) & 0xff;
raw[p + 2] = b;
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0);
ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8;
ihdr[9] = 2;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
_pngChunk("IHDR", ihdr),
_pngChunk("IDAT", zlib.deflateSync(raw)),
_pngChunk("IEND", Buffer.alloc(0)),
]);
}
const env = {
baseUrl: normalizeBaseUrl(process.env.PENPOT_BASE_URL || ""),
oidcIssuerUrl: normalizeBaseUrl(process.env.OIDC_ISSUER_URL || ""),
canonicalDomain: decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || ""),
adminUsername: decodeDotenvQuotedValue(process.env.ADMIN_USERNAME),
adminPassword: decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD),
adminEmail: decodeDotenvQuotedValue(process.env.ADMIN_EMAIL),
biberUsername: decodeDotenvQuotedValue(process.env.BIBER_USERNAME),
biberPassword: decodeDotenvQuotedValue(process.env.BIBER_PASSWORD),
biberEmail: decodeDotenvQuotedValue(process.env.BIBER_EMAIL),
};
const loginRoute = (base) => `${base.replace(/\/$/, "")}/#/auth/login`;
async function assertAuthenticated(page) {
await expect
.poll(() => page.url(), { timeout: resolveTimeout(60_000), message: "expected to leave the login route after sign-in" })
.not.toContain("/auth/login");
await expect
.poll(() => page.url(), { timeout: resolveTimeout(60_000), message: "expected the SPA to reach a #/ route after sign-in" })
.toContain("#/");
await expect(page.locator("body")).toBeVisible({ timeout: resolveTimeout(60_000) });
}
// In-app OIDC (flavor: oidc): the login page renders an "OpenID" provider entry
// (clickable text, not a role=button) that redirects to Keycloak.
async function penpotOidcLogin(page, username, password) {
const expectedAuth = `${env.oidcIssuerUrl}/protocol/openid-connect/auth`;
await gotoOnion(page, loginRoute(env.baseUrl));
const oidcEntry = page.getByText("OpenID", { exact: true });
await expect(oidcEntry, "Expected a Penpot OpenID login entry").toBeVisible({ timeout: resolveTimeout(60_000) });
await oidcEntry.click({ timeout: resolveTimeout(30_000) });
await expect
.poll(() => page.url(), { timeout: resolveTimeout(60_000), message: `expected redirect to ${expectedAuth}` })
.toContain(expectedAuth);
await performKeycloakLoginForm(page, username, password);
await assertAuthenticated(page);
}
// LDAP: a dedicated "LDAP" submit button (enabled once the form is filled)
// binds against OpenLDAP directly — no Keycloak round-trip.
async function penpotLdapLogin(page, email, password) {
await gotoOnion(page, loginRoute(env.baseUrl));
const emailField = page.getByLabel(/work email/i);
const passwordField = page.getByLabel(/^password$/i);
await expect(emailField, "Expected the Penpot login form").toBeVisible({ timeout: resolveTimeout(60_000) });
await emailField.fill(email);
await passwordField.fill(password);
const ldapButton = page.getByRole("button", { name: /^LDAP$/i });
await expect(ldapButton, "Expected the LDAP submit button to enable once the form is filled").toBeEnabled({ timeout: resolveTimeout(30_000) });
await ldapButton.click({ timeout: resolveTimeout(30_000) });
await assertAuthenticated(page);
}
// Native: register a fresh local-DB account (Penpot's register flow can be a
// two-step form: email/password, then full name). SMTP-off deployments activate
// the profile immediately (no verification), landing on the dashboard.
async function penpotRegister(page, fullname, email, password) {
await gotoOnion(page, `${env.baseUrl.replace(/\/$/, "")}/#/auth/register`);
const emailField = page.getByLabel(/work email|^email$/i).first();
const passwordField = page.getByLabel(/^password$/i).first();
await expect(emailField, "Expected the Penpot register form").toBeVisible({ timeout: resolveTimeout(60_000) });
await emailField.fill(email);
await passwordField.fill(password);
const next = page.getByRole("button", { name: /create an account|create account|sign up|register|next|continue/i }).first();
await expect(next, "Expected the register submit button to enable").toBeEnabled({ timeout: resolveTimeout(30_000) });
await next.click({ timeout: resolveTimeout(30_000) });
// Optional second step: full name + terms acceptance.
const nameField = page.getByLabel(/full ?name|^name$/i).first();
if (
await nameField
.waitFor({ state: "visible", timeout: resolveTimeout(2_000) })
.then(() => true)
.catch(() => false)
) {
await nameField.fill(fullname);
const terms = page.getByRole("checkbox").first();
if (await terms.isVisible().catch(() => false)) await terms.check().catch(() => {});
const finish = page.getByRole("button", { name: /create an account|create account|sign up|register|next|continue|finish/i }).first();
await expect(finish).toBeEnabled({ timeout: resolveTimeout(30_000) });
await finish.click({ timeout: resolveTimeout(30_000) });
}
await assertAuthenticated(page);
}
async function penpotNativeLogin(page, email, password) {
await gotoOnion(page, loginRoute(env.baseUrl));
const emailField = page.getByLabel(/work email/i);
const passwordField = page.getByLabel(/^password$/i);
await expect(emailField, "Expected the Penpot login form").toBeVisible({ timeout: resolveTimeout(60_000) });
await emailField.fill(email);
await passwordField.fill(password);
const loginButton = page.getByTestId("login-submit");
await expect(loginButton, "Expected the login submit button to enable once the form is filled").toBeEnabled({ timeout: resolveTimeout(30_000) });
await loginButton.click({ timeout: resolveTimeout(30_000) });
await assertAuthenticated(page);
}
module.exports = {
test,
expect,
skipUnlessServiceEnabled,
isServiceEnabled,
runAdminFlow,
runBiberFlow,
runGuestFlow,
env,
loginRoute,
penpotOidcLogin,
penpotLdapLogin,
penpotNativeLogin,
penpotRegister,
validImagePng,
uniqueImagePng,
};