diff --git a/roles/test-e2e-playwright/files/personas/utils/keycloak.js b/roles/test-e2e-playwright/files/personas/utils/keycloak.js index ea2526b964..2e3c8f91ac 100644 --- a/roles/test-e2e-playwright/files/personas/utils/keycloak.js +++ b/roles/test-e2e-playwright/files/personas/utils/keycloak.js @@ -31,12 +31,25 @@ const { resolveTimeout } = require("../../timeouts"); // SPOT for the role-side OIDC adapter readiness contract. A role whose // `templates/javascript/oidc.js.j2` wraps its Login link in a JS click // handler (e.g. `keycloak.login()` with PKCE) MUST set this flag on -// `window` after the click interceptor is wired, so persona helpers can -// click the link without racing the adapter. +// `window` to `false` before the Login link renders and to `true` after +// the click interceptor is wired, so persona helpers can click the link +// without racing the adapter. const OIDC_LOGIN_READY_FLAG = "__oidcLoginReady"; const OIDC_TRIGGER_NAME = /sso|openid|single[\s-]?sign/i; +// Keycloak's end-session URL also contains `openid-connect`; a page that +// renders a hidden Logout item before its Login link would otherwise pin +// `.first()` to the hidden Logout item until the probe times out. +const OIDC_ENTRY_SELECTOR = [ + "a[href*='openid_connect' i]:not([href*='logout' i])", + "a[href*='openid-connect' i]:not([href*='logout' i])", + "a[href*='/auth/auth/' i]:not([href*='logout' i])", + "form[action*='openid' i] button", + "a[data-testid*='oidc' i]", + "button[data-testid*='oidc' i]", +].join(", "); + async function performKeycloakLoginForm(target, username, password) { const usernameField = target .getByRole("textbox", { name: /username|email/i }) @@ -73,8 +86,8 @@ async function performKeycloakLogin(page, username, password, canonicalDomain) { // the role's adapter to signal readiness (OIDC_LOGIN_READY_FLAG) before // clicking, so the click hits the JS-wrapped handler (which stores // PKCE state) and not the raw `href` (which would skip PKCE and break -// the post-login token exchange on PKCE-enforced clients). The 15s -// fallback covers roles whose Login link is purely static. Returns the +// the post-login token exchange on PKCE-enforced clients). Roles that +// never declare the flag only wait for the page's load event. Returns the // Page that reached `openid-connect/auth` — the opener, or the popup for // roles that hand the IdP off via `window.open` — else null. // @@ -88,11 +101,7 @@ async function performKeycloakLogin(page, username, password, canonicalDomain) { // flow instead of the role's own auth chain. async function clickOidcLoginLink(page, strictLink, looseLink) { for (let attempt = 0; attempt < 2; attempt += 1) { - let oidcLink = page - .locator( - "a[href*='openid_connect' i], a[href*='openid-connect' i], a[href*='/auth/auth/' i], form[action*='openid' i] button, a[data-testid*='oidc' i], button[data-testid*='oidc' i]", - ) - .first(); + let oidcLink = page.locator(OIDC_ENTRY_SELECTOR).first(); if (attempt > 0) { oidcLink = oidcLink .or(page.getByRole("button", { name: OIDC_TRIGGER_NAME })) @@ -120,9 +129,10 @@ async function clickOidcLoginLink(page, strictLink, looseLink) { } } + await page.waitForLoadState("load", { timeout: resolveTimeout(15_000) }).catch(() => {}); await page .waitForFunction( - (flag) => window[flag] === true, + (flag) => window[flag] !== false, OIDC_LOGIN_READY_FLAG, { timeout: resolveTimeout(15_000) }, ) diff --git a/roles/web-app-dashboard/templates/javascript.js.j2 b/roles/web-app-dashboard/templates/javascript.js.j2 index 38e836bda6..b4ae65c428 100644 --- a/roles/web-app-dashboard/templates/javascript.js.j2 +++ b/roles/web-app-dashboard/templates/javascript.js.j2 @@ -11,6 +11,9 @@ ]; // Cache buster (highest mtime computed during deploy) const VERSION = "{{ javascript_file_version }}"; +{% if 'oidc.js' in DASHBOARD_JS_FILES %} + window.__oidcLoginReady = false; +{% endif %} // 2) Helper to load a