From b491d88a94a3eca04a09c0911ce46315ba252c2c Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Thu, 28 May 2026 15:02:04 +0100 Subject: [PATCH 1/6] GITOPS-9682 UI E2E Create App test Signed-off-by: Triona Doyle --- test/ui-e2e/.auth/setup.ts | 24 ++-- test/ui-e2e/README.md | 134 +++++++++++++------ test/ui-e2e/run-ui-tests.sh | 22 ++- test/ui-e2e/src/fixtures.ts | 54 ++++++++ test/ui-e2e/src/pages/ApplicationsPage.ts | 118 ++++++++++++++++ test/ui-e2e/src/pages/LoginPage.ts | 32 +++-- test/ui-e2e/tests/admin-login.spec.ts | 59 ++++++++ test/ui-e2e/tests/create-application.spec.ts | 41 ++++++ test/ui-e2e/tests/login.spec.ts | 4 + 9 files changed, 417 insertions(+), 71 deletions(-) create mode 100644 test/ui-e2e/src/fixtures.ts create mode 100644 test/ui-e2e/src/pages/ApplicationsPage.ts create mode 100644 test/ui-e2e/tests/admin-login.spec.ts create mode 100644 test/ui-e2e/tests/create-application.spec.ts diff --git a/test/ui-e2e/.auth/setup.ts b/test/ui-e2e/.auth/setup.ts index eda19db648f..59dbecefb39 100644 --- a/test/ui-e2e/.auth/setup.ts +++ b/test/ui-e2e/.auth/setup.ts @@ -1,4 +1,4 @@ -import { test as setup } from '@playwright/test'; +import { test as setup, expect } from '@playwright/test'; const authFile = '.auth/storageState.json'; @@ -13,21 +13,17 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { console.log(`Navigating to OpenShift Console: ${targetUrl}`); await page.goto(targetUrl); - //set locators + // 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."); - } + // Fail loudly if the page is dead so we don't get weird errors later + await expect( + idpScreenText.or(usernameInput).first(), + "OpenShift login page failed to load. Check cluster health and URL." + ).toBeVisible({ timeout: 20000 }); const idpName = process.env.IDP || 'kube:admin'; const user = process.env.CLUSTER_USER || 'kubeadmin'; @@ -35,7 +31,7 @@ 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 + // Look for the specific IDP const idpLink = page.getByRole('link', { name: new RegExp(idpName, 'i') }); await idpLink.waitFor({ state: 'visible', timeout: 5000 }); @@ -44,7 +40,7 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { console.log("No IDP screen detected (or already selected), proceeding to credentials..."); } - // fill in the Credentials + // Fill in the credentials await usernameInput.waitFor({ state: 'visible', timeout: 10000 }); await usernameInput.fill(user); @@ -59,7 +55,7 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { await passwordInput.fill(process.env.CLUSTER_PASSWORD); await page.getByRole('button', { name: /Log in/i }).click(); - //save the auth state + // 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/README.md b/test/ui-e2e/README.md index 339f22e8f16..69889674ee8 100644 --- a/test/ui-e2e/README.md +++ b/test/ui-e2e/README.md @@ -1,65 +1,115 @@ -# GitOps Operator - UI End-to-End Tests -This suite validates the OpenShift GitOps Operator UI, focusing on Argo CD and SSO integration. +# OpenShift GitOps Operator - UI End-to-End Test Suite -## 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 - ``` +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. -## 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 -``` +## Prerequisites -## Execution Commands +Before running the suite locally, ensure your machine has the following tools installed: -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.** +1. **Node.js** (v18 or higher) +2. **OpenShift CLI (oc)**: Must be configured in your system PATH. +3. **Browser Binaries**: Playwright requires its own specific browser engines to run tests reproducibly. These are installed automatically when you run the `npx playwright install` setup command. -**Run All Tests (Headless):** -```bash -./run-ui-tests.sh --project=chromium -``` +### Installation + +Navigate to this directory and install the Node modules along with the required Playwright browser binaries: -**Run All Tests (Headed + Trace):** ```bash -./run-ui-tests.sh --project=chromium --headed --reporter=list --trace on +cd test/ui-e2e +npm install +npx playwright install chromium + ``` -**Run Single Test (Headed + Trace):** +--- + +## Environment Configuration + +The test suite requires cluster administrative credentials to discover routes and handle authentication loops. You can configure these either via a local `.env` file or by exporting them directly into your terminal/CI environment pipeline. + +### Quick Setup (Local Development) + +Generate a local `.env` file in the root of this directory using the following block: + ```bash -./run-ui-tests.sh tests/login.spec.ts --project=chromium --headed --trace on +cat < .env +export CLUSTER_USER="kubeadmin" +export CLUSTER_PASSWORD="" +export OC_API_URL="" +export IDP="kube:admin" # (Optional) Defaults to kube:admin +EOF + ``` -**View Trace Results:** +> **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. + +### Standard Test Execution + +| Target | Command | +| --- | --- | +| **Run All Tests (Headless/CI Mode)** | `./run-ui-tests.sh --project=chromium` | +| **Run All Tests (Headed + Visual Tracing)** | `./run-ui-tests.sh --project=chromium --headed --trace on` | +| **Run a Specific Spec File** | `./run-ui-tests.sh tests/create-application.spec.ts --project=chromium --headed --trace on` | + +### Playwright Flags Reference + +| Flag | Purpose | +| --- | --- | +| `--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. | + +### Visual Debugging (Trace Viewer) + +If a test fails during execution, Playwright records a full interactive timeline (DOM snapshots, network calls, console logs). + +When a test fails, the terminal output will provide an exact command to view the trace. Copy and paste that specific command: + ```bash -npx playwright show-trace test-results/**/*/trace.zip +# Example: +npx playwright show-trace test-results/create-application-chromium/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 +## Suite 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. +```text +├── .auth/ +│ └── setup.ts # Orchestrates global OCP authentication & saves storageState.json +├── src/ +│ └── pages/ # Page Object Models (POM) isolating UI selectors from spec logic +│ └── ApplicationsPage.ts +├── tests/ # Test specs organized by feature epic +│ ├── login.spec.ts +│ └── create-application.spec.ts +├── .env # Local runtime environment overrides (Git ignored) +└── run-ui-tests.sh # Context-aware orchestrator & URL discovery engine + +``` + +### Core Architecture Patterns -**Spec Isolation:** -`login.spec.ts` explicitly clears session cookies to force a full SSO UI validation from a fresh state. +* **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. + +--- ## 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 +### 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. + diff --git a/test/ui-e2e/run-ui-tests.sh b/test/ui-e2e/run-ui-tests.sh index 5aa9f582cb7..17f30f1095c 100755 --- a/test/ui-e2e/run-ui-tests.sh +++ b/test/ui-e2e/run-ui-tests.sh @@ -15,15 +15,23 @@ 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." +echo "Syncing CLI context..." +if [ -n "$OC_API_URL" ] && [ -n "$CLUSTER_PASSWORD" ]; then + # If variables exist, FORCE the CLI to match them so there is no cross-cluster confusion + echo "Logging into $OC_API_URL..." + oc login "$OC_API_URL" -u "$CLUSTER_USER" -p "$CLUSTER_PASSWORD" --insecure-skip-tls-verify=true > /dev/null 2>&1 + + if [ $? -ne 0 ]; then + echo "Error: Failed to log into the cluster. Please check the credentials in your .env file." exit 1 fi +elif ! oc whoami > /dev/null 2>&1; then + # If variables don't exist AND we aren't logged in, fail out + echo "Error: Not logged in. Missing OC_API_URL or CLUSTER_PASSWORD." + exit 1 +else + # If variables don't exist but we ARE logged in locally, just use the current session + echo "No .env credentials found. Using existing oc CLI session..." fi #find the URLs for console and argocd diff --git a/test/ui-e2e/src/fixtures.ts b/test/ui-e2e/src/fixtures.ts new file mode 100644 index 00000000000..76bcbd9799f --- /dev/null +++ b/test/ui-e2e/src/fixtures.ts @@ -0,0 +1,54 @@ +import { test as base, expect } from '@playwright/test'; +import { LoginPage } from './pages/LoginPage'; +import { ApplicationsPage } from './pages/ApplicationsPage'; + +//define custom fixture types +type MyFixtures = { + managedApp: string; +}; + +export const test = base.extend({ + + //login override + page: async ({ page }, use) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.loginViaOpenShift(); + await use(page); + }, + +//app setup/teardown + managedApp: [ async ({ page }, use) => { + const appName = `e2e-app-${Date.now()}`; + const appsPage = new ApplicationsPage(page); + + console.log(`[setup] creating and syncing application: ${appName}`); + await appsPage.navigate(); + await appsPage.createApp( + appName, + 'https://github.com/redhat-developer/openshift-gitops-getting-started.git', + 'app' + ); + await appsPage.syncApplication(appName); + await appsPage.verifyStatus(appName); + + //pass the name to the test + await use(appName); + + //teardown + console.log(`[teardown] deleting ${appName} via api`); + const response = await page.request.delete(`/api/v1/applications/${appName}?cascade=true`, { + headers: { 'Content-Type': 'application/json' } + }); + + //ignore if missing or rbac locked + if (response.status() === 404 || response.status() === 403) { + if (response.status() === 403) console.log('warning: delete forbidden (RBAC) on this cluster; skipping cleanup'); + } else { + expect(response.status()).toBeLessThan(400); + } + }, { timeout: 120000 } ], +}); + +//export it so spec files can use it +export { expect }; \ No newline at end of file diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts new file mode 100644 index 00000000000..1bb788a0759 --- /dev/null +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -0,0 +1,118 @@ +import { Page, expect, Locator } from '@playwright/test'; + +export class ApplicationsPage { + readonly page: Page; + readonly newAppButton: Locator; + readonly appNameInput: Locator; + readonly projectInput: Locator; + readonly repoUrlInput: Locator; + readonly pathInput: Locator; + readonly clusterUrlInput: Locator; + readonly namespaceInput: Locator; + readonly createButton: Locator; + + constructor(page: Page) { + this.page = page; + + //header buttons + this.newAppButton = page.getByRole('button', { name: /NEW APP/i }); + this.createButton = page.getByRole('button', { name: 'Create', exact: true }); + + this.appNameInput = page.getByLabel('Application Name', { exact: true }); + this.projectInput = page.locator('[qe-id="application-create-field-project"]'); + this.repoUrlInput = page.locator('.argo-form-row').filter({ hasText: 'Repository URL' }).locator('input').first(); + this.pathInput = page.locator('.argo-form-row').filter({ hasText: 'Path' }).locator('input').first(); + + //dest + this.clusterUrlInput = page.locator('.argo-form-row').filter({ hasText: 'Cluster URL' }).locator('input').first(); + this.namespaceInput = page.locator('.argo-form-row') + .filter({ has: page.getByText('Namespace', { exact: true }) }) + .locator('input').first(); + } + + async navigate() { + await this.page.goto('/applications'); + + //ignore the "failed to load data" banner if it appears + const errorBanner = this.page.getByText('try again'); + try { + //wait 3 secs + await errorBanner.waitFor({ state: 'visible', timeout: 3000 }); + await errorBanner.click(); + } catch (error) { + //banner didn't appear so just continue + } + + await expect(this.newAppButton).toBeVisible({ timeout: 15000 }); + } + + //helper for fields that need to have select a pre existing option + async fillDropdown(locator: Locator, value: string) { + await locator.click(); + await locator.pressSequentially(value, { delay: 50 }); + + //Wait for the dropdown + await expect(locator).toHaveValue(value, { timeout: 5000 }); + + await locator.press('Enter'); + } + + async createApp(appName: string, repoUrl: string, repoPath: string) { + await this.newAppButton.click(); + await this.page.getByText('Loading...').first().waitFor({ state: 'hidden', timeout: 15000 }); + + await this.appNameInput.fill(appName); + await this.fillDropdown(this.projectInput, 'default'); + + //src + await this.repoUrlInput.fill(repoUrl); + await this.pathInput.fill(repoPath); + + //dest + await this.clusterUrlInput.fill('https://kubernetes.default.svc'); + + //deploy + await this.namespaceInput.fill('openshift-gitops'); + await this.createButton.click(); + } + + async syncApplication(appName: string) { + //search for app + await this.page.getByPlaceholder(/Search applications/i).fill(appName); + + const appContainer = this.page.locator('.white-box, .argo-table-list__row').filter({ hasText: appName }); + await appContainer.waitFor({ state: 'visible', timeout: 20000 }); + await appContainer.getByText('Sync', { exact: true }).click(); + + //slideout panel + const resourcesSection = this.page.locator('.argo-form-row').filter({ hasText: 'SYNCHRONIZE RESOURCES' }); + await expect(resourcesSection).toContainText('spring-petclinic', { timeout: 15000 }); + + const validationWarning = resourcesSection.getByText('Select at least one resource'); + + //click 'all' until the UI registers it + await expect(async () => { + if (await validationWarning.isVisible()) { + + //clickable anchor tag + const allLink = resourcesSection.getByRole('link', { name: 'all', exact: true }); + await allLink.click(); + //wait for re-render and hide the text + await expect(validationWarning).toBeHidden({ timeout: 5000 }); + } + }).toPass({ timeout: 15000 }); + + //click the main sync button + await this.page.getByRole('button', { name: /^synchronize$/i }).click(); + } + + async verifyStatus(appName: string) { + //re-apply search filter just in case + await this.page.getByPlaceholder(/Search applications/i).fill(appName); + const appContainer = this.page.locator('.white-box, .argo-table-list__row').filter({ hasText: appName }); + + //90 secs + await expect(appContainer.getByText(/synced/i)).toBeVisible({ timeout: 90000 }); + await expect(appContainer.getByText(/healthy/i)).toBeVisible({ timeout: 90000 }); + } +} \ No newline at end of file diff --git a/test/ui-e2e/src/pages/LoginPage.ts b/test/ui-e2e/src/pages/LoginPage.ts index 7fc8728560b..9dd9a0826ea 100644 --- a/test/ui-e2e/src/pages/LoginPage.ts +++ b/test/ui-e2e/src/pages/LoginPage.ts @@ -12,10 +12,19 @@ export class LoginPage { await this.page.goto('/'); } - async loginViaOpenShift(user: string, pass: string, idp: string = 'kube:admin') { - //click the SSO button on the Argo CD landing page + async loginViaOpenShift(user?: string, pass?: string, idp: string = 'kube:admin') { const ssoButton = this.page.getByText(/LOG IN VIA OPENSHIFT/i); - await ssoButton.waitFor({ state: 'visible', timeout: 10000 }); + const newAppButton = this.page.getByRole('button', { name: /NEW APP/i }); + + //wait dynamically for either the login screen OR the dashboard to render + await ssoButton.or(newAppButton).first().waitFor({ state: 'visible', timeout: 20000 }); + + //if we landed straight on the dashboard, the cluster was already fully authenticated + if (await newAppButton.isVisible()) { + return; + } + + //otherwise, click the SSO button on the Argo CD landing page await ssoButton.click(); //handle the OpenShift IDP selection screen if it appears @@ -27,11 +36,18 @@ export class LoginPage { //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(); + //check if manual login is actually required + const usernameInput = this.page.getByLabel(/Username/i); + const needsLogin = await usernameInput.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false); + + if (needsLogin && user && pass) { + //fill out the OpenShift credentials + await usernameInput.fill(user); + await this.page.getByLabel(/Password/i).fill(pass); + await this.page.getByRole('button', { name: /Log in/i }).click(); + } else if (needsLogin) { + throw new Error('Login required but credentials (user/pass) not provided'); + } //Auth Handle the Allow Permissions screen try { 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..47cc90ca311 --- /dev/null +++ b/test/ui-e2e/tests/admin-login.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'node:child_process'; + +test('Log into Argo CD as local admin', async ({ browser }) => { + let rawOutput: string; + let routeUrl: string; + + try { + rawOutput = execSync( + 'oc extract secret/openshift-gitops-cluster -n openshift-gitops --keys=admin.password --to=-', + { timeout: 15000, stdio: 'pipe' } + ).toString(); + } catch (error) { + throw new Error("Failed to extract admin password. Please check your cluster connection and oc CLI."); + } + + //get credentials + const password = rawOutput.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))[0]; + + if (!password || password.length < 8) { + throw new Error("Extracted password appears invalid. Please verify the secret format in the OpenShift cluster."); + } + + try { + routeUrl = execSync( + 'oc get route openshift-gitops-server -n openshift-gitops -o jsonpath="{.spec.host}"', + { timeout: 15000, stdio: 'pipe' } + ).toString().trim(); + } catch (error) { + throw new Error("Failed to fetch Argo CD route. Please check your cluster connection and oc CLI."); + } + + //Fresh context to avoid any cached state issues + const context = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + ignoreHTTPSErrors: true + }); + + try { + //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.getByLabel(/username/i); + 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 }); + } finally { + // This guarantees the context closes even if an assertion fails above! + await context.close(); + } + }); \ No newline at end of file diff --git a/test/ui-e2e/tests/create-application.spec.ts b/test/ui-e2e/tests/create-application.spec.ts new file mode 100644 index 00000000000..573f5b0d406 --- /dev/null +++ b/test/ui-e2e/tests/create-application.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from '../src/fixtures'; +import { ApplicationsPage } from '../src/pages/ApplicationsPage'; + +test.describe('ArgoCD Create Application', () => { + //declare appname + let appName: string; + +test.afterEach(async ({ page }) => { + if (!appName) return; + + console.log(`cleaning up: deleting ${appName} via api`); + + const response = await page.request.delete(`/api/v1/applications/${appName}?cascade=true`, { + headers: { 'Content-Type': 'application/json' } + }); + + //ignore 404 (already gone) or 403 (no permission on this cluster) + if (response.status() === 404 || response.status() === 403) { + if (response.status() === 403) console.log('warning: rbac bypass on this cluster'); + return; + } + + //only fail for actual server errors + expect(response.status()).toBeLessThan(400); + }); + + test('Deploy the Spring Petclinic application via UI', async ({ page }) => { + test.setTimeout(180000); + + const appsPage = new ApplicationsPage(page); + appName = `spring-petclinic-${Date.now()}`; + const publicRepo = 'https://github.com/redhat-developer/openshift-gitops-getting-started.git'; + const repoPath = 'app'; + + await appsPage.navigate(); + await appsPage.createApp(appName, publicRepo, repoPath); + await appsPage.syncApplication(appName); + await appsPage.verifyStatus(appName); + }); + +}); \ No newline at end of file diff --git a/test/ui-e2e/tests/login.spec.ts b/test/ui-e2e/tests/login.spec.ts index 57051cda6d3..e2b1aa30391 100644 --- a/test/ui-e2e/tests/login.spec.ts +++ b/test/ui-e2e/tests/login.spec.ts @@ -3,6 +3,9 @@ import { LoginPage } from '../src/pages/LoginPage'; test.describe('Argo CD SSO Authentication', () => { + //give the manual login flow plenty of time to finish + test.setTimeout(60000); + //clear storageState to force a full login flow for this specific test test.use({ storageState: { cookies: [], origins: [] } }); @@ -20,4 +23,5 @@ test.describe('Argo CD SSO Authentication', () => { //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 From 0ed24a8fadfcc9ddc927cef3f074e1a177342ace Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 3 Jun 2026 11:03:02 +0100 Subject: [PATCH 2/6] address Coderabbit PR review feedback Signed-off-by: Triona Doyle --- test/ui-e2e/.auth/setup.ts | 3 ++- test/ui-e2e/src/fixtures.ts | 21 +++++++++++++++++---- test/ui-e2e/src/pages/ApplicationsPage.ts | 4 ++-- test/ui-e2e/src/pages/LoginPage.ts | 5 ++++- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/test/ui-e2e/.auth/setup.ts b/test/ui-e2e/.auth/setup.ts index 59dbecefb39..4f52b9fe9a1 100644 --- a/test/ui-e2e/.auth/setup.ts +++ b/test/ui-e2e/.auth/setup.ts @@ -56,6 +56,7 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { await page.getByRole('button', { name: /Log in/i }).click(); // Save the auth state - await page.waitForLoadState('networkidle'); + await expect(page.getByRole('navigation').first()).toBeVisible({ timeout: 15000 }); await page.context().storageState({ path: authFile }); + }); \ No newline at end of file diff --git a/test/ui-e2e/src/fixtures.ts b/test/ui-e2e/src/fixtures.ts index 76bcbd9799f..0eae15e8159 100644 --- a/test/ui-e2e/src/fixtures.ts +++ b/test/ui-e2e/src/fixtures.ts @@ -13,7 +13,20 @@ export const test = base.extend({ page: async ({ page }, use) => { const loginPage = new LoginPage(page); await loginPage.goto(); - await loginPage.loginViaOpenShift(); + + // 1. Grab variables from the environment + const user = process.env.CLUSTER_USER || 'kubeadmin'; + const pass = process.env.CLUSTER_PASSWORD; + const idp = process.env.IDP || 'kube:admin'; + + // 2. Fail loudly if the password is missing + if (!pass) { + throw new Error('CLUSTER_PASSWORD environment variable is missing. Cannot authenticate.'); + } + + // 3. Pass them into the login method + await loginPage.loginViaOpenShift(user, pass, idp); + await use(page); }, @@ -41,9 +54,9 @@ export const test = base.extend({ headers: { 'Content-Type': 'application/json' } }); - //ignore if missing or rbac locked - if (response.status() === 404 || response.status() === 403) { - if (response.status() === 403) console.log('warning: delete forbidden (RBAC) on this cluster; skipping cleanup'); + // 4. Update the teardown to only ignore 404s, treating 403s as failures + if (response.status() === 404) { + return; } else { expect(response.status()).toBeLessThan(400); } diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index 1bb788a0759..06edde2650a 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -76,7 +76,7 @@ export class ApplicationsPage { await this.createButton.click(); } - async syncApplication(appName: string) { + async syncApplication(appName: string, expectedResource: string = 'spring-petclinic') { //search for app await this.page.getByPlaceholder(/Search applications/i).fill(appName); @@ -86,7 +86,7 @@ export class ApplicationsPage { //slideout panel const resourcesSection = this.page.locator('.argo-form-row').filter({ hasText: 'SYNCHRONIZE RESOURCES' }); - await expect(resourcesSection).toContainText('spring-petclinic', { timeout: 15000 }); + await expect(resourcesSection).toContainText(expectedResource, { timeout: 15000 }); const validationWarning = resourcesSection.getByText('Select at least one resource'); diff --git a/test/ui-e2e/src/pages/LoginPage.ts b/test/ui-e2e/src/pages/LoginPage.ts index 9dd9a0826ea..6bf8615dd95 100644 --- a/test/ui-e2e/src/pages/LoginPage.ts +++ b/test/ui-e2e/src/pages/LoginPage.ts @@ -37,7 +37,10 @@ export class LoginPage { } //check if manual login is actually required - const usernameInput = this.page.getByLabel(/Username/i); + const usernameInput = this.page.getByLabel(/Username/i) + .or(this.page.locator('input[name="username"]')) + .or(this.page.getByPlaceholder(/Username/i)); + const needsLogin = await usernameInput.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false); if (needsLogin && user && pass) { From b5ae6211a145eea1fd16d0815be2d053bf76eda7 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 3 Jun 2026 11:21:28 +0100 Subject: [PATCH 3/6] address Coderabbit 'nitpick' feedback Signed-off-by: Triona Doyle --- test/ui-e2e/.auth/setup.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/ui-e2e/.auth/setup.ts b/test/ui-e2e/.auth/setup.ts index 4f52b9fe9a1..e9c78f6b2f7 100644 --- a/test/ui-e2e/.auth/setup.ts +++ b/test/ui-e2e/.auth/setup.ts @@ -57,6 +57,7 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { // Save the auth state await expect(page.getByRole('navigation').first()).toBeVisible({ timeout: 15000 }); + await expect(page).toHaveURL(/.*dashboards.*/, { timeout: 15000 }); await page.context().storageState({ path: authFile }); }); \ No newline at end of file From c0ebb34fabb0c6537a8d0a88ff250cfb8cca48f2 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 3 Jun 2026 12:03:24 +0100 Subject: [PATCH 4/6] address further coderabbit feedback ... Signed-off-by: Triona Doyle --- test/ui-e2e/.auth/setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ui-e2e/.auth/setup.ts b/test/ui-e2e/.auth/setup.ts index e9c78f6b2f7..6b30991d3ac 100644 --- a/test/ui-e2e/.auth/setup.ts +++ b/test/ui-e2e/.auth/setup.ts @@ -57,7 +57,7 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { // Save the auth state await expect(page.getByRole('navigation').first()).toBeVisible({ timeout: 15000 }); - await expect(page).toHaveURL(/.*dashboards.*/, { timeout: 15000 }); + await expect(page).toHaveURL(/(console|k8s|overview|dashboards)/i, { timeout: 15000 }); await page.context().storageState({ path: authFile }); }); \ No newline at end of file From d260866e838ce5bbd4102e1e50a74701e0db4c46 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Thu, 4 Jun 2026 12:33:19 +0100 Subject: [PATCH 5/6] update applications page locators for 1.21.0 compatibility Signed-off-by: Triona Doyle --- test/ui-e2e/src/pages/ApplicationsPage.ts | 45 ++++++++++++----------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index 06edde2650a..f97bebc4fbc 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -20,14 +20,20 @@ export class ApplicationsPage { this.appNameInput = page.getByLabel('Application Name', { exact: true }); this.projectInput = page.locator('[qe-id="application-create-field-project"]'); - this.repoUrlInput = page.locator('.argo-form-row').filter({ hasText: 'Repository URL' }).locator('input').first(); - this.pathInput = page.locator('.argo-form-row').filter({ hasText: 'Path' }).locator('input').first(); + //src + this.repoUrlInput = page.locator('[qe-id="application-create-field-repository-url"]') + .or(page.getByPlaceholder(/github\.com/i)).first(); + + this.pathInput = page.locator('[qe-id="application-create-field-path"]') + .or(page.getByText('Path').locator('..').locator('input')).first(); //dest - this.clusterUrlInput = page.locator('.argo-form-row').filter({ hasText: 'Cluster URL' }).locator('input').first(); - this.namespaceInput = page.locator('.argo-form-row') - .filter({ has: page.getByText('Namespace', { exact: true }) }) - .locator('input').first(); + this.clusterUrlInput = page.locator('[qe-id="application-create-field-cluster-url"]') + .or(page.getByText('Cluster URL', { exact: true }).locator('..').locator('input')).first(); + + this.namespaceInput = page.locator('[qe-id="application-create-field-namespace"]') + .or(page.getByText('Namespace', { exact: true }).locator('..').locator('input')).first(); + } async navigate() { @@ -76,7 +82,7 @@ export class ApplicationsPage { await this.createButton.click(); } - async syncApplication(appName: string, expectedResource: string = 'spring-petclinic') { +async syncApplication(appName: string, expectedResource: string = 'spring-petclinic') { //search for app await this.page.getByPlaceholder(/Search applications/i).fill(appName); @@ -85,25 +91,20 @@ export class ApplicationsPage { await appContainer.getByText('Sync', { exact: true }).click(); //slideout panel - const resourcesSection = this.page.locator('.argo-form-row').filter({ hasText: 'SYNCHRONIZE RESOURCES' }); - await expect(resourcesSection).toContainText(expectedResource, { timeout: 15000 }); + // Wait for the manifests to fetch from Git and render on the panel + await expect(this.page.getByText(expectedResource).first()).toBeVisible({ timeout: 15000 }); - const validationWarning = resourcesSection.getByText('Select at least one resource'); - - //click 'all' until the UI registers it - await expect(async () => { - if (await validationWarning.isVisible()) { - - //clickable anchor tag - const allLink = resourcesSection.getByRole('link', { name: 'all', exact: true }); + //click 'all' to ensure all resource checkboxes are ticked across all Argo CD versions + const allLink = this.page.getByRole('link', { name: 'all', exact: true }); + if (await allLink.isVisible()) { await allLink.click(); - //wait for re-render and hide the text - await expect(validationWarning).toBeHidden({ timeout: 5000 }); - } - }).toPass({ timeout: 15000 }); + } //click the main sync button - await this.page.getByRole('button', { name: /^synchronize$/i }).click(); + await this.page.getByRole('button', { name: /^synchronize$/i }).first().click(); + + //wait for the panel to close + await expect(this.page.getByText('SYNCHRONIZE RESOURCES')).toBeHidden({ timeout: 10000 }); } async verifyStatus(appName: string) { From 430bac85ecd39177ef978081ab89e36cb0ced61a Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Thu, 4 Jun 2026 12:51:57 +0100 Subject: [PATCH 6/6] address the coderabbit feedback Signed-off-by: Triona Doyle --- test/ui-e2e/src/pages/ApplicationsPage.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index f97bebc4fbc..130c067c7f2 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -96,10 +96,12 @@ async syncApplication(appName: string, expectedResource: string = 'spring-petcli //click 'all' to ensure all resource checkboxes are ticked across all Argo CD versions const allLink = this.page.getByRole('link', { name: 'all', exact: true }); - if (await allLink.isVisible()) { - await allLink.click(); + try { + await allLink.waitFor({ state: 'visible', timeout: 3000 }); + await allLink.click(); + } catch (error) { + //all link didn't appear within 3 sec } - //click the main sync button await this.page.getByRole('button', { name: /^synchronize$/i }).first().click();