diff --git a/frontend/e2e/fixtures/auth-fixture.ts b/frontend/e2e/fixtures/auth-fixture.ts new file mode 100644 index 00000000000..a5d9e3a7d55 --- /dev/null +++ b/frontend/e2e/fixtures/auth-fixture.ts @@ -0,0 +1,89 @@ +import type { Page, TestInfo } from '@playwright/test'; + +import { + isOnLoginPage, + performLogin, + resolveCredentialsForStorageState, + saveStorageState, +} from '../setup/login-helper'; + +/** + * Guards against re-entrant / concurrent re-login attempts on the same page. + */ +const reloginInProgress = new WeakMap>(); + +function storageStatePath(testInfo: TestInfo): string | undefined { + const state = testInfo.project.use.storageState; + return typeof state === 'string' ? state : undefined; +} + +/** + * Detects when a navigation has landed on the OAuth login page — meaning the + * session snapshot in storageState has expired — and transparently + * re-authenticates the current persona, refreshing the stored session so + * subsequent tests reuse the fresh state. + * + * The setup projects establish the session once; long runs can outlive the + * OAuth token, after which every navigation silently redirects to the login + * page and tests hang waiting for elements that never appear. This fixture is + * the self-healing recovery for that case. + */ +export async function recoverSessionIfExpired( + page: Page, + testInfo: TestInfo, +): Promise { + if (!(await isOnLoginPage(page))) { + return false; + } + + const existing = reloginInProgress.get(page); + if (existing) { + await existing; + return true; + } + + const statePath = storageStatePath(testInfo); + const credentials = statePath ? resolveCredentialsForStorageState(statePath) : undefined; + if (!credentials) { + // No credentials to recover with (e.g. auth disabled or dev creds unset). + return false; + } + + const relogin = (async () => { + // eslint-disable-next-line no-console + console.warn( + `[auth] Session expired for "${testInfo.titlePath.join(' > ')}"; re-authenticating.`, + ); + await performLogin(page, credentials.username, credentials.password, credentials.idpName); + if (statePath) { + await saveStorageState(page, statePath); + } + })(); + + reloginInProgress.set(page, relogin); + try { + await relogin; + } finally { + reloginInProgress.delete(page); + } + return true; +} + +/** + * Attaches a main-frame navigation listener that re-authenticates whenever a + * navigation lands on the login page. Returns a disposer to detach it. + */ +export function attachSessionRecovery(page: Page, testInfo: TestInfo): () => void { + const handler = (frame: import('@playwright/test').Frame) => { + if (frame !== page.mainFrame()) { + return; + } + // Fire-and-forget: recovery guards its own re-entrancy. Swallow errors so a + // transient navigation event doesn't reject an unrelated step. + void recoverSessionIfExpired(page, testInfo).catch(() => { + /* best-effort recovery */ + }); + }; + page.on('framenavigated', handler); + return () => page.off('framenavigated', handler); +} diff --git a/frontend/e2e/fixtures/index.ts b/frontend/e2e/fixtures/index.ts index 0ae703794ab..bebbce70b38 100644 --- a/frontend/e2e/fixtures/index.ts +++ b/frontend/e2e/fixtures/index.ts @@ -5,6 +5,7 @@ import { test as base, expect } from '@playwright/test'; import KubernetesClient from '../clients/kubernetes-client'; +import { attachSessionRecovery, recoverSessionIfExpired } from './auth-fixture'; import type { CleanupFixture } from './cleanup-fixture'; import { createCleanupFixture } from './cleanup-fixture'; @@ -24,6 +25,21 @@ type WorkerFixtures = { }; export const test = base.extend({ + // Override the built-in page fixture to transparently recover from expired + // sessions. Long runs can outlive the OAuth token captured in storageState; + // without this, navigations silently redirect to the login page and tests + // hang. The listener re-authenticates the persona whenever a navigation + // lands on the login page, and a proactive check covers the initial page. + page: async ({ page }, use, testInfo) => { + const detach = attachSessionRecovery(page, testInfo); + try { + await recoverSessionIfExpired(page, testInfo); + await use(page); + } finally { + detach(); + } + }, + testConfig: [ async ({}, use) => { const configPath = path.resolve(import.meta.dirname, '..', '.test-config.json'); diff --git a/frontend/e2e/setup/login-helper.ts b/frontend/e2e/setup/login-helper.ts index 459edb7dd56..0e0a0473389 100644 --- a/frontend/e2e/setup/login-helper.ts +++ b/frontend/e2e/setup/login-helper.ts @@ -33,6 +33,36 @@ export function getDeveloperCredentials(): { }; } +/** + * Resolves the login credentials for a given storage-state file. The auth + * fixture uses this to re-authenticate the correct persona (admin vs. + * developer) when a session expires mid-run, mirroring the setup projects. + * Returns null when the required credentials are not configured. + */ +export function resolveCredentialsForStorageState( + storageStatePath: string, +): { username: string; password: string; idpName: string } | null { + const fileName = path.basename(storageStatePath); + if (fileName === 'developer.json') { + return getDeveloperCredentials(); + } + // Default to the kubeadmin/admin persona. + return getAdminCredentials(); +} + +/** + * Returns true when the given page is currently showing the OAuth login page + * (i.e. the session has expired or was never established). + */ +export async function isOnLoginPage(page: Page): Promise { + return page + .locator('[data-test-id="login"]') + .or(page.locator('#inputUsername')) + .first() + .isVisible() + .catch(() => false); +} + export async function performLogin( page: Page, username: string,