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>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-14 14:01:43 +02:00
1 parent e0eda30fbe
commit 42449ebf0a
16 files changed
+72 -36

No files matched your search

@@ -90,7 +90,7 @@ async function signInViaBbbOidc(page, username, password, personaLabel) {
.locator("a, button")
.filter({ hasText: /sign\s*in\s*with|openid|oidc|sso|single\s*sign[-\s]*on/i })
.first();
if (await oidcButton.isVisible({ timeout: resolveTimeout(5_000) }).catch(() => false)) {
if (await oidcButton.waitFor({ state: "visible", timeout: resolveTimeout(2_000) }).then(() => true).catch(() => false)) {
await oidcButton.click({ timeout: resolveTimeout(30_000) }).catch(() => {});
}
});
@@ -64,7 +64,7 @@ test("seaweedfs: an uploaded Decidim avatar is stored in the SeaweedFS bucket",
.locator('[data-upload] button, .upload-container button, [data-upload-modal]')
.or(appPage.getByRole("button", { name: /avatar|add file|add image|edit image|replace image|upload|change/i }))
.first();
if (await openModal.isVisible({ timeout: resolveTimeout(10_000) }).catch(() => false)) {
if (await openModal.waitFor({ state: "visible", timeout: resolveTimeout(2_000) }).then(() => true).catch(() => false)) {
await openModal.click().catch(() => {});
await appPage.waitForLoadState("networkidle").catch(() => {});
}
@@ -88,7 +88,7 @@ async function loginToMastodonViaOidc(page, baseUrl) {
.locator("button[type='submit'], button")
.filter({ hasText: /save and continue/i })
.first();
if (await saveAndContinue.isVisible({ timeout: resolveTimeout(10_000) }).catch(() => false)) {
if (await saveAndContinue.waitFor({ state: "visible", timeout: resolveTimeout(2_000) }).then(() => true).catch(() => false)) {
await saveAndContinue.click();
}
@@ -178,7 +178,7 @@ async function postOnFriendicaViaUi(page, baseUrl, statusText) {
// friendica session. In v2 the password form is already visible from
// step 1's `/` redirect to `/login`.
await page.waitForLoadState("domcontentloaded", { timeout: resolveTimeout(30_000) }).catch(() => {});
if (await page.locator("input[name='password']").first().isVisible({ timeout: resolveTimeout(30_000) }).catch(() => false)) {
if (await page.locator("input[name='password']").first().waitFor({ state: "visible", timeout: resolveTimeout(2_000) }).then(() => true).catch(() => false)) {
await loginViaFriendicaForm(page);
}
@@ -76,7 +76,7 @@ async function loginViaOauth2ProxyAndFriendica(page, username, password) {
// Step 2: Friendica's own login form (post-oauth2-proxy).
await page.waitForLoadState("domcontentloaded", { timeout: resolveTimeout(30_000) }).catch(() => {});
if (await page.locator("input[name='password']").first().isVisible({ timeout: resolveTimeout(30_000) }).catch(() => false)) {
if (await page.locator("input[name='password']").first().waitFor({ state: "visible", timeout: resolveTimeout(2_000) }).then(() => true).catch(() => false)) {
await loginViaFriendicaForm(page, username, password);
}
@@ -51,7 +51,7 @@ test("addon ldapauth: a valid LDAP credential binds against openldap and a bogus
const hasLoginForm = await page
.locator("input[name='password']")
.first()
.isVisible({ timeout: resolveTimeout(2_000) })
.isVisible()
.catch(() => false);
if (onAdminAddons && !hasLoginForm) {
await expect(
@@ -81,7 +81,8 @@ test("addon ldapauth: a valid LDAP credential binds against openldap and a bogus
const hasForm = await negPage
.locator("input[name='password']")
.first()
.isVisible({ timeout: resolveTimeout(30_000) })
.waitFor({ state: "visible", timeout: resolveTimeout(2_000) })
.then(() => true)
.catch(() => false);
// Only meaningful where Friendica's own login form is directly reachable
@@ -50,7 +50,7 @@ async function clickThroughMailuSsoPage(frame) {
return;
}
if (await oidcLink.isVisible({ timeout: resolveTimeout(3_000) }).catch(() => false)) {
if (await oidcLink.waitFor({ state: "visible", timeout: resolveTimeout(3_000) }).then(() => true).catch(() => false)) {
await oidcLink.click({ timeout: resolveTimeout(30_000) });
}
}
@@ -138,7 +138,7 @@ test("mailu: sso login, open admin interface, logout", async ({ page }) => {
// 9. Logout — Mailu admin logout is a link with /logout or /signout in the href
const logoutByHref = page.locator("a[href*='logout'], a[href*='signout']");
const logoutVisible = await logoutByHref.first().isVisible({ timeout: resolveTimeout(5_000) }).catch(() => false);
const logoutVisible = await logoutByHref.first().isVisible().catch(() => false);
if (logoutVisible) {
await logoutByHref.first().click({ timeout: resolveTimeout(30_000) });
@@ -172,7 +172,7 @@ test("mailu: biber sends email to administrator, administrator receives it", asy
// Mailu webmail may show an SSO button or redirect directly to Keycloak
const ssoButton = biberPage.getByRole("button", { name: /sso|single sign.?on|login with/i });
const ssoButtonVisible = await ssoButton.first().isVisible({ timeout: resolveTimeout(5_000) }).catch(() => false);
const ssoButtonVisible = await ssoButton.first().isVisible().catch(() => false);
if (ssoButtonVisible) {
await ssoButton.first().click({ timeout: resolveTimeout(30_000) });
@@ -243,7 +243,7 @@ test("mailu: biber sends email to administrator, administrator receives it", asy
await adminPage.goto(mailuBaseUrl);
const ssoButtonAdmin = adminPage.getByRole("button", { name: /sso|single sign.?on|login with/i });
const ssoAdminVisible = await ssoButtonAdmin.first().isVisible({ timeout: resolveTimeout(5_000) }).catch(() => false);
const ssoAdminVisible = await ssoButtonAdmin.first().isVisible().catch(() => false);
if (ssoAdminVisible) {
await ssoButtonAdmin.first().click({ timeout: resolveTimeout(30_000) });
@@ -318,7 +318,7 @@ test("administrator: app → universal logout", async ({ page }) => {
const link = interactivePage
.getByRole("link", { name: /^(admin|administration|domains|users|fetchmail|aliases)$/i })
.first();
if (await link.isVisible({ timeout: resolveTimeout(10_000) }).catch(() => false)) {
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(
@@ -99,7 +99,7 @@ async function signInViaElementOidc(page, username, password, personaLabel) {
.filter({ hasText: /single\s*sign[- ]*on|continue\s+with\s+(sso|oidc|keycloak|openid)|sign\s+in\s+with\s+(sso|oidc|keycloak|openid)/i })
.first();
await gotoElementApp(page, `${elementBaseUrl}/#/login`, ssoButton.or(ssoTextButton));
const candidate = (await ssoButton.isVisible({ timeout: resolveTimeout(15_000) }).catch(() => false))
const candidate = (await ssoButton.isVisible().catch(() => false))
? ssoButton
: ssoTextButton;
await expect(candidate, `${personaLabel}: Element SSO entry button on #/login must be visible`).toBeVisible({ timeout: resolveTimeout(30_000) });
@@ -117,7 +117,7 @@ async function signInViaElementOidc(page, username, password, personaLabel) {
// token to Element. First-time logins also display a username-selection form
// asking the user to pick their Matrix localpart before this confirmation.
const usernameSelectField = page.locator("input[name='username'], input#field-username").first();
if (await usernameSelectField.isVisible({ timeout: resolveTimeout(5_000) }).catch(() => false)) {
if (await usernameSelectField.waitFor({ state: "visible", timeout: resolveTimeout(5_000) }).then(() => true).catch(() => false)) {
await usernameSelectField.fill(username);
const submit = page.locator("button[type='submit'], input[type='submit']").first();
await submit.click({ timeout: resolveTimeout(30_000) });
@@ -71,7 +71,7 @@ test("seaweedfs: an uploaded Matrix avatar is stored in the SeaweedFS bucket", a
const saveButton = appPage
.getByRole("button", { name: /^(save|apply|upload|confirm)$/i })
.first();
if (await saveButton.isVisible({ timeout: resolveTimeout(10_000) }).catch(() => false)) {
if (await saveButton.waitFor({ state: "visible", timeout: resolveTimeout(2_000) }).then(() => true).catch(() => false)) {
await saveButton.click().catch(() => {});
}
},
@@ -79,11 +79,11 @@ test("integration integration_matrix: per-user login drives a real session again
.waitForResponse((resp) => /\/apps\/integration_matrix\//.test(resp.url()), { timeout: resolveTimeout(30_000) })
.catch(() => null);
if (await passwordField.isVisible({ timeout: resolveTimeout(10_000) }).catch(() => false)) {
if (await passwordField.waitFor({ state: "visible", timeout: resolveTimeout(2_000) }).then(() => true).catch(() => false)) {
await loginField.fill(shared.env.loginUsername || "admin").catch(() => {});
await passwordField.fill(shared.env.loginPassword || "").catch(() => {});
await connectButton.click({ timeout: resolveTimeout(10_000) }).catch(() => {});
} else if (await connectButton.isVisible({ timeout: resolveTimeout(5_000) }).catch(() => false)) {
} else if (await connectButton.waitFor({ state: "visible", timeout: resolveTimeout(2_000) }).then(() => true).catch(() => false)) {
await connectButton.click({ timeout: resolveTimeout(10_000) }).catch(() => {});
}
@@ -94,7 +94,7 @@ async function ssoLoginAndAssertUsername(page, username, password) {
const credentialError = page
.locator('#input-error, .alert-error, .kc-feedback-text')
.first();
if (await credentialError.isVisible({ timeout: resolveTimeout(5_000) }).catch(() => false)) {
if (await credentialError.isVisible().catch(() => false)) {
const message = ((await credentialError.textContent().catch(() => "")) || "").trim();
throw new Error(
`Keycloak rejected credentials for ${username}: "${message || "<no message>"}" (final URL: ${page.url()})`,
@@ -116,7 +116,7 @@ async function ssoLoginAndAssertUsername(page, username, password) {
// Playwright's Chromium UA is not on the OpenCloud allow list, so the SPA
// shows a splash with a "I want to continue anyway" button.
const continueAnyway = page.getByRole("button", { name: /continue anyway/i });
if (await continueAnyway.isVisible({ timeout: resolveTimeout(5_000) }).catch(() => false)) {
if (await continueAnyway.waitFor({ state: "visible", timeout: resolveTimeout(2_000) }).then(() => true).catch(() => false)) {
await continueAnyway.click();
}
@@ -38,9 +38,13 @@ async function ssoLoginAndAssertDashboard(page, username, password) {
// The OpenTalk frontend either auto-redirects to Keycloak or renders a
// "Sign in" CTA first. Race both paths.
if (!issuerPattern.test(page.url())) {
const signInCta = page.getByRole("button", { name: /sign in|log in|anmelden/i });
if (await signInCta.first().isVisible({ timeout: resolveTimeout(10_000) }).catch(() => false)) {
await signInCta.first().click({ timeout: resolveTimeout(30_000) });
const signInCta = page.getByRole("button", { name: /sign in|log in|anmelden/i }).first();
const ctaAppeared = await signInCta
.waitFor({ state: "visible", timeout: resolveTimeout(2_000) })
.then(() => true)
.catch(() => false);
if (ctaAppeared && !issuerPattern.test(page.url())) {
await signInCta.click({ timeout: resolveTimeout(30_000) });
}
await page.waitForURL(issuerPattern, { timeout: resolveTimeout(60_000) });
}
@@ -46,13 +46,13 @@ async function dismissAllOpenModals(page) {
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
const modal = page.locator("[role='dialog'][aria-modal='true']").first();
if (!(await modal.isVisible({ timeout: resolveTimeout(500) }).catch(() => false))) {
if (!(await modal.isVisible().catch(() => false))) {
return;
}
const explicitClose = modal
.getByRole("button", { name: closeNameRegex })
.first();
if (await explicitClose.isVisible({ timeout: resolveTimeout(500) }).catch(() => false)) {
if (await explicitClose.isVisible().catch(() => false)) {
await explicitClose.click().catch(() => {});
} else {
await page.keyboard.press("Escape").catch(() => {});
@@ -71,7 +71,7 @@ async function openwebuiLogout(page, _openwebuiBaseUrl) {
.locator("a, button, [role='menuitem']")
.filter(signOutTextFilter)
.first();
if (await topLevelSignOut.isVisible({ timeout: resolveTimeout(2_000) }).catch(() => false)) {
if (await topLevelSignOut.isVisible().catch(() => false)) {
await actionableClick(topLevelSignOut);
return;
}
@@ -80,7 +80,12 @@ async function openwebuiLogout(page, _openwebuiBaseUrl) {
.getByRole("img", { name: /open\s+user\s+profile\s+menu/i })
.or(page.getByRole("button", { name: /open\s+user\s+profile\s+menu/i }))
.first();
if (!(await menuTrigger.isVisible({ timeout: resolveTimeout(5_000) }).catch(() => false))) {
if (
!(await menuTrigger
.waitFor({ state: "visible", timeout: resolveTimeout(2_000) })
.then(() => true)
.catch(() => false))
) {
return;
}
await dismissAllOpenModals(page);
@@ -171,12 +176,17 @@ async function signInViaNativePassword(page, email, password, personaLabel) {
const nameField = page
.locator("input[autocomplete='name'], input[name='name'], input#name")
.first();
if (await nameField.isVisible({ timeout: resolveTimeout(3_000) }).catch(() => false)) {
if (
await nameField
.waitFor({ state: "visible", timeout: resolveTimeout(2_000) })
.then(() => true)
.catch(() => false)
) {
const signinToggle = page
.locator("a, button")
.filter({ hasText: /sign\s*in|already\s+have/i })
.first();
if (await signinToggle.isVisible({ timeout: resolveTimeout(3_000) }).catch(() => false)) {
if (await signinToggle.isVisible().catch(() => false)) {
await signinToggle.click();
}
}
@@ -278,7 +288,7 @@ async function expectSignInRequiredAfterLogout(page) {
})
)
.first();
if (await kcLogoutConfirm.isVisible({ timeout: resolveTimeout(2_000) }).catch(() => false)) {
if (await kcLogoutConfirm.isVisible().catch(() => false)) {
await actionableClick(kcLogoutConfirm);
}
await page.waitForTimeout(resolveTimeout(2_000));
@@ -141,7 +141,12 @@ async function penpotRegister(page, fullname, email, password) {
// Optional second step: full name + terms acceptance.
const nameField = page.getByLabel(/full ?name|^name$/i).first();
if (await nameField.isVisible({ timeout: resolveTimeout(15_000) }).catch(() => false)) {
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(() => {});
@@ -49,12 +49,22 @@ test("seaweedfs: an uploaded Taiga avatar is stored in the SeaweedFS bucket", as
.getByRole("button", { name: /save|upload|change/i })
.or(appPage.locator('button[type="submit"], a.button-save, .save'))
.first();
if (await saveAction.isVisible({ timeout: resolveTimeout(10_000) }).catch(() => false)) {
if (
await saveAction
.waitFor({ state: "visible", timeout: resolveTimeout(2_000) })
.then(() => true)
.catch(() => false)
) {
await saveAction.click().catch(() => {});
}
const rejection = appPage.getByText(/invalid image format|something went wrong/i).first();
if (await rejection.isVisible({ timeout: resolveTimeout(15_000) }).catch(() => false)) {
if (
await rejection
.waitFor({ state: "visible", timeout: resolveTimeout(2_000) })
.then(() => true)
.catch(() => false)
) {
throw new Error("Taiga rejected the avatar upload (change_avatar pil_image validation) — no object written to SeaweedFS");
}
},
@@ -54,7 +54,10 @@ exports.register = function (shared) {
name: /welcome to the editor/i,
});
if (
await welcomeDialog.isVisible({ timeout: resolveTimeout(5_000) }).catch(() => false)
await welcomeDialog
.waitFor({ state: "visible", timeout: resolveTimeout(2_000) })
.then(() => true)
.catch(() => false)
) {
await welcomeDialog
.getByRole("button", { name: /^close$/i })
@@ -76,7 +79,8 @@ exports.register = function (shared) {
.getByRole("textbox", { name: /add title/i })
.first();
const titleBox = (await iframedTitleBox
.isVisible({ timeout: resolveTimeout(5_000) })
.waitFor({ state: "visible", timeout: resolveTimeout(2_000) })
.then(() => true)
.catch(() => false))
? iframedTitleBox
: topLevelTitleBox;
@@ -56,7 +56,8 @@ test("ldap-authenticator: XWiki enforces its LDAP-backed native credential form
(await page
.locator("input[type='password']:visible")
.first()
.isVisible({ timeout: resolveTimeout(30_000) })
.waitFor({ state: "visible", timeout: resolveTimeout(2_000) })
.then(() => true)
.catch(() => false));
expect(
onLoginSurface,
@@ -100,7 +101,8 @@ test("ldap-authenticator: XWiki enforces its LDAP-backed native credential form
(await page
.locator("input[type='password']:visible")
.first()
.isVisible({ timeout: resolveTimeout(15_000) })
.waitFor({ state: "visible", timeout: resolveTimeout(2_000) })
.then(() => true)
.catch(() => false));
expect(
stillUnauthenticated,