From efde7898dedbc4927bf30bb26be351ef641abc72 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 22 Apr 2026 13:23:02 +0100 Subject: [PATCH 1/4] Set up Playwright E2E foundation and automate the Argo CD SSO authentication Signed-off-by: Triona Doyle --- .gitignore | 1 + test/ui-e2e/.auth/setup.ts | 69 ++++++++++++++++++ test/ui-e2e/.gitignore | 10 +++ test/ui-e2e/README.md | 65 +++++++++++++++++ test/ui-e2e/package-lock.json | 111 +++++++++++++++++++++++++++++ test/ui-e2e/package.json | 15 ++++ test/ui-e2e/playwright.config.ts | 77 ++++++++++++++++++++ test/ui-e2e/run-ui-tests.sh | 41 +++++++++++ test/ui-e2e/src/pages/LoginPage.ts | 65 +++++++++++++++++ test/ui-e2e/tests/login.spec.ts | 24 +++++++ test/ui-e2e/tsconfig.json | 11 +++ 11 files changed, 489 insertions(+) create mode 100644 test/ui-e2e/.auth/setup.ts create mode 100644 test/ui-e2e/.gitignore create mode 100644 test/ui-e2e/README.md create mode 100644 test/ui-e2e/package-lock.json create mode 100644 test/ui-e2e/package.json create mode 100644 test/ui-e2e/playwright.config.ts create mode 100755 test/ui-e2e/run-ui-tests.sh create mode 100644 test/ui-e2e/src/pages/LoginPage.ts create mode 100644 test/ui-e2e/tests/login.spec.ts create mode 100644 test/ui-e2e/tsconfig.json diff --git a/.gitignore b/.gitignore index 9d4ef10b514..e80e6f6fe5f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ kuttl-test.json # ignore vendor vendor/ .vscode/ +.DS_Store diff --git a/test/ui-e2e/.auth/setup.ts b/test/ui-e2e/.auth/setup.ts new file mode 100644 index 00000000000..4d01ff0f3d6 --- /dev/null +++ b/test/ui-e2e/.auth/setup.ts @@ -0,0 +1,69 @@ +import { test as setup } from '@playwright/test'; + +const authFile = '.auth/storageState.json'; + +setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { + // 1. Navigate to the OpenShift Console + // It checks Playwright's config (baseURL) first, then falls back to environment variables + const targetUrl = baseURL || process.env.CONSOLE_URL || process.env.BASE_URL; + + if (!targetUrl) { + throw new Error("No Console URL provided! Ensure your bash script exports BASE_URL or CONSOLE_URL."); + } + + console.log(`Navigating to OpenShift Console: ${targetUrl}`); + await page.goto(targetUrl); // <-- THIS WAS THE MISSING LINK! + + // 2. Define our locators flexibly + const idpScreenText = page.getByText(/Log in with/i); + const usernameInput = page.getByLabel(/Username/i) + .or(page.locator('input[name="username"]')) + .or(page.getByPlaceholder(/Username/i)); + + // 3. Wait for EITHER the IDP screen OR the Username field to appear + try { + await Promise.race([ + idpScreenText.waitFor({ state: 'visible', timeout: 15000 }), + usernameInput.waitFor({ state: 'visible', timeout: 15000 }) + ]); + } catch (e) { + console.log("Timed out waiting for OpenShift login page to render."); + } + + // Set a default user to prevent undefined errors if you forget to export it + const user = process.env.CLUSTER_USER || 'kubeadmin'; + + // 4. Handle the IDP Screen if it exists + if (await idpScreenText.isVisible()) { + console.log("IDP selection screen detected. Selecting provider..."); + + // Decide which IDP to click based on the user + const idpRegex = (user === 'kubeadmin') ? /kube:admin/i : /htpasswd/i; + + // OpenShift IDPs are usually links styled as buttons + await page.getByRole('link', { name: idpRegex }).click(); + } else { + console.log("No IDP screen detected, proceeding directly to credentials..."); + } + + // 5. Fill in Cluster Credentials + await usernameInput.waitFor({ state: 'visible', timeout: 10000 }); + await usernameInput.fill(user); // Using the fallback variable defined above + + const passwordInput = page.getByLabel(/Password/i) + .or(page.locator('input[name="password"]')) + .or(page.getByPlaceholder(/Password/i)); + + // Assert that password exists so we don't accidentally type 'undefined' + if (!process.env.CLUSTER_PASSWORD) { + throw new Error("CLUSTER_PASSWORD is not set in the environment!"); + } + + await passwordInput.fill(process.env.CLUSTER_PASSWORD); + await page.getByRole('button', { name: /Log in/i }).click(); + + // 6. Save this pure OpenShift auth state + // Added a brief wait to ensure login completes before saving state + await page.waitForLoadState('networkidle'); + await page.context().storageState({ path: authFile }); +}); \ No newline at end of file diff --git a/test/ui-e2e/.gitignore b/test/ui-e2e/.gitignore new file mode 100644 index 00000000000..0c639e7d929 --- /dev/null +++ b/test/ui-e2e/.gitignore @@ -0,0 +1,10 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ +.auth/storageState.json +.env diff --git a/test/ui-e2e/README.md b/test/ui-e2e/README.md new file mode 100644 index 00000000000..339f22e8f16 --- /dev/null +++ b/test/ui-e2e/README.md @@ -0,0 +1,65 @@ +# GitOps Operator - UI End-to-End Tests + +This suite validates the OpenShift GitOps Operator UI, focusing on Argo CD and SSO integration. + +## Prerequisites +1. **Node.js** (v18+) +2. **OpenShift CLI (oc)**: Installed and in your PATH. +3. **Install Dependencies:** Navigate to this directory and install required packages: + ```bash + cd test/ui-e2e + npm install + npx playwright install chromium + ``` + +## Environment Variables +You must provide cluster credentials before running tests. You can either `export` these in your terminal (or pipeline), or create a `.env` file in the `test/ui-e2e` directory: + +```text +# .env file example +CLUSTER_PASSWORD=your_openshift_admin_password +OC_API_URL=[https://api.cluster.com:6443](https://api.cluster.com:6443) +CLUSTER_USER=kubeadmin # (Optional) Defaults to kubeadmin +IDP=kube:admin # (Optional) Defaults to kube:admin +``` + +## Execution Commands + +All commands use the `./run-ui-tests.sh` wrapper which handles auth, OpenShift token generation, and URL discovery. **Ensure you are in the `test/ui-e2e` directory.** + +**Run All Tests (Headless):** +```bash +./run-ui-tests.sh --project=chromium +``` + +**Run All Tests (Headed + Trace):** +```bash +./run-ui-tests.sh --project=chromium --headed --reporter=list --trace on +``` + +**Run Single Test (Headed + Trace):** +```bash +./run-ui-tests.sh tests/login.spec.ts --project=chromium --headed --trace on +``` + +**View Trace Results:** +```bash +npx playwright show-trace test-results/**/*/trace.zip +``` + +** Helpful Flags Explained** +* `--headed`: Runs tests in a visible browser. Without this, tests run in "headless" mode (invisible background). +* `--reporter=list`: Changes console output to a clean, line-by-line list so you can see exactly which test is running in real-time. +* `--trace on`: Captures a full "recording" (DOM snapshots, network, actions) of the test for debugging. + +## Architecture + +**Global Setup:** +`.auth/setup.ts` logs into the OCP console to generate a reusable session (`storageState.json`). This prevents having to log in repeatedly for every test file. + +**Spec Isolation:** +`login.spec.ts` explicitly clears session cookies to force a full SSO UI validation from a fresh state. + +## Troubleshooting + +* **"Invalid login or password" during automated login:** If you are testing against multiple clusters sequentially, your terminal's `oc` CLI might be holding onto a sticky session from an older cluster. Run `oc logout` before running the bash script to force a clean authentication. \ No newline at end of file diff --git a/test/ui-e2e/package-lock.json b/test/ui-e2e/package-lock.json new file mode 100644 index 00000000000..09ea870fb02 --- /dev/null +++ b/test/ui-e2e/package-lock.json @@ -0,0 +1,111 @@ +{ + "name": "ui-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui-e2e", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.59.1", + "@types/node": "^25.6.0", + "dotenv": "^17.4.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/test/ui-e2e/package.json b/test/ui-e2e/package.json new file mode 100644 index 00000000000..eaa1d6957cc --- /dev/null +++ b/test/ui-e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "ui-e2e", + "version": "1.0.0", + "main": "index.js", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@playwright/test": "^1.59.1", + "@types/node": "^25.6.0", + "dotenv": "^17.4.2" + } +} diff --git a/test/ui-e2e/playwright.config.ts b/test/ui-e2e/playwright.config.ts new file mode 100644 index 00000000000..a1c958d6deb --- /dev/null +++ b/test/ui-e2e/playwright.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ + +// top of playwright.config.ts +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['list'], + ['html', { open: process.env.CI ? 'never' : 'on-failure' }] + ], + +/* GLOBAL FOUNDATION: These apply to everything */ + use: { + baseURL: process.env.ARGOCD_URL, + ignoreHTTPSErrors: true, + trace: 'on-first-retry', + }, + + /* Configure for major browsers */ + projects: [ + { + name: 'setup', + testDir: './', + testMatch: '**/.auth/setup.ts', + /* Only changes the URL for this specific project */ + use: { + baseURL: process.env.CONSOLE_URL, }, + }, + + // Update chromium project + { + name: 'chromium', + dependencies: ['setup'], + use: { + ...devices['Desktop Chrome'], + storageState: '.auth/storageState.json', + // project still has ignoreHTTPSErrors: true from above + }, + }, + + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + // storageState and dependencies here later if we want to run Firefox tests but for now just focus on Chromium + }, + }, + // ... webkit etc ... + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/test/ui-e2e/run-ui-tests.sh b/test/ui-e2e/run-ui-tests.sh new file mode 100755 index 00000000000..1e14b4c838b --- /dev/null +++ b/test/ui-e2e/run-ui-tests.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +#making sure we are in the correct dir +cd "$(dirname "$0")" || exit 1 + +# username (might be something different for rosa - can be overwritten with export CLUSTER_USER) +export CLUSTER_USER=${CLUSTER_USER:-"kubeadmin"} +export IDP=${IDP:-"kube:admin"} + +#check auth state first +echo "Checking cluster authentication..." +if ! oc whoami > /dev/null 2>&1; then + if [ -n "$OC_API_URL" ] && [ -n "$CLUSTER_PASSWORD" ]; then + echo "Attempting automated login..." + oc login "$OC_API_URL" -u "$CLUSTER_USER" -p "$CLUSTER_PASSWORD" --insecure-skip-tls-verify=true + else + echo "Error: Not logged in. Missing OC_API_URL or CLUSTER_PASSWORD." + exit 1 + fi +fi + +#find the URLs for console and argocd +echo "🔍 Discovering component URLs..." +export ARGOCD_URL=$(oc get route openshift-gitops-server -n openshift-gitops -o jsonpath='{"https://"}{.spec.host}') +export CONSOLE_URL=$(oc whoami --show-console) + +if [ -z "$ARGOCD_URL" ] || [ -z "$CONSOLE_URL" ]; then + echo "Error: Could not find Argo CD or Console routes." + exit 1 +fi + +echo "OpenShift Console: $CONSOLE_URL" +echo " Argo CD UI: $ARGOCD_URL" + +#clean up any old Playwright state +echo "Getting rid of any old browser sessions..." +rm -f .auth/storageState.json || true + +#run Playwright +echo " Starting Playwright tests..." +npx playwright test "$@" \ No newline at end of file diff --git a/test/ui-e2e/src/pages/LoginPage.ts b/test/ui-e2e/src/pages/LoginPage.ts new file mode 100644 index 00000000000..2f5831b65c7 --- /dev/null +++ b/test/ui-e2e/src/pages/LoginPage.ts @@ -0,0 +1,65 @@ +import { Page, expect } from '@playwright/test'; + +export class LoginPage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async goto() { + await this.page.goto('/'); + } + + async loginViaOpenShift(user: string, pass: string, idp: string = 'kube:admin') { + + // ====================================================== + // Argo CD Login Screen + // ====================================================== + // We just wait patiently. Even if Argo CD does its weird + // redirect dance for 2 seconds, Playwright will wait up to + // 10 seconds for this button to finally appear. + const ssoButton = this.page.getByText(/LOG IN VIA OPENSHIFT/i); + await ssoButton.waitFor({ state: 'visible', timeout: 10000 }); + await ssoButton.click(); + + // ====================================================== + // OpenShift Login (with optional IDP step) + // ====================================================== + // Sometimes OpenShift asks which IDP to use. We check for it quickly. + try { + const idpButton = this.page.getByText(idp, { exact: true }); + // Only wait 3 seconds. If it's not there, OpenShift skipped straight to the form. + await idpButton.waitFor({ state: 'visible', timeout: 3000 }); + await idpButton.click(); + console.log(`Clicked IDP: ${idp}`); + } catch (e) { + console.log('No IDP selection screen, proceeding to credentials form...'); + } + + // Now fill out the actual Username/Password form + await this.page.getByLabel(/Username/i).waitFor({ state: 'visible' }); + await this.page.getByLabel(/Username/i).fill(user); + await this.page.getByLabel(/Password/i).fill(pass); + await this.page.getByRole('button', { name: /Log in/i }).click(); + + // ====================================================== + // Authorize Access (First Login Only) + // ====================================================== + try { + const allowButton = this.page.getByRole('button', { name: 'Allow selected permissions' }); + // Wait 5 seconds. If it's not there, we've already authorized in the past. + await allowButton.waitFor({ state: 'visible', timeout: 5000 }); + await allowButton.click(); + console.log('Clicked Authorize Access button.'); + } catch (error) { + console.log('No Authorize screen appeared, continuing...'); + } + + // ====================================================== + // Argo CD Dashboard (Success) + // ====================================================== + // Wait for the URL to change to the applications dashboard + await this.page.waitForURL('**/applications**', { timeout: 20000 }); + } +} \ No newline at end of file diff --git a/test/ui-e2e/tests/login.spec.ts b/test/ui-e2e/tests/login.spec.ts new file mode 100644 index 00000000000..623a6ade71f --- /dev/null +++ b/test/ui-e2e/tests/login.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../src/pages/LoginPage'; + +test.describe('Authentication Flow', () => { + + // don't not use the saved login state + // make sure we always get to the argo login screen, even after setup.ts already ran. + test.use({ storageState: { cookies: [], origins: [] } }); + + //login via openshift + test('Scenario: Successful OpenShift SSO Login', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + + await loginPage.loginViaOpenShift( + process.env.CLUSTER_USER!, + process.env.CLUSTER_PASSWORD!, + process.env.IDP + ); + + const newAppButton = page.getByRole('button', { name: /NEW APP/i }); + await expect(newAppButton).toBeVisible({ timeout: 15000 }); + }); +}); \ No newline at end of file diff --git a/test/ui-e2e/tsconfig.json b/test/ui-e2e/tsconfig.json new file mode 100644 index 00000000000..9d4d781ca99 --- /dev/null +++ b/test/ui-e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "node16", + "moduleResolution": "node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["**/*.ts", ".auth/**/*.ts"]} \ No newline at end of file From bf41766eff55228e686552a08a73ab19ee779468 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Tue, 28 Apr 2026 13:11:01 +0100 Subject: [PATCH 2/4] Add login via local credentials test Signed-off-by: Triona Doyle --- test/ui-e2e/tests/admin-login.spec.ts | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 test/ui-e2e/tests/admin-login.spec.ts diff --git a/test/ui-e2e/tests/admin-login.spec.ts b/test/ui-e2e/tests/admin-login.spec.ts new file mode 100644 index 00000000000..51d7d954077 --- /dev/null +++ b/test/ui-e2e/tests/admin-login.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'node:child_process'; + +test('Log into Argo CD as local admin', async ({ browser }) => { + const rawOutput = execSync( + 'oc extract secret/openshift-gitops-cluster -n openshift-gitops --keys=admin.password --to=-' + ).toString(); + + //get credentials + const password = rawOutput.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))[0]; + + const routeUrl = execSync( + 'oc get route openshift-gitops-server -n openshift-gitops -o jsonpath="{.spec.host}"' + ).toString().trim(); + + //Fresh context to avoid any cached state issues + const context = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + ignoreHTTPSErrors: true + }); + + //Navigate and wait for the page to be loaded + const page = await context.newPage(); + const loginUrl = `https://${routeUrl}/login?dex=none`; + await page.goto(loginUrl, { waitUntil: 'load' }); + + const userField = page.getByRole('textbox').first(); + await userField.waitFor({ state: 'visible', timeout: 20000 }); + + //Fill and Sign In + await userField.fill('admin'); + await page.locator('input[type="password"]').fill(password); + await page.getByRole('button', { name: /sign in/i }).click(); + + //Verify we're logged in + await expect(page.locator('.sidebar, [data-testid="sidebar"]').first()).toBeVisible({ timeout: 20000 }); + + await context.close(); +}); \ No newline at end of file From 23c8791a6ff7fc89536cf3ffba8a4eaefabb2763 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 22 Apr 2026 13:23:02 +0100 Subject: [PATCH 3/4] Set up Playwright E2E foundation and automate the Argo CD SSO authentication Signed-off-by: Triona Doyle --- .gitignore | 1 + test/ui-e2e/.auth/setup.ts | 65 +++++++++++++++++ test/ui-e2e/.gitignore | 10 +++ test/ui-e2e/README.md | 65 +++++++++++++++++ test/ui-e2e/package-lock.json | 111 +++++++++++++++++++++++++++++ test/ui-e2e/package.json | 15 ++++ test/ui-e2e/playwright.config.ts | 77 ++++++++++++++++++++ test/ui-e2e/run-ui-tests.sh | 41 +++++++++++ test/ui-e2e/src/pages/LoginPage.ts | 48 +++++++++++++ test/ui-e2e/tests/login.spec.ts | 23 ++++++ test/ui-e2e/tsconfig.json | 11 +++ 11 files changed, 467 insertions(+) create mode 100644 test/ui-e2e/.auth/setup.ts create mode 100644 test/ui-e2e/.gitignore create mode 100644 test/ui-e2e/README.md create mode 100644 test/ui-e2e/package-lock.json create mode 100644 test/ui-e2e/package.json create mode 100644 test/ui-e2e/playwright.config.ts create mode 100755 test/ui-e2e/run-ui-tests.sh create mode 100644 test/ui-e2e/src/pages/LoginPage.ts create mode 100644 test/ui-e2e/tests/login.spec.ts create mode 100644 test/ui-e2e/tsconfig.json diff --git a/.gitignore b/.gitignore index 9d4ef10b514..e80e6f6fe5f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ kuttl-test.json # ignore vendor vendor/ .vscode/ +.DS_Store diff --git a/test/ui-e2e/.auth/setup.ts b/test/ui-e2e/.auth/setup.ts new file mode 100644 index 00000000000..eda19db648f --- /dev/null +++ b/test/ui-e2e/.auth/setup.ts @@ -0,0 +1,65 @@ +import { test as setup } from '@playwright/test'; + +const authFile = '.auth/storageState.json'; + +setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { + // Navigate to the OpenShift Console + const targetUrl = baseURL || process.env.CONSOLE_URL || process.env.BASE_URL; + + if (!targetUrl) { + throw new Error("No Console URL provided! Ensure your bash script exports BASE_URL or CONSOLE_URL."); + } + + console.log(`Navigating to OpenShift Console: ${targetUrl}`); + await page.goto(targetUrl); + + //set locators + const idpScreenText = page.getByText(/Log in with/i); + const usernameInput = page.getByLabel(/Username/i) + .or(page.locator('input[name="username"]')) + .or(page.getByPlaceholder(/Username/i)); + + //wait for the IDP screen OR the Username field to appear + try { + await Promise.race([ + idpScreenText.waitFor({ state: 'visible', timeout: 15000 }), + usernameInput.waitFor({ state: 'visible', timeout: 15000 }) + ]); + } catch (e) { + console.log("Timed out waiting for OpenShift login page to render."); + } + + const idpName = process.env.IDP || 'kube:admin'; + const user = process.env.CLUSTER_USER || 'kubeadmin'; + + if (await idpScreenText.isVisible()) { + console.log(`IDP selection screen detected. Selecting provider: "${idpName}"`); + + // look for the specific IDP + const idpLink = page.getByRole('link', { name: new RegExp(idpName, 'i') }); + + await idpLink.waitFor({ state: 'visible', timeout: 5000 }); + await idpLink.click(); + } else { + console.log("No IDP screen detected (or already selected), proceeding to credentials..."); + } + + // fill in the Credentials + await usernameInput.waitFor({ state: 'visible', timeout: 10000 }); + await usernameInput.fill(user); + + const passwordInput = page.getByLabel(/Password/i) + .or(page.locator('input[name="password"]')) + .or(page.getByPlaceholder(/Password/i)); + + if (!process.env.CLUSTER_PASSWORD) { + throw new Error("CLUSTER_PASSWORD is not set in the environment!"); + } + + await passwordInput.fill(process.env.CLUSTER_PASSWORD); + await page.getByRole('button', { name: /Log in/i }).click(); + + //save the auth state + await page.waitForLoadState('networkidle'); + await page.context().storageState({ path: authFile }); +}); \ No newline at end of file diff --git a/test/ui-e2e/.gitignore b/test/ui-e2e/.gitignore new file mode 100644 index 00000000000..0c639e7d929 --- /dev/null +++ b/test/ui-e2e/.gitignore @@ -0,0 +1,10 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ +.auth/storageState.json +.env diff --git a/test/ui-e2e/README.md b/test/ui-e2e/README.md new file mode 100644 index 00000000000..339f22e8f16 --- /dev/null +++ b/test/ui-e2e/README.md @@ -0,0 +1,65 @@ +# GitOps Operator - UI End-to-End Tests + +This suite validates the OpenShift GitOps Operator UI, focusing on Argo CD and SSO integration. + +## Prerequisites +1. **Node.js** (v18+) +2. **OpenShift CLI (oc)**: Installed and in your PATH. +3. **Install Dependencies:** Navigate to this directory and install required packages: + ```bash + cd test/ui-e2e + npm install + npx playwright install chromium + ``` + +## Environment Variables +You must provide cluster credentials before running tests. You can either `export` these in your terminal (or pipeline), or create a `.env` file in the `test/ui-e2e` directory: + +```text +# .env file example +CLUSTER_PASSWORD=your_openshift_admin_password +OC_API_URL=[https://api.cluster.com:6443](https://api.cluster.com:6443) +CLUSTER_USER=kubeadmin # (Optional) Defaults to kubeadmin +IDP=kube:admin # (Optional) Defaults to kube:admin +``` + +## Execution Commands + +All commands use the `./run-ui-tests.sh` wrapper which handles auth, OpenShift token generation, and URL discovery. **Ensure you are in the `test/ui-e2e` directory.** + +**Run All Tests (Headless):** +```bash +./run-ui-tests.sh --project=chromium +``` + +**Run All Tests (Headed + Trace):** +```bash +./run-ui-tests.sh --project=chromium --headed --reporter=list --trace on +``` + +**Run Single Test (Headed + Trace):** +```bash +./run-ui-tests.sh tests/login.spec.ts --project=chromium --headed --trace on +``` + +**View Trace Results:** +```bash +npx playwright show-trace test-results/**/*/trace.zip +``` + +** Helpful Flags Explained** +* `--headed`: Runs tests in a visible browser. Without this, tests run in "headless" mode (invisible background). +* `--reporter=list`: Changes console output to a clean, line-by-line list so you can see exactly which test is running in real-time. +* `--trace on`: Captures a full "recording" (DOM snapshots, network, actions) of the test for debugging. + +## Architecture + +**Global Setup:** +`.auth/setup.ts` logs into the OCP console to generate a reusable session (`storageState.json`). This prevents having to log in repeatedly for every test file. + +**Spec Isolation:** +`login.spec.ts` explicitly clears session cookies to force a full SSO UI validation from a fresh state. + +## Troubleshooting + +* **"Invalid login or password" during automated login:** If you are testing against multiple clusters sequentially, your terminal's `oc` CLI might be holding onto a sticky session from an older cluster. Run `oc logout` before running the bash script to force a clean authentication. \ No newline at end of file diff --git a/test/ui-e2e/package-lock.json b/test/ui-e2e/package-lock.json new file mode 100644 index 00000000000..09ea870fb02 --- /dev/null +++ b/test/ui-e2e/package-lock.json @@ -0,0 +1,111 @@ +{ + "name": "ui-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui-e2e", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.59.1", + "@types/node": "^25.6.0", + "dotenv": "^17.4.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/test/ui-e2e/package.json b/test/ui-e2e/package.json new file mode 100644 index 00000000000..eaa1d6957cc --- /dev/null +++ b/test/ui-e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "ui-e2e", + "version": "1.0.0", + "main": "index.js", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@playwright/test": "^1.59.1", + "@types/node": "^25.6.0", + "dotenv": "^17.4.2" + } +} diff --git a/test/ui-e2e/playwright.config.ts b/test/ui-e2e/playwright.config.ts new file mode 100644 index 00000000000..a1c958d6deb --- /dev/null +++ b/test/ui-e2e/playwright.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ + +// top of playwright.config.ts +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['list'], + ['html', { open: process.env.CI ? 'never' : 'on-failure' }] + ], + +/* GLOBAL FOUNDATION: These apply to everything */ + use: { + baseURL: process.env.ARGOCD_URL, + ignoreHTTPSErrors: true, + trace: 'on-first-retry', + }, + + /* Configure for major browsers */ + projects: [ + { + name: 'setup', + testDir: './', + testMatch: '**/.auth/setup.ts', + /* Only changes the URL for this specific project */ + use: { + baseURL: process.env.CONSOLE_URL, }, + }, + + // Update chromium project + { + name: 'chromium', + dependencies: ['setup'], + use: { + ...devices['Desktop Chrome'], + storageState: '.auth/storageState.json', + // project still has ignoreHTTPSErrors: true from above + }, + }, + + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + // storageState and dependencies here later if we want to run Firefox tests but for now just focus on Chromium + }, + }, + // ... webkit etc ... + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/test/ui-e2e/run-ui-tests.sh b/test/ui-e2e/run-ui-tests.sh new file mode 100755 index 00000000000..dee6a4d5fba --- /dev/null +++ b/test/ui-e2e/run-ui-tests.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +#making sure we are in the correct dir +cd "$(dirname "$0")" || exit 1 + +# username (might be something different for rosa - can be overwritten with export CLUSTER_USER) +export CLUSTER_USER=${CLUSTER_USER:-"kubeadmin"} +export IDP=${IDP:-"kube:admin"} + +#check auth state first +echo "Checking cluster authentication..." +if ! oc whoami > /dev/null 2>&1; then + if [ -n "$OC_API_URL" ] && [ -n "$CLUSTER_PASSWORD" ]; then + echo "Attempting automated login..." + oc login "$OC_API_URL" -u "$CLUSTER_USER" -p "$CLUSTER_PASSWORD" --insecure-skip-tls-verify=true + else + echo "Error: Not logged in. Missing OC_API_URL or CLUSTER_PASSWORD." + exit 1 + fi +fi + +#find the URLs for console and argocd +echo "Discovering component URLs..." +export ARGOCD_URL=$(oc get route openshift-gitops-server -n openshift-gitops -o jsonpath='{"https://"}{.spec.host}') +export CONSOLE_URL=$(oc whoami --show-console) + +if [ -z "$ARGOCD_URL" ] || [ -z "$CONSOLE_URL" ]; then + echo "Error: Could not find Argo CD or Console routes." + exit 1 +fi + +echo "OpenShift Console: $CONSOLE_URL" +echo " Argo CD UI: $ARGOCD_URL" + +#clean up any old Playwright state +echo "Getting rid of any old browser sessions..." +rm -f .auth/storageState.json || true + +#run Playwright +echo " Starting Playwright tests..." +npx playwright test "$@" \ No newline at end of file diff --git a/test/ui-e2e/src/pages/LoginPage.ts b/test/ui-e2e/src/pages/LoginPage.ts new file mode 100644 index 00000000000..7fc8728560b --- /dev/null +++ b/test/ui-e2e/src/pages/LoginPage.ts @@ -0,0 +1,48 @@ +import { Page } from '@playwright/test'; + +export class LoginPage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async goto() { + //navigate to the baseURL defined in playwright.config.ts + await this.page.goto('/'); + } + + async loginViaOpenShift(user: string, pass: string, idp: string = 'kube:admin') { + //click the SSO button on the Argo CD landing page + const ssoButton = this.page.getByText(/LOG IN VIA OPENSHIFT/i); + await ssoButton.waitFor({ state: 'visible', timeout: 10000 }); + await ssoButton.click(); + + //handle the OpenShift IDP selection screen if it appears + try { + const idpButton = this.page.getByText(idp, { exact: true }); + await idpButton.waitFor({ state: 'visible', timeout: 3000 }); + await idpButton.click(); + } catch (e) { + //if it's not there then OpenShift likely defaulted to another + } + + //fil out the OpenShift credentials + await this.page.getByLabel(/Username/i).waitFor({ state: 'visible' }); + await this.page.getByLabel(/Username/i).fill(user); + await this.page.getByLabel(/Password/i).fill(pass); + await this.page.getByRole('button', { name: /Log in/i }).click(); + + //Auth Handle the Allow Permissions screen + try { + const allowButton = this.page.getByRole('button', { name: /Allow selected permissions/i }); + await allowButton.waitFor({ state: 'visible', timeout: 5000 }); + await allowButton.click(); + } catch (error) { + // Screen didn't appear likely already authorised. + } + + //Success Checking make we land on the applications dashboard + await this.page.waitForURL('**/applications**', { timeout: 20000 }); + } +} \ No newline at end of file diff --git a/test/ui-e2e/tests/login.spec.ts b/test/ui-e2e/tests/login.spec.ts new file mode 100644 index 00000000000..57051cda6d3 --- /dev/null +++ b/test/ui-e2e/tests/login.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../src/pages/LoginPage'; + +test.describe('Argo CD SSO Authentication', () => { + + //clear storageState to force a full login flow for this specific test + test.use({ storageState: { cookies: [], origins: [] } }); + + test('should successfully log in via OpenShift SSO', async ({ page }) => { + const loginPage = new LoginPage(page); + + await loginPage.goto(); + + await loginPage.loginViaOpenShift( + process.env.CLUSTER_USER!, + process.env.CLUSTER_PASSWORD!, + process.env.IDP || 'kube:admin' + ); + + //Check the button is visible as proof of successful login + await expect(page.getByRole('button', { name: /NEW APP/i })).toBeVisible({ timeout: 15000 }); + }); +}); \ No newline at end of file diff --git a/test/ui-e2e/tsconfig.json b/test/ui-e2e/tsconfig.json new file mode 100644 index 00000000000..9d4d781ca99 --- /dev/null +++ b/test/ui-e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "node16", + "moduleResolution": "node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["**/*.ts", ".auth/**/*.ts"]} \ No newline at end of file From 69485bad490886065b5bf50a0ae489c602936c82 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 29 Apr 2026 08:14:01 +0100 Subject: [PATCH 4/4] fix: load .env variables in bash script for CI/CD compatibility Signed-off-by: Triona Doyle --- test/ui-e2e/run-ui-tests.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/ui-e2e/run-ui-tests.sh b/test/ui-e2e/run-ui-tests.sh index dee6a4d5fba..5aa9f582cb7 100755 --- a/test/ui-e2e/run-ui-tests.sh +++ b/test/ui-e2e/run-ui-tests.sh @@ -1,5 +1,12 @@ #!/bin/bash +if [ -f .env ]; then + echo "Loading variables from .env file..." + set -a #export all variables + source .env + set +a # stop automatically exporting +fi + #making sure we are in the correct dir cd "$(dirname "$0")" || exit 1