diff --git a/test/ui-e2e/.auth/setup.ts b/test/ui-e2e/.auth/setup.ts index 03fad54e779..8cf9728ee04 100644 --- a/test/ui-e2e/.auth/setup.ts +++ b/test/ui-e2e/.auth/setup.ts @@ -34,8 +34,8 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { if (await idpScreenText.isVisible()) { console.log(`IDP selection screen detected. Selecting provider: "${idpName}"`); - //look for the specific IDP - const idpLink = page.getByRole('link', { name: idpName, exact: true }); + //look for the specific idp link without exact matching + const idpLink = page.getByRole('link', { name: idpName }); await idpLink.waitFor({ state: 'visible', timeout: TIMEOUTS.short }); await idpLink.click(); @@ -58,10 +58,10 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { await passwordInput.fill(process.env.CLUSTER_PASSWORD); await page.getByRole('button', { name: /Log in/i }).click(); -//handle the OpenShift 4.x Welcome Tour modal if it appears + //handle the openshift welcome tour modal if it appears try { const skipTourButton = page.getByRole('button', { name: /skip tour/i }); - //wait up to 5 seconds for the modal to pop up + //wait briefly for the modal to pop up await skipTourButton.waitFor({ state: 'visible', timeout: TIMEOUTS.short }); await skipTourButton.click(); console.log('Dismissed the OpenShift Welcome Tour modal.'); diff --git a/test/ui-e2e/README.md b/test/ui-e2e/README.md index fae9ca41057..d9e1b9092a1 100644 --- a/test/ui-e2e/README.md +++ b/test/ui-e2e/README.md @@ -1,4 +1,3 @@ - # OpenShift GitOps Operator - UI End-to-End Test Suite This directory contains the Playwright-based UI End-to-End (E2E) automation suite for the OpenShift GitOps Operator. It validates core frontend workflows, console integration, Red Hat Single Sign-On (RHSSO) loops, and multi-version Argo CD compatibility across OpenShift clusters. @@ -21,7 +20,6 @@ Navigate to this directory and install the Node modules along with the required cd test/ui-e2e npm install npx playwright install chromium - ``` --- @@ -35,40 +33,43 @@ The test suite requires cluster administrative credentials to discover routes an Generate a local `.env` file in the root of this directory using the following block: ```bash -cat < .env +cat < .env export CLUSTER_USER="kubeadmin" export CLUSTER_PASSWORD="" export OC_API_URL="" export IDP="kube:admin" # (Optional) Defaults to kube:admin EOF - ``` -> **Security Warning:** The `.env` file is explicitly ignored by Git. Please don't commit credentials to the repository. +> **Security Warning:** The `.env` file is explicitly ignored by Git. Please don't commit credentials to the repository. --- ## Execution Commands -All executions are driven via the ./run-ui-tests.sh wrapper script. This wrapper automatically syncs your local oc CLI context to match your .env configuration, performs route discovery for the Console/Argo CD components, and initializes the Playwright runner. +All executions are driven via the `./run-ui-tests.sh` wrapper script. This wrapper automatically syncs your local oc CLI context to match your `.env` configuration, performs route discovery for the Console/Argo CD components, and initializes the Playwright runner. + +> **IMPORTANT: Sequential Execution (`--workers=1`)** +> By default, Playwright attempts to run tests in parallel. Because our suite creates and destroys resources in the shared `openshift-gitops` namespace, parallel execution can cause race conditions and false test failures. **Always append `--workers=1` when running the entire test suite** to force sequential execution. ### Standard Test Execution | Target | Command | | --- | --- | -| **Run All Tests (Local Headless)** | `./run-ui-tests.sh --project=chromium` | -| **Run All Tests (Local Headed + Trace)** | `./run-ui-tests.sh --project=chromium --headed --trace on` | -| **Run All Tests (Simulate CI)** | `./run-ui-tests.sh --env=ci --project=chromium` | +| **Run All Tests (Local Headless)** | `./run-ui-tests.sh --project=chromium --workers=1` | +| **Run All Tests (Local Headed + Trace)** | `./run-ui-tests.sh --project=chromium --workers=1 --headed --trace on` | +| **Run All Tests (Simulate CI)** | `./run-ui-tests.sh --env=ci --project=chromium --workers=1` | | **Run a Specific Spec File** | `./run-ui-tests.sh tests/resource-tree.spec.ts --project=chromium --headed` | ### Playwright Flags Reference | Flag | Purpose | | --- | --- | +| `--workers=1` | Forces tests to run sequentially (one at a time). Essential for preventing cluster resource race conditions when running multiple files. | | `--headed` | Launches the visible Chromium browser UI. Excellent for local debugging. | | `--trace on` | Records a granular execution trace (DOM snapshots, network calls, actions) for visual triage. | | `--reporter=list` | Switches stdout to a clean line-by-line format, ideal for monitoring real-time execution steps. | -| --env= | Overrides the local setup to simulate automation. It forces headless execution, performs a clean `npm ci`, and installs required browser binaries dynamically. | +| `--env=` | Overrides the local setup to simulate automation. It forces headless execution, performs a clean `npm ci`, and installs required browser binaries dynamically. | ### Visual Debugging (Trace Viewer) @@ -79,7 +80,6 @@ When a test fails, the terminal output will provide an exact command to view the ```bash # Example: npx playwright show-trace test-results/create-application-chromium/trace.zip - ``` --- @@ -98,13 +98,12 @@ npx playwright show-trace test-results/create-application-chromium/trace.zip │ └── resource-tree.spec.ts ├── .env # Local runtime environment overrides (Git ignored) └── run-ui-tests.sh # Context-aware orchestrator & URL discovery engine - ``` ### Core Architecture Patterns -* **Global Authentication Reusability:** The .auth/setup.ts module runs first to execute the login sequence against the OpenShift cluster identity provider. It drops an authenticated session state cookie into storageState.json, allowing subsequent test specs to skip login actions entirely and save execution time. -* **Isolated SSO Specs:** Explicit UI authentication testing (such as login.spec.ts) bypasses global storage state configurations and clears active browser contexts intentionally to validate raw login screens and provider selections. +* **Global Authentication Reusability:** The `.auth/setup.ts` module runs first to execute the login sequence against the OpenShift cluster identity provider. It drops an authenticated session state cookie into `storageState.json`, allowing subsequent test specs to skip login actions entirely and save execution time. +* **Isolated SSO Specs:** Explicit UI authentication testing (such as `login.spec.ts`) bypasses global storage state configurations and clears active browser contexts intentionally to validate raw login screens and provider selections. * **Cross-Version UI Abstraction:** Selectors inside the Page Object Models are written to withstand UI layout drift between consecutive OpenShift versions by prioritizing user-facing roles and text-based assertions over brittle CSS class trees. --- @@ -113,6 +112,5 @@ npx playwright show-trace test-results/create-application-chromium/trace.zip ### Symptom: Playwright targets the wrong cluster version -* **Cause:** The wrapper script handles cross-cluster contexts dynamically. If your terminal environment variables don't match your local ~/.kube/config cache, your terminal may fall back to cached sessions. -* **Resolution:** Ensure you either run `source .env` inside your terminal window to reset active shell contexts, or verify that the variables declared within your .env file match your active target system configuration. - +* **Cause:** The wrapper script handles cross-cluster contexts dynamically. If your terminal environment variables don't match your local `~/.kube/config` cache, your terminal may fall back to cached sessions. +* **Resolution:** Ensure you either run `source .env` inside your terminal window to reset active shell contexts, or verify that the variables declared within your `.env` file match your active target system configuration. \ No newline at end of file diff --git a/test/ui-e2e/global.setup.ts b/test/ui-e2e/global.setup.ts index abad09b10e0..466a9e675bb 100644 --- a/test/ui-e2e/global.setup.ts +++ b/test/ui-e2e/global.setup.ts @@ -13,10 +13,10 @@ async function globalSetup() { execSync('oc delete all -l app=spring-petclinic -n openshift-gitops --wait=false', { stdio: 'ignore' }); console.log('* Cluster sanitized. Starting test suite.'); - } catch (error) { + } catch (error) { console.error('Pre-flight cleanup failed. Check your cluster connection.', error); throw error; - } + } } export default globalSetup; \ No newline at end of file diff --git a/test/ui-e2e/run-ui-tests.sh b/test/ui-e2e/run-ui-tests.sh index 87b684f40d2..1b59649b0a2 100755 --- a/test/ui-e2e/run-ui-tests.sh +++ b/test/ui-e2e/run-ui-tests.sh @@ -73,8 +73,8 @@ if [ -z "$GITOPS_VERSION" ]; then GITOPS_VERSION="Unknown" fi -#get Argo CD version -ARGO_API_VERSION=$(curl -s -k "$ARGOCD_URL/api/version" | grep -o '"Version":"[^"]*"' | cut -d'"' -f4) +#get Argo CD version +ARGO_API_VERSION=$(curl -s -k --max-time 10 "$ARGOCD_URL/api/version" | grep -o '"Version":"[^"]*"' | cut -d'"' -f4) if [ -z "$ARGO_API_VERSION" ]; then ARGO_API_VERSION="Unknown" fi @@ -86,11 +86,14 @@ echo " " # 2. Execute based on the environment if [ "$ENV" = "ci" ] || [ "$ENV" = "pipeline" ]; then echo "Running headlessly in automation ($ENV)..." - npm ci + + #hard-fails + npm ci || { echo "Error: npm ci failed."; exit 1; } + if [ "$(uname -s)" = "Darwin" ]; then - npx playwright install chromium + npx playwright install chromium || { echo "Error: Playwright browser install failed."; exit 1; } else - npx playwright install chromium --with-deps + npx playwright install chromium --with-deps || { echo "Error: Playwright browser install failed."; exit 1; } fi #headed from args diff --git a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts index 0bd6405d5bf..ff9a75b4d05 100644 --- a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts @@ -51,8 +51,8 @@ export class ApplicationDetailsPage { } async verifyPodLogs(expectedLogText?: string) { - //click Logs - await this.logsTab.waitFor({ state: 'visible', timeout: 5000 }); + //click Logs timeout 30s + await this.logsTab.waitFor({ state: 'visible', timeout: 30000 }); await this.logsTab.click(); const logFilterInput = this.slideOutPanel.getByPlaceholder('containing'); diff --git a/test/ui-e2e/src/pages/LoginPage.ts b/test/ui-e2e/src/pages/LoginPage.ts index a1b107c0ff1..8665c2d52a6 100644 --- a/test/ui-e2e/src/pages/LoginPage.ts +++ b/test/ui-e2e/src/pages/LoginPage.ts @@ -63,6 +63,6 @@ export class LoginPage { } //Success Checking make we land on the applications dashboard - await this.page.waitForURL('**/applications**', { timeout: 20000 }); + await this.page.waitForURL('**/applications**', { timeout: 30000 }); } } \ No newline at end of file diff --git a/test/ui-e2e/src/utils/ha-manager.ts b/test/ui-e2e/src/utils/ha-manager.ts new file mode 100644 index 00000000000..456d667b39a --- /dev/null +++ b/test/ui-e2e/src/utils/ha-manager.ts @@ -0,0 +1,87 @@ +import { execSync } from 'child_process'; + +//private helper test file doesn't need to see it +function isClusterResourceConstrained(): boolean { + if (process.env.REDUCE_HA_RESOURCES === 'true') return true; + try { + const raw = execSync('oc get nodes -l node-role.kubernetes.io/worker -o json', { stdio: 'pipe' }).toString(); + const workers = JSON.parse(raw).items || []; + if (workers.length < 3) return true; + for (const node of workers) { + const memKi = parseInt(node.status?.allocatable?.memory || '0', 10); + if (memKi > 0 && memKi < 12 * 1024 * 1024) return true; + } + } catch {} + return false; +} + +export async function enableHA() { + console.log('\n[setup] Enabling High Availability (HA) for Argo CD...'); + + const reduceResources = isClusterResourceConstrained(); + const lowRes = { requests: { cpu: '50m', memory: '64Mi' }, limits: { cpu: '250m', memory: '128Mi' } }; + const patchObj = reduceResources + ? { spec: { ha: { enabled: true, resources: lowRes }, redis: { resources: lowRes } } } + : { spec: { ha: { enabled: true } } }; + + if (reduceResources) { + console.log('[setup] Resource-constrained cluster detected. Applying reduced specs for HA patch...'); + } + + execSync(`oc patch argocd openshift-gitops -n openshift-gitops --type=merge -p '${JSON.stringify(patchObj)}'`, { stdio: 'inherit', timeout: 30000 }); + + console.log('[setup] Polling cluster for new HA deployment (this may take a few minutes)...'); + let retries = 30; + let podsReady = false; + + while (retries > 0 && !podsReady) { + try { + execSync('oc wait --for=condition=Available deployment/openshift-gitops-redis-ha-haproxy -n openshift-gitops --timeout=30s', { stdio: 'pipe' }); + podsReady = true; + } catch (e) { + console.log(`[setup] HA proxy not provisioned yet. Retrying in 10s... (${retries} attempts left)`); + await new Promise(resolve => setTimeout(resolve, 10000)); + retries--; + } + } + + if (!podsReady) throw new Error('HA proxy deployment never appeared or became available after polling.'); + + console.log('[setup] Waiting for Operator to roll out HA-aware components...'); + execSync('oc rollout status statefulset/openshift-gitops-redis-ha-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + execSync('oc rollout status deployment/openshift-gitops-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + execSync('oc rollout status deployment/openshift-gitops-dex-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + + console.log('[setup] Rollouts complete. Giving cluster time to stabilize network routes...'); + await new Promise(resolve => setTimeout(resolve, 10000)); + console.log('[setup] HA successfully enabled and stabilized.'); +} + +export async function disableHA() { + console.log('\n[teardown] Disabling High Availability (HA) to restore cluster state...'); + + //setting resources to get rid of any overrides from the setup + const disablePatch = { spec: { ha: { enabled: false, resources: null }, redis: { resources: null } } }; + execSync(`oc patch argocd openshift-gitops -n openshift-gitops --type=merge -p '${JSON.stringify(disablePatch)}'`, { stdio: 'inherit', timeout: 30000 }); + + console.log('[teardown] Waiting for Operator to begin reconciliation...'); + await new Promise(resolve => setTimeout(resolve, 15000)); + + execSync('oc wait --for=condition=Available deployment/openshift-gitops-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + execSync('oc rollout status deployment/openshift-gitops-dex-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + execSync('oc rollout status deployment/openshift-gitops-redis -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + + const waitForDelete = (resource: string) => { + try { + execSync(`oc wait --for=delete ${resource} -n openshift-gitops --timeout=300s`, { stdio: 'pipe' }); + } catch (e: any) { + if (!e.message?.includes('NotFound') && !e.stderr?.toString().includes('NotFound')) throw e; + } + }; + + waitForDelete('statefulset/openshift-gitops-redis-ha-server'); + waitForDelete('deployment/openshift-gitops-redis-ha-haproxy'); + + await new Promise(resolve => setTimeout(resolve, 5000)); + console.log('[teardown] Cluster successfully restored to non-HA state.'); +} \ No newline at end of file diff --git a/test/ui-e2e/tests/ha-login.spec.ts b/test/ui-e2e/tests/ha-login.spec.ts new file mode 100644 index 00000000000..28066c352bf --- /dev/null +++ b/test/ui-e2e/tests/ha-login.spec.ts @@ -0,0 +1,100 @@ +import { test, expect, request } from '@playwright/test'; +import { execSync } from 'child_process'; +import { LoginPage } from '../src/pages/LoginPage'; +import { enableHA, disableHA } from '../src/utils/ha-manager'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('HA Login Verification', () => { + + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeAll(async ({}, testInfo) => { + testInfo.setTimeout(600000); //10 mins for ha rollout + try { + await enableHA(); + } catch (error) { + console.error('[setup] Failed to enable HA. Aborting tests.', error); + throw error; + } + }); + + test.afterAll(async ({}, testInfo) => { + testInfo.setTimeout(300000); //5 mins for teardown + try { + await disableHA(); + + console.log('[teardown] Rollout complete. Waiting 15s for router to drop terminating HA pods...'); + await new Promise(resolve => setTimeout(resolve, 15000)); + + console.log('[teardown] Polling OpenShift Route via Playwright until it returns 200 OK...'); + + //get route url + const routeHost = execSync(`oc get route openshift-gitops-server -n openshift-gitops -o jsonpath='{.spec.host}'`).toString().trim(); + + //create api context ignoring self-signed certs + const apiContext = await request.newContext({ ignoreHTTPSErrors: true }); + + //poll ui, auth, api, and callback routes with cache-busting to prevent 503s + await expect(async () => { + const cb = Date.now(); //cache buster to force fresh network connections + + const uiResponse = await apiContext.get(`https://${routeHost}/?cb=${cb}`); + const authResponse = await apiContext.get(`https://${routeHost}/auth/login?cb=${cb}`); + const apiResponse = await apiContext.get(`https://${routeHost}/api/version?cb=${cb}`); + const callbackResponse = await apiContext.get(`https://${routeHost}/api/dex/callback?cb=${cb}`); + + //check for 2xx status codes + expect(uiResponse.ok()).toBeTruthy(); + expect(authResponse.ok()).toBeTruthy(); + expect(apiResponse.ok()).toBeTruthy(); + + //callback will return 400 without a token, just ensure it isn't 503 + expect(callbackResponse.status()).not.toBe(503); + }).toPass({ timeout: 60000 }); + + console.log('[teardown] Routing stabilized. Ready for the next test suite.'); + + } catch (error) { + console.error('[teardown] Failed to disable HA. Cluster may be in a dirty state.', error); + throw error; + } + }); + + test('Local Admin Login under HA', async ({ page }) => { + test.setTimeout(120000); + + let rawOutput = execSync('oc extract secret/openshift-gitops-cluster -n openshift-gitops --keys=admin.password --to=-', { timeout: 30000 }).toString(); + const adminPassword = rawOutput.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))[0]; + + if (!adminPassword) { + throw new Error('failed to extract admin password from cluster secret'); + } + + await page.goto('/login?dex=none', { waitUntil: 'load' }); + const userField = page.getByLabel(/username/i); + await userField.waitFor({ state: 'visible', timeout: 30000 }); + + await userField.fill('admin'); + await page.locator('input[type="password"]').fill(adminPassword); + await page.getByRole('button', { name: /sign in/i }).click(); + + await expect(page.getByText('Applications', { exact: true }).first()).toBeVisible({ timeout: 30000 }); + }); + + test('OpenShift SSO Login under HA', async ({ page }) => { + test.setTimeout(120000); + + const loginPage = new LoginPage(page); + await loginPage.goto(); + + await loginPage.loginViaOpenShift( + process.env.CLUSTER_USER!, + process.env.CLUSTER_PASSWORD!, + process.env.IDP || 'kube:admin' + ); + + await expect(page.getByText('Applications', { exact: true }).first()).toBeVisible({ timeout: 30000 }); + }); + +}); \ No newline at end of file diff --git a/test/ui-e2e/tests/resource-tree.spec.ts b/test/ui-e2e/tests/resource-tree.spec.ts index e262fa9b221..4123e396f0b 100644 --- a/test/ui-e2e/tests/resource-tree.spec.ts +++ b/test/ui-e2e/tests/resource-tree.spec.ts @@ -6,7 +6,7 @@ test.describe('Argo CD Resource Tree and Pod Logs', () => { test.use({ storageState: '.auth/storageState.json' }); - test('Navigate to app details, open a Pod, and verify logs stream', async ({ page, managedApp, argoVersion }) => { + test('Navigate to app details, open a Pod, and verify logs stream', async ({ page, managedApp }) => { test.setTimeout(120000); const appsPage = new ApplicationsPage(page);