Files
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

143 lines
6.0 KiB
JavaScript

const { expect } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const { decodeDotenvQuotedValue, gotoOnion } = require("./personas");
const baseUrl = decodeDotenvQuotedValue(process.env.APP_BASE_URL);
const issuerUrl = decodeDotenvQuotedValue(process.env.OIDC_ISSUER_URL);
const adminUsername = decodeDotenvQuotedValue(process.env.LOGIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.LOGIN_PASSWORD);
const biberUsername = decodeDotenvQuotedValue(process.env.BIBER_USERNAME);
const biberPassword = decodeDotenvQuotedValue(process.env.BIBER_PASSWORD);
const issuerHost = new URL(issuerUrl).host;
const issuerPattern = new RegExp(`^https?://${issuerHost.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
const baseUrlPattern = new RegExp(`^${baseUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
function attachDiagnostics(page, _label) {
const diagnostics = { console: [], requests: [], errors: [] };
page.on("console", (msg) => {
diagnostics.console.push(`[${msg.type()}] ${msg.text()}`);
});
page.on("pageerror", (err) => {
diagnostics.errors.push(String(err));
});
page.on("requestfailed", (req) => {
diagnostics.requests.push(`${req.failure()?.errorText || ""} ${req.method()} ${req.url()}`);
});
return diagnostics;
}
async function ssoLoginAndAssertUsername(page, username, password) {
const diagnostics = attachDiagnostics(page, username);
// OpenCloud Web's index.html shows a "browser not supported" splash unless
// `forceAllowOldBrowser` is present in localStorage. Playwright's headless
// Chromium UA is not on its allow list, so prime the flag for the host
// before any navigation happens.
await page.addInitScript(() => {
try {
window.localStorage.setItem(
"forceAllowOldBrowser",
JSON.stringify({ expiry: Date.now() + 30 * 24 * 60 * 60 * 1000 })
);
} catch {}
});
await gotoOnion(page, baseUrl);
const onIssuer = async () => issuerPattern.test(page.url());
if (!(await onIssuer())) {
// Either click the Login control on the OpenCloud SPA or wait for the
// SPA to redirect to Keycloak. Try both in parallel and finish on
// whichever happens first.
const loginCta = page.locator(
'button:has-text("Login"), button:has-text("Sign in"), button:has-text("Log in"), [data-test-id="login-button"], a[href*="oidc"]'
);
try {
await Promise.race([
page.waitForURL(issuerPattern, { timeout: resolveTimeout(60_000) }),
(async () => {
await loginCta.first().waitFor({ state: "visible", timeout: resolveTimeout(60_000) });
await Promise.all([
page.waitForURL(issuerPattern, { timeout: resolveTimeout(60_000) }),
loginCta.first().click({ timeout: resolveTimeout(30_000) }),
]);
})(),
]);
} catch (err) {
const summary = [
`URL when stuck: ${page.url()}`,
`Console (${diagnostics.console.length}):`,
...diagnostics.console.slice(-25),
`Page errors (${diagnostics.errors.length}):`,
...diagnostics.errors.slice(-10),
`Failed requests (${diagnostics.requests.length}):`,
...diagnostics.requests.slice(-10),
].join("\n");
throw new Error(`OpenCloud SPA never reached Keycloak.\n${summary}\nOriginal error: ${err}`, { cause: err });
}
}
await page.locator('input[name="username"], #username').fill(username);
await page.locator('input[name="password"], #password').fill(password);
const leftKeycloak = page
.waitForURL((url) => !issuerPattern.test(url.toString()), { timeout: resolveTimeout(60_000) })
.catch(() => {});
await page
.locator('input[name="password"], #password')
.press("Enter", { timeout: resolveTimeout(5_000) })
.catch(() => {});
await leftKeycloak;
// Fast-fail on a Keycloak credential rejection: the form re-renders with
// `#input-error` / `.alert-error` instead of redirecting to the callback,
// and the 90s banner wait below would otherwise bury the real cause.
const credentialError = page
.locator('#input-error, .alert-error, .kc-feedback-text')
.first();
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()})`,
);
}
// Wait for the redirect chain back to OpenCloud to finish before
// asserting on SPA elements. Without this the banner assertion races
// the OIDC callback hops (auth → /oidc-callback → /web-oidc-callback →
// SPA) and times out before the Files view actually mounts.
try {
await page.waitForURL(baseUrlPattern, { timeout: resolveTimeout(60_000), waitUntil: "load" });
} catch {
// SPA may have already navigated past the matcher window; fall
// through to the visibility assertions which carry their own waits.
}
// Bypass OpenCloud's "browser not supported" splash if it appears.
// 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.waitFor({ state: "visible", timeout: resolveTimeout(2_000) }).then(() => true).catch(() => false)) {
await continueAnyway.click();
}
// The Files view is the post-login landing route; assert against the SPA
// navigation banner instead of body text because OpenCloud shows the
// username inside the user-menu drawer rather than in the page body.
await expect(page.getByRole("banner", { name: /top bar/i })).toBeVisible({ timeout: resolveTimeout(90_000) });
await expect(page.getByRole("link", { name: /personal files/i })).toBeVisible({ timeout: resolveTimeout(30_000) });
}
module.exports = {
baseUrl,
issuerUrl,
adminUsername,
adminPassword,
biberUsername,
biberPassword,
issuerHost,
issuerPattern,
baseUrlPattern,
attachDiagnostics,
ssoLoginAndAssertUsername,
};