Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions test/ui-e2e/.auth/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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.');
Expand Down
32 changes: 15 additions & 17 deletions test/ui-e2e/README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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

```

---
Expand All @@ -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 <<EOF > .env
cat <<EOF> .env
export CLUSTER_USER="kubeadmin"
export CLUSTER_PASSWORD="<your_cluster_password>"
export OC_API_URL="<your_cluster_server_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=<ci\|pipeline> | Overrides the local setup to simulate automation. It forces headless execution, performs a clean `npm ci`, and installs required browser binaries dynamically. |
| `--env=<ci\|pipeline>` | 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)

Expand All @@ -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

```

---
Expand All @@ -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.

---
Expand All @@ -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.
4 changes: 2 additions & 2 deletions test/ui-e2e/global.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
13 changes: 8 additions & 5 deletions test/ui-e2e/run-ui-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions test/ui-e2e/src/pages/ApplicationDetailsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion test/ui-e2e/src/pages/LoginPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
87 changes: 87 additions & 0 deletions test/ui-e2e/src/utils/ha-manager.ts
Original file line number Diff line number Diff line change
@@ -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.');
}
100 changes: 100 additions & 0 deletions test/ui-e2e/tests/ha-login.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});

});
2 changes: 1 addition & 1 deletion test/ui-e2e/tests/resource-tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading