Files
computer-playbook/roles/web-app-taiga/files/playwright/_shared.js
kevinveenbirkenbachandClaude Opus 4.8 07c6ad8e7c test(e2e): scale every nav click + timeout literal for onion targets
Over Tor the redirect chain behind a login/logout/OIDC click routinely exceeds Playwright's fixed 30s action timeout, so the click throws before the flow's scaled waits run. Sweep all role Playwright suites and the shared persona helpers: give every navigation/auth-transition click an onion-scaled timeout (.click({ timeout: resolveTimeout(30_000) })) and route the remaining raw millisecond literals (timeout options, waitForTimeout, sleep-promise setTimeout, positional wait args) through resolveTimeout. Local UI clicks (menus, toggles, modal dismissals, DOM clicks inside evaluate) stay untouched.

Two new lints keep it that way: test_no_raw_timeout_literals forbids raw numeric timeout literals in Playwright specs (opt-out: nocheck: raw-timeout), test_persona_clicks_scaled requires every click in the shared persona auth flows to carry a resolveTimeout timeout (opt-out: nocheck: unscaled-click).

Verified: node --check on all 133 changed JS files, all 13 playwright lints green, make autoformat clean, make test green (2745+196+163+4 tests OK).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:35:53 +02:00

517 lines
17 KiB
JavaScript

// Shared Taiga Playwright spec state: env vars, locator helpers, login/
// logout flow, themed-surface assertions, and the `beforeEach` env-presence
// guard. `playwright.spec.js` wires the lifecycle hook and `require()`s one
const { resolveTimeout } = require("./timeouts");
// test module per scenario so each test stays atomar.
const { expect } = require("@playwright/test");
const { decodeDotenvQuotedValue, escapeRegex, gotoOnion, runAdminFlow, runBiberFlow, runGuestFlow } = require("./personas");
const { isServiceEnabled, skipUnlessServiceEnabled } = require("./service-gating");
const loginUsername = decodeDotenvQuotedValue(process.env.LOGIN_USERNAME);
const loginPassword = decodeDotenvQuotedValue(process.env.LOGIN_PASSWORD);
const oidcIssuerUrl = decodeDotenvQuotedValue(process.env.OIDC_ISSUER_URL);
const oidcButtonText = decodeDotenvQuotedValue(process.env.OIDC_BUTTON_TEXT);
const taigaBaseUrl = decodeDotenvQuotedValue(process.env.TAIGA_BASE_URL);
const ssoEnabled = isServiceEnabled("sso");
const ssoFlavor = decodeDotenvQuotedValue(process.env.SSO_FLAVOR) || "oidc";
const taigaOauth2Enabled = ssoEnabled && ssoFlavor === "oauth2";
const taigaOidcEnabled = ssoEnabled && ssoFlavor === "oidc";
async function findFirstVisible(locators) {
for (const locator of locators) {
const candidate = locator.first();
if (await candidate.isVisible().catch(() => false)) {
return candidate;
}
}
return null;
}
async function getOauth2ProxyCookies(page, baseUrl) {
const cookies = await page.context().cookies(baseUrl);
return cookies.filter((cookie) => /oauth2/i.test(cookie.name));
}
function getOidcEntryLocators(target) {
const oidcLabelPattern = oidcButtonText
? new RegExp(escapeRegex(oidcButtonText), "i")
: /oidc|single sign-on|sso/i;
return [
target.getByRole("link", { name: oidcLabelPattern }),
target.getByRole("button", { name: oidcLabelPattern }),
target.locator('[href*="/oidc"], [data-href*="/oidc"], [ui-sref*="oidc"], [ng-click*="oidc"]')
];
}
async function waitForOauth2ProxyCookie(page, baseUrl, shouldExist, timeout, errorMessage) {
await expect
.poll(
async () => {
const oauth2Cookies = await getOauth2ProxyCookies(page, baseUrl);
return oauth2Cookies.length > 0;
},
{
timeout,
message: errorMessage
}
)
.toBe(shouldExist);
}
async function activateLocatorClick(locator) {
const target = locator.first();
const count = await target.count().catch(() => 0);
if (!count) {
return false;
}
try {
await target.dispatchEvent("click");
} catch {
await target.evaluate((el) => el.click());
}
return true;
}
async function tryLogoutFromTaiga(page, taigaFrame) {
const directLogoutLocators = [
taigaFrame.locator('a[title="Logout"]'),
taigaFrame.locator('a[ng-click*="logout"]'),
taigaFrame.locator('a[href*="logout"]'),
taigaFrame.locator('[tg-nav*="logout"]'),
taigaFrame.getByRole("link", { name: /log ?out/i }),
taigaFrame.getByRole("button", { name: /log ?out/i }),
taigaFrame.locator('[href*="logout"], [ui-sref*="logout"], [ng-click*="logout"]')
];
const menuTriggerLocators = [
taigaFrame.locator("[aria-haspopup='true']"),
taigaFrame.locator("nav button"),
taigaFrame.locator(".user-avatar, .avatar, .profile-avatar, .profile-button, [class*='avatar']")
];
for (const directLogoutLocator of directLogoutLocators) {
if (await activateLocatorClick(directLogoutLocator)) {
await page.waitForTimeout(resolveTimeout(1_000));
return true;
}
}
const directLogout = await findFirstVisible(directLogoutLocators);
if (directLogout) {
await directLogout.click({ timeout: resolveTimeout(2_000) }).catch(() => {});
await page.waitForTimeout(resolveTimeout(1_000));
return true;
}
for (const triggerLocator of menuTriggerLocators) {
const trigger = await findFirstVisible([triggerLocator]);
if (!trigger) {
continue;
}
await trigger.click({ timeout: resolveTimeout(2_000) }).catch(() => {});
await page.waitForTimeout(resolveTimeout(500));
const revealedLogout = await findFirstVisible(directLogoutLocators);
if (revealedLogout) {
await revealedLogout.click({ timeout: resolveTimeout(2_000) }).catch(() => {});
await page.waitForTimeout(resolveTimeout(1_000));
return true;
}
}
return false;
}
async function waitForAuthenticatedTaigaShell(frameOrPage, timeout, errorMessage) {
await expect
.poll(
async () => {
const bodyText = await frameOrPage.locator("body").innerText().catch(() => "");
const loginFormVisible = await frameOrPage
.locator("input[name='username'], input#username, input[name='password'], input#password, #kc-login")
.first()
.isVisible()
.catch(() => false);
if (loginFormVisible) {
return false;
}
return bodyText.length > 100 && /taiga|projects|kanban|backlog|discover/i.test(bodyText);
},
{
timeout,
message: errorMessage
}
)
.toBe(true);
}
async function waitForTopLevelLoginRequirement(
page,
expectedTaigaBaseUrl,
expectedOidcAuthUrl,
timeout,
errorMessage
) {
const deadline = Date.now() + timeout;
const keycloakUsernameField = page.locator("input[name='username'], input#username");
while (Date.now() < deadline) {
const currentUrl = page.url();
if (
currentUrl.includes(expectedOidcAuthUrl) &&
(await keycloakUsernameField.first().isVisible().catch(() => false))
) {
return { kind: "keycloak" };
}
if (currentUrl.includes(expectedTaigaBaseUrl)) {
const loginEntry = await findFirstVisible([
page.getByRole("link", { name: /^Login$/i }),
page.getByRole("button", { name: /^Login$/i })
]);
if (loginEntry) {
return { kind: "taiga-login-page", locator: loginEntry };
}
const oidcEntry = await findFirstVisible(getOidcEntryLocators(page));
if (oidcEntry) {
return { kind: "taiga-oidc-entry", locator: oidcEntry };
}
}
await page.waitForTimeout(resolveTimeout(500));
}
throw new Error(errorMessage);
}
function getTaigaUrls() {
const expectedTaigaBaseUrl = taigaBaseUrl.replace(/\/$/, "");
const expectedOidcAuthUrl = oidcIssuerUrl
? `${oidcIssuerUrl.replace(/\/$/, "")}/protocol/openid-connect/auth`
: "";
return {
expectedTaigaBaseUrl,
expectedOidcAuthUrl,
taigaOauth2SignOutUrl: `${expectedTaigaBaseUrl}/oauth2/sign_out`,
discoverUrl: `${expectedTaigaBaseUrl}/discover`,
projectsUrl: `${expectedTaigaBaseUrl}/projects`,
userSettingsUrl: `${expectedTaigaBaseUrl}/user-settings/user-profile`
};
}
async function getComputedStyleValue(locator, propertyName) {
return locator
.first()
.evaluate((element, cssProperty) => getComputedStyle(element).getPropertyValue(cssProperty), propertyName);
}
async function expectGradientBackground(locator, message) {
await expect(locator.first()).toBeVisible({ timeout: resolveTimeout(60_000) });
await expect
.poll(
async () => getComputedStyleValue(locator, "background-image"),
{
timeout: resolveTimeout(60_000),
message
}
)
.toMatch(/gradient/i);
}
async function loginToTaiga(page) {
const taigaUrls = getTaigaUrls();
// Navigate directly to Taiga's login route. Hitting `/` auto-redirects to
// `/discover` (a public showcase that exposes only a top-of-page Login
// link, NOT the SSO button), which made the detector classify the page
// as `taiga-login-page`, click the bare Login link, then immediately
// expect the Keycloak username field — never re-detecting the SSO
// entry on `/login`. Going straight to `/login` puts us on the page
// that actually renders the OIDC button so the directive's click
// handler is bound by the time we click.
await gotoOnion(page, `${taigaUrls.expectedTaigaBaseUrl}/login`);
if (taigaOauth2Enabled) {
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `Expected Taiga to navigate to Keycloak auth via oauth2-proxy: ${taigaUrls.expectedOidcAuthUrl}`
})
.toContain(taigaUrls.expectedOidcAuthUrl);
} else {
const initialAuthState = await waitForTopLevelLoginRequirement(
page,
taigaUrls.expectedTaigaBaseUrl,
taigaUrls.expectedOidcAuthUrl,
60_000,
"Expected Taiga to expose either the Taiga OIDC entry point or the Keycloak login page"
);
if (initialAuthState.kind === "taiga-oidc-entry") {
await initialAuthState.locator.click({ timeout: resolveTimeout(30_000) });
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `Expected Taiga OIDC login to navigate to Keycloak auth: ${taigaUrls.expectedOidcAuthUrl}`
})
.toContain(taigaUrls.expectedOidcAuthUrl);
} else if (initialAuthState.kind === "taiga-login-page") {
await initialAuthState.locator.click({ timeout: resolveTimeout(30_000) });
// The bare Login link routes to `/login`; the OIDC entry only
// becomes interactive once that view has rendered. Re-detect so
// we click the SSO entry next, not the keycloak form.
const ssoEntry = await waitForTopLevelLoginRequirement(
page,
taigaUrls.expectedTaigaBaseUrl,
taigaUrls.expectedOidcAuthUrl,
60_000,
"Expected Taiga OIDC entry to appear after the Login link click"
);
if (ssoEntry.kind === "taiga-oidc-entry") {
await ssoEntry.locator.click({ timeout: resolveTimeout(30_000) });
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `Expected Taiga OIDC login to navigate to Keycloak auth: ${taigaUrls.expectedOidcAuthUrl}`
})
.toContain(taigaUrls.expectedOidcAuthUrl);
}
}
}
const usernameField = page.locator("input[name='username'], input#username");
const passwordField = page.locator("input[name='password'], input#password");
const signInButton = page.locator(
"input#kc-login, button#kc-login, button[type='submit'], input[type='submit']"
);
await expect(usernameField.first()).toBeVisible({ timeout: resolveTimeout(60_000) });
await usernameField.first().fill(loginUsername);
await passwordField.first().fill(loginPassword);
await signInButton.first().click({ timeout: resolveTimeout(30_000) });
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `Expected Taiga to redirect back after Keycloak login: ${taigaUrls.expectedTaigaBaseUrl}`
})
.toContain(taigaUrls.expectedTaigaBaseUrl);
if (taigaOauth2Enabled) {
await waitForOauth2ProxyCookie(
page,
taigaUrls.expectedTaigaBaseUrl,
true,
60_000,
"Expected Taiga to establish an oauth2-proxy session after the Keycloak login redirect"
);
}
await waitForAuthenticatedTaigaShell(
page,
60_000,
"Timed out waiting for a signed-in Taiga shell after the Keycloak login redirect"
);
return taigaUrls;
}
async function logoutFromTaiga(page, session) {
if (taigaOauth2Enabled) {
await gotoOnion(page, session.taigaOauth2SignOutUrl);
await waitForOauth2ProxyCookie(
page,
session.expectedTaigaBaseUrl,
false,
60_000,
"Expected Taiga oauth2-proxy session cookie to be cleared after /oauth2/sign_out"
);
} else {
await gotoOnion(page, session.expectedTaigaBaseUrl);
await waitForAuthenticatedTaigaShell(
page,
60_000,
"Timed out waiting for the top-level signed-in Taiga shell before logout"
);
await tryLogoutFromTaiga(page, page);
}
await gotoOnion(page, session.expectedTaigaBaseUrl);
const loggedOutState = await waitForTopLevelLoginRequirement(
page,
session.expectedTaigaBaseUrl,
session.expectedOidcAuthUrl,
60_000,
"Expected logged-out access to Taiga to require a fresh login"
);
if (taigaOauth2Enabled) {
expect(loggedOutState.kind).toBe("keycloak");
await expect(page.locator("input[name='username'], input#username").first()).toBeVisible({ timeout: resolveTimeout(60_000) });
await expect
.poll(
async () => page.url(),
{
timeout: resolveTimeout(60_000),
message: "Expected top-level Taiga re-entry to stay on the Keycloak login page after logout"
}
)
.toContain(session.expectedOidcAuthUrl);
return;
}
expect(["keycloak", "taiga-login-page", "taiga-oidc-entry"]).toContain(loggedOutState.kind);
if (loggedOutState.kind === "keycloak") {
await expect(page.locator("input[name='username'], input#username").first()).toBeVisible({ timeout: resolveTimeout(60_000) });
return;
}
await expect(loggedOutState.locator).toBeVisible({ timeout: resolveTimeout(60_000) });
}
async function reachTopLevelTaigaAuthEntry(page, taigaUrls, timeout, errorMessage) {
const deadline = Date.now() + timeout;
let loginClicked = false;
while (Date.now() < deadline) {
const currentUrl = page.url();
const keycloakUsernameField = page.locator("input[name='username'], input#username");
if (
currentUrl.includes(taigaUrls.expectedOidcAuthUrl) &&
(await keycloakUsernameField.first().isVisible().catch(() => false))
) {
return { kind: "keycloak" };
}
if (currentUrl.includes(taigaUrls.expectedTaigaBaseUrl)) {
const oidcEntry = await findFirstVisible(getOidcEntryLocators(page));
if (oidcEntry) {
return { kind: "taiga-oidc-entry", locator: oidcEntry };
}
const loginEntry = await findFirstVisible([
page.getByRole("link", { name: /^Login$/i }),
page.getByRole("button", { name: /^Login$/i })
]);
if (loginEntry && !loginClicked) {
await loginEntry.click({ timeout: resolveTimeout(30_000) });
loginClicked = true;
await page.waitForTimeout(resolveTimeout(500));
continue;
}
const visibleLocalLoginField = await findFirstVisible([
page.locator("input[name='username'], input#username"),
page.locator("input[name='password'], input#password")
]);
if (visibleLocalLoginField) {
await page.waitForTimeout(resolveTimeout(1_000));
const persistedLocalLoginField = await findFirstVisible([
page.locator("input[name='username'], input#username"),
page.locator("input[name='password'], input#password")
]);
if (persistedLocalLoginField) {
return { kind: "taiga-local-login-visible", locator: persistedLocalLoginField };
}
}
}
await page.waitForTimeout(resolveTimeout(500));
}
throw new Error(errorMessage);
}
function beforeEach() {
expect(taigaBaseUrl, "TAIGA_BASE_URL must be set in the Playwright env file").toBeTruthy();
expect(loginUsername, "LOGIN_USERNAME must be set in the Playwright env file").toBeTruthy();
expect(loginPassword, "LOGIN_PASSWORD must be set in the Playwright env file").toBeTruthy();
if (taigaOauth2Enabled || taigaOidcEnabled) {
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set in the Playwright env file").toBeTruthy();
}
}
async function loginToTaigaNative(page) {
const taigaUrls = getTaigaUrls();
// Variant where neither OIDC nor oauth2 is enabled: Taiga shows its own
// form (LDAP federation when `loginFormType=ldap`, plain local-DB auth
// otherwise). The login route stays on Taiga the whole time — no
// Keycloak round-trip — so the username/password form lives at /login
// directly.
await gotoOnion(page, `${taigaUrls.expectedTaigaBaseUrl}/login`);
const usernameField = page.locator("input[name='username'], input#username");
const passwordField = page.locator("input[name='password'], input#password");
const signInButton = page.locator(
"input#kc-login, button#kc-login, button[type='submit'], input[type='submit']",
);
await expect(usernameField.first()).toBeVisible({ timeout: resolveTimeout(60_000) });
await usernameField.first().fill(loginUsername);
await passwordField.first().fill(loginPassword);
await signInButton.first().click({ timeout: resolveTimeout(30_000) });
await waitForAuthenticatedTaigaShell(
page,
60_000,
"Timed out waiting for a signed-in Taiga shell after the native login form submit",
);
return taigaUrls;
}
async function logoutFromTaigaNative(page, session) {
await gotoOnion(page, session.expectedTaigaBaseUrl);
await waitForAuthenticatedTaigaShell(
page,
60_000,
"Timed out waiting for the top-level signed-in Taiga shell before native logout",
);
await tryLogoutFromTaiga(page, page);
await gotoOnion(page, `${session.expectedTaigaBaseUrl}/login`);
await expect(page.locator("input[name='username'], input#username").first())
.toBeVisible({ timeout: resolveTimeout(60_000) });
}
module.exports = {
env: { taigaOauth2Enabled, taigaOidcEnabled },
skipUnlessServiceEnabled,
loginToTaiga,
logoutFromTaiga,
loginToTaigaNative,
logoutFromTaigaNative,
getTaigaUrls,
expectGradientBackground,
reachTopLevelTaigaAuthEntry,
runAdminFlow,
runBiberFlow,
runGuestFlow,
beforeEach,
};