Skip to content
180 changes: 180 additions & 0 deletions frontend/e2e/fixtures/auth-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import type { Frame, 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<Page, Promise<void>>();

/**
* The last app route each page navigated to before any auth redirect. Used to
* send the page back where the test intended to be after re-authenticating,
* since by the time recovery runs the page URL is the OAuth/login page.
*/
const lastAppUrl = new WeakMap<Page, string>();

function storageStatePath(testInfo: TestInfo): string | undefined {
const state = testInfo.project.use.storageState;
return typeof state === 'string' ? state : undefined;
}

/**
* True for OAuth server / console login URLs — the pages a session-expiry
* redirect passes through. These are never the route a test wants to resume at.
*
* Matches against the URL pathname and anchors on known auth path prefixes so
* console resource routes that merely contain "auth" or "login" as a segment
* (e.g. a Secret named "auth") aren't misclassified.
*/
function isAuthUrl(url: string): boolean {
let pathname: string;
try {
pathname = new URL(url).pathname;
} catch {
return false;
}
return /^\/(oauth2?|login|auth)(\/|$)/.test(pathname);
}

/**
* 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. After re-login, navigates back to
* the route the test was heading to so deep-link tests resume in place.
*
* 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,
detectTimeoutMs = 0,
): Promise<boolean> {
// If a re-login is already running (or being set up) for this page, await it
// rather than starting a second one.
const existing = reloginInProgress.get(page);
if (existing) {
await existing;
return true;
}

// Claim the re-login slot synchronously — before any await — so concurrent
// navigation events can't both pass the check above and launch duplicate
// logins. The deferred is published now and settled in the finally below;
// keeping every early return and rejection inside the try guarantees the
// slot is always released (a stuck claim would block all future recovery).
// The claim resolves on success and rejects on failure so joiners (the
// `existing` branch above) observe the same outcome instead of a false success.
let release!: () => void;
let fail!: (error: unknown) => void;
const claim = new Promise<void>((resolve, reject) => {
release = resolve;
fail = reject;
});
// A rejected claim that nobody awaits is an unhandled rejection; attach a
// no-op catch to the stored copy so only explicit awaiters see the error.
claim.catch(() => {});
reloginInProgress.set(page, claim);
Comment on lines +103 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- storage-state configuration ---'
fd -i 'playwright.config.*|package.json' frontend -x sh -c \
  'printf "\n--- %s ---\n" "$1"; rg -n -C 3 "storageState|workers|fullyParallel" "$1" || true' sh {}

printf '%s\n' '--- storage-state persistence implementation ---'
rg -n -C 5 --glob '*.ts' \
  'function saveStorageState|const saveStorageState|saveStorageState\(|storageState\(\{.*path' \
  frontend/e2e

Repository: openshift/console

Length of output: 6073


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- playwright projects and storage-state paths ---'
sed -n '1,190p' frontend/playwright.config.ts

printf '%s\n' '--- auth fixture and fixture scopes ---'
sed -n '1,150p' frontend/e2e/fixtures/auth-fixture.ts
sed -n '1,180p' frontend/e2e/fixtures/index.ts

printf '%s\n' '--- storage-state path definitions and save helper ---'
rg -n -C 8 --glob '*.ts' \
  'adminStorageState|developerStorageState|STORAGE_STATE_DIR|storageState\(\{ path|saveStorageState' frontend/e2e frontend/playwright.config.ts

Repository: openshift/console

Length of output: 22794


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Playwright version ---'
rg -n -C 2 '"`@playwright/test`"|"playwright"' frontend/package.json package.json frontend/yarn.lock yarn.lock pnpm-lock.yaml package-lock.json 2>/dev/null || true

printf '%s\n' '--- repository-level synchronization around auth-state writes ---'
rg -n -C 4 --glob '!node_modules/**' \
  'kubeadmin\.json|developer\.json|storageState\(\{ *path|saveStorageState|lockfile|proper-lockfile|mkdir.*lock|writeFile.*storage' .

Repository: openshift/console

Length of output: 16321


🌐 Web query:

Playwright storageState({ path }) implementation atomic file write behavior

💡 Result:

The Playwright storageState({ path }) method does not implement atomic file write behavior [1][2]. When this method is called, Playwright serializes the browser context's state (cookies, local storage, and optionally IndexedDB) and writes it directly to the specified path using standard file system operations [1]. Internally, Playwright uses Node.js's fs.promises.writeFile to save the state as a JSON file [1][3][4]. This process creates or overwrites the file directly [1][2]. Because it does not use an atomic pattern—such as writing to a temporary file and then renaming it—there is a possibility that a process reading the file simultaneously could encounter a partially written or corrupted state if the write operation is interrupted [1][2]. Additionally, the method does not provide built-in protections against race conditions or concurrent writes to the same path [2]. If multiple test workers or processes attempt to call storageState with the same output path simultaneously, their writes may interleave or cause conflicts [2]. To mitigate these risks in environments where concurrency is a concern, it is recommended to: 1. Use unique paths for different workers or test runs (e.g., by utilizing test.info().outputPath()) [2]. 2. Avoid sharing the same state file across concurrent processes that might write to it [2]. 3. Treat the file as an immutable artifact once it has been generated by a setup process, ensuring that subsequent test workers only perform read operations [2].

Citations:


Serialize storage-state writes by statePath.

WORKERS can run multiple workers in one project, but all admin workers use kubeadmin.json and all developer workers use developer.json. saveStorageState writes directly without a lock or atomic replacement. Concurrent recovery can corrupt or overwrite the shared state. Use a lock keyed by statePath or assign each worker a separate state file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/e2e/fixtures/auth-fixture.ts` around lines 60 - 68, Update the
authentication recovery flow around saveStorageState to serialize storage-state
writes by statePath. Use a shared lock keyed by each statePath so concurrent
workers targeting kubeadmin.json or developer.json cannot write simultaneously,
and ensure the lock is released on every success and failure path; preserve the
existing re-login coordination behavior.

Comment on lines +108 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Propagate the actual recovery result to joiners.

The shared claim is Promise<void>, but recoverSessionIfExpired returns Promise<boolean>. A caller that joins at Lines 64-67 always receives true. The owner resolves the claim without recovery when the login page is not detected or credentials are unavailable.

Use Promise<boolean> for the claim. Resolve false on both no-op paths and true after successful recovery. Return the existing claim to joiners.

Proposed fix
-  let release!: () => void;
+  let release!: (recovered: boolean) => void;
...
-  const claim = new Promise<void>((resolve, reject) => {
+  const claim = new Promise<boolean>((resolve, reject) => {
...
   if (existing) {
-    await existing;
-    return true;
+    return existing;
   }
...
-      release();
+      release(false);
...
-      release();
+      release(false);
...
-    release();
+    release(true);

Also update the reloginInProgress map value type to Promise<boolean>.

Also applies to: 117-118

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/e2e/fixtures/auth-fixture.ts` around lines 75 - 86, Update the
reloginInProgress map and shared claim in recoverSessionIfExpired to use
Promise<boolean>, return the existing claim directly to joiners, resolve false
when the login page is absent or credentials are unavailable, and resolve true
only after successful recovery.


try {
if (!(await isOnLoginPage(page, detectTimeoutMs))) {
release();
return false;
}

const statePath = storageStatePath(testInfo);
const credentials = statePath ? resolveCredentialsForStorageState(statePath) : null;
if (!credentials) {
// No credentials to recover with (e.g. auth disabled or dev creds unset).
release();
return false;
}

const intendedUrl = lastAppUrl.get(page);
// 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);
}
// Restore the route the test was navigating to before the redirect, so
// deep-link tests resume where they expected rather than on the console
// home page that performLogin lands on.
if (intendedUrl && intendedUrl !== page.url()) {
await page.goto(intendedUrl, { waitUntil: 'domcontentloaded' });
}
release();
return true;
} catch (error) {
// Propagate the failure to every awaiter of this claim.
fail(error);
throw error;
} finally {
reloginInProgress.delete(page);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* True while a re-login is running (or being set up) for the page. The page.goto
* override consults this to skip the recovery step for recovery-owned
* navigations — performLogin and the route-restoration goto both call the
* overridden page.goto, and re-running recovery there would deadlock the
* override on the very claim it is nested inside.
*/
export function isRecoveryInProgress(page: Page): boolean {
return reloginInProgress.has(page);
}

/**
* Awaits any session recovery currently in progress for the page (no-op if
* none). Call this after a navigation so a test action doesn't race an
* in-flight re-login triggered by that same navigation.
*/
export async function awaitSessionRecovery(page: Page): Promise<void> {
const inProgress = reloginInProgress.get(page);
if (inProgress) {
await inProgress.catch(() => {
/* best-effort — recovery errors are surfaced by the failing test action */
});
}
}

/**
* 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: Frame) => {
if (frame !== page.mainFrame()) {
return;
}
const url = frame.url();
const onAuthUrl = isAuthUrl(url);
// Remember the most recent non-auth route so recovery can return to it.
if (url && url !== 'about:blank' && !onAuthUrl) {
lastAppUrl.set(page, url);
}
// On a normal (non-auth) navigation, detect the login page instantly so the
// hot path adds no latency. When we land on an auth URL the session likely
// expired but the login form may still be rendering, so give it a bounded
// window to appear before deciding recovery isn't needed.
// 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, onAuthUrl ? 5_000 : 0).catch(() => {
/* best-effort recovery */
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
page.on('framenavigated', handler);
return () => page.off('framenavigated', handler);
}
48 changes: 48 additions & 0 deletions frontend/e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { test as base, expect } from '@playwright/test';

import KubernetesClient from '../clients/kubernetes-client';

import {
attachSessionRecovery,
awaitSessionRecovery,
isRecoveryInProgress,
recoverSessionIfExpired,
} from './auth-fixture';
import type { CleanupFixture } from './cleanup-fixture';
import { createCleanupFixture } from './cleanup-fixture';

Expand All @@ -24,6 +30,48 @@ type WorkerFixtures = {
};

export const test = base.extend<TestFixtures, WorkerFixtures>({
// 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. A navigation listener re-authenticates the persona whenever a
// navigation lands on the login page.
//
// We also wrap page.goto so that after every navigation the caller awaits any
// recovery the navigation itself triggered — otherwise the action that hit
// the expiry would race the background re-login and act on the login page.
// This covers both raw page.goto calls in tests and BasePage.goTo.
page: async ({ page }, use, testInfo) => {
const detach = attachSessionRecovery(page, testInfo);
const originalGoto = page.goto.bind(page);
page.goto = async (url, options) => {
// Recovery itself navigates through this same overridden goto (performLogin
// and the route-restoration goto). Those recovery-owned navigations must
// bypass the recovery step below — re-entering would deadlock the override
// on the very claim it is nested inside. Detect them and pass straight
// through to the original goto.
if (isRecoveryInProgress(page)) {
return originalGoto(url, options);
}
const response = await originalGoto(url, options);
// Drive recovery synchronously here rather than relying on the
// framenavigated listener, whose dispatch can race goto resolving. This
// call is guarded/idempotent: it no-ops when not on the login page and
// joins any recovery the listener already started.
await recoverSessionIfExpired(page, testInfo, 2_000);
await awaitSessionRecovery(page);
return response;
};
try {
// Best-effort guard for a page that somehow starts on the login page; at
// fixture setup the page is typically about:blank, so this usually no-ops
// and the listener does the real work.
await recoverSessionIfExpired(page, testInfo);
await use(page);
} finally {
detach();
}
},

testConfig: [
async ({}, use) => {
const configPath = path.resolve(import.meta.dirname, '..', '.test-config.json');
Expand Down
8 changes: 4 additions & 4 deletions frontend/e2e/pages/web-terminal-config-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class WebTerminalConfigPage extends BasePage {

async navigateToWebTerminalConfig(): Promise<void> {
await this.goTo('/k8s/cluster/operator.openshift.io~v1~Console/cluster');
await this.waitForLoadingComplete(10_000);
await this.waitForLoadingComplete(30_000);
const customizeButton = this.page.getByRole('button', { name: 'Customize' });
// eslint-disable-next-line no-restricted-syntax
await customizeButton
Expand All @@ -25,17 +25,17 @@ export class WebTerminalConfigPage extends BasePage {
await this.robustClick(customizeButton.first());
} else {
const actionsMenu = this.page.getByTestId('actions-menu-button');
await this.robustClick(actionsMenu);
await this.robustClick(actionsMenu, { timeout: 60_000 });
const customizeAction = this.page.locator('[data-test-action="Customize"]:not([disabled])');
await this.robustClick(customizeAction);
}
await this.waitForLoadingComplete(10_000);
await this.waitForLoadingComplete(30_000);
await this.clickWebTerminalTab();
}

async clickWebTerminalTab(): Promise<void> {
const tab = this.page.getByRole('tab', { name: 'Web Terminal' });
await this.robustClick(tab, { timeout: 60_000 });
await this.robustClick(tab, { timeout: 60_000, retries: 1 });
await this.waitForLoadingComplete(5_000);
}

Expand Down
26 changes: 6 additions & 20 deletions frontend/e2e/pages/web-terminal-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,12 @@ export class WebTerminalPage extends BasePage {
private readonly closeTerminalButton = this.page.getByLabel(/Close terminal/);
private readonly inactivityMessageArea = this.page.locator('div.co-cloudshell-exec__error-msg');

async waitForTerminalIconVisible(maxRetries = 10): Promise<void> {
async waitForTerminalIconVisible(): Promise<void> {
await warmupSPA(this.page);
try {
// eslint-disable-next-line no-restricted-syntax
await this.terminalIcon.waitFor({ state: 'visible', timeout: 30_000 });
return;
} catch {
// Icon not visible on first load — retry with reloads
}
for (let attempt = 0; attempt < maxRetries; attempt++) {
await this.page.reload();
try {
// eslint-disable-next-line no-restricted-syntax
await this.terminalIcon.waitFor({ state: 'visible', timeout: 15_000 });
return;
} catch {
// Retry
}
}
throw new Error(`Terminal icon not visible after ${maxRetries} retries`);
await expect(async () => {
await this.page.reload({ waitUntil: 'domcontentloaded' });
await expect(this.terminalIcon).toBeVisible({ timeout: 15_000 });
}).toPass({ intervals: [2_000, 5_000, 10_000], timeout: 120_000 });
}

async clickTerminalIcon(): Promise<void> {
Expand All @@ -54,7 +40,7 @@ export class WebTerminalPage extends BasePage {
await this.loadingBox.waitFor({ state: 'detached', timeout: 60_000 }).catch(() => {});
}

async waitForTerminalWindow(timeoutMs = 60_000): Promise<void> {
async waitForTerminalWindow(timeoutMs = 120_000): Promise<void> {
await expect(this.terminalContainer).toBeVisible({ timeout: timeoutMs });
await expect(this.terminalWindow).toBeVisible({ timeout: timeoutMs });
}
Expand Down
9 changes: 3 additions & 6 deletions frontend/e2e/setup/admin-auth.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@ import * as path from 'path';

import { test as setup } from '@playwright/test';

import { performLogin, saveStorageState } from './login-helper';
import { getAdminCredentials, performLogin, saveStorageState } from './login-helper';

const adminStorageState = path.resolve(import.meta.dirname, '..', '.auth', 'kubeadmin.json');

setup('login as kubeadmin', async ({ page }) => {
setup.skip(process.env.SKIP_GLOBAL_SETUP === 'true', 'SKIP_GLOBAL_SETUP is set');

const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000';
const username = process.env.OPENSHIFT_USERNAME || 'kubeadmin';
const password = process.env.BRIDGE_KUBEADMIN_PASSWORD || '';

await performLogin(page, baseURL, username, password, 'kube:admin');
const { username, password, idpName } = getAdminCredentials();
await performLogin(page, username, password, idpName);
await saveStorageState(page, adminStorageState);
});
13 changes: 4 additions & 9 deletions frontend/e2e/setup/developer-auth.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,16 @@ import * as path from 'path';

import { test as setup } from '@playwright/test';

import { performLogin, saveStorageState } from './login-helper';
import { getDeveloperCredentials, performLogin, saveStorageState } from './login-helper';

const developerStorageState = path.resolve(import.meta.dirname, '..', '.auth', 'developer.json');

setup('login as developer', async ({ page }) => {
setup.skip(process.env.SKIP_GLOBAL_SETUP === 'true', 'SKIP_GLOBAL_SETUP is set');

const htpasswdUser = process.env.BRIDGE_HTPASSWD_USERNAME;
const htpasswdPass = process.env.BRIDGE_HTPASSWD_PASSWORD;
const creds = getDeveloperCredentials();
setup.skip(!creds, 'No developer credentials configured');

setup.skip(!htpasswdUser || !htpasswdPass, 'No developer credentials configured');

const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000';
const htpasswdIdp = process.env.BRIDGE_HTPASSWD_IDP || htpasswdUser!;

await performLogin(page, baseURL, htpasswdUser!, htpasswdPass!, htpasswdIdp);
await performLogin(page, creds!.username, creds!.password, creds!.idpName);
await saveStorageState(page, developerStorageState);
});
Loading