From b385eded6cac796638c3300deade52a9a498ebbc Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Tue, 23 Jun 2026 16:59:06 +0100 Subject: [PATCH 1/4] test: add UI E2E tests for Argo CD Resource Tree and Pod logs Signed-off-by: Triona Doyle --- test/ui-e2e/.auth/setup.ts | 15 ++++- test/ui-e2e/README.md | 13 ++-- test/ui-e2e/run-ui-tests.sh | 40 ++++++++++-- .../src/pages/ApplicationDetailsPage.ts | 64 +++++++++++++++++++ test/ui-e2e/src/pages/ApplicationsPage.ts | 51 +++++++++++---- test/ui-e2e/src/pages/LoginPage.ts | 5 +- test/ui-e2e/tests/admin-login.spec.ts | 4 +- test/ui-e2e/tests/resource-tree.spec.ts | 29 +++++++++ 8 files changed, 194 insertions(+), 27 deletions(-) create mode 100644 test/ui-e2e/src/pages/ApplicationDetailsPage.ts create mode 100644 test/ui-e2e/tests/resource-tree.spec.ts diff --git a/test/ui-e2e/.auth/setup.ts b/test/ui-e2e/.auth/setup.ts index 6b30991d3ac..3d94f354eea 100644 --- a/test/ui-e2e/.auth/setup.ts +++ b/test/ui-e2e/.auth/setup.ts @@ -55,9 +55,20 @@ 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 + try { + const skipTourButton = page.getByRole('button', { name: /skip tour/i }); + // Wait up to 5 seconds for the modal to pop up + await skipTourButton.waitFor({ state: 'visible', timeout: 5000 }); + await skipTourButton.click(); + console.log('Dismissed the OpenShift Welcome Tour modal.'); + } catch (error) { + // If it doesn't appear within 5 seconds, it's an older cluster or already dismissed. + // Safely ignore the error and move on + } + // Save the auth state - await expect(page.getByRole('navigation').first()).toBeVisible({ timeout: 15000 }); + await expect(page.getByRole('navigation').first()).toBeVisible({ timeout: 20000 }); await expect(page).toHaveURL(/(console|k8s|overview|dashboards)/i, { timeout: 15000 }); 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 69889674ee8..9ecd4afb805 100644 --- a/test/ui-e2e/README.md +++ b/test/ui-e2e/README.md @@ -56,9 +56,10 @@ All executions are driven via the ./run-ui-tests.sh wrapper script. This wrapper | 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` | +| **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 a Specific Spec File** | `./run-ui-tests.sh tests/resource-tree.spec.ts --project=chromium --headed` | ### Playwright Flags Reference @@ -67,6 +68,7 @@ All executions are driven via the ./run-ui-tests.sh wrapper script. This wrapper | `--headed` | Launches the visible Chromium browser UI. Excellent for local debugging. | | `--trace on` | Records a granular execution trace (DOM snapshots, network calls, actions) for visual triage. | | `--reporter=list` | Switches stdout to a clean line-by-line format, ideal for monitoring real-time execution steps. | +| `--env=` | Overrides the local setup to simulate automation. It forces headless execution, performs a clean `npm ci`, and installs required browser binaries dynamically. | ### Visual Debugging (Trace Viewer) @@ -91,8 +93,9 @@ npx playwright show-trace test-results/create-application-chromium/trace.zip │ └── 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 +│ ├── admin-login.spec.ts +│ ├── create-application.spec.ts +│ └── resource-tree.spec.ts ├── .env # Local runtime environment overrides (Git ignored) └── run-ui-tests.sh # Context-aware orchestrator & URL discovery engine diff --git a/test/ui-e2e/run-ui-tests.sh b/test/ui-e2e/run-ui-tests.sh index 17f30f1095c..609aa691c1b 100755 --- a/test/ui-e2e/run-ui-tests.sh +++ b/test/ui-e2e/run-ui-tests.sh @@ -1,16 +1,28 @@ #!/bin/bash +# use arguments to extract --env and keep the rest for Playwright +ENV="local" +TEST_ARGS=() + +while [[ "$#" -gt 0 ]]; do + case $1 in + --env=*) ENV="${1#*=}" ;; + *) TEST_ARGS+=("$1") ;; # Save all other args (files, --headed, etc.) + esac + shift +done + if [ -f .env ]; then echo "Loading variables from .env file..." set -a #export all variables source .env - set +a # stop automatically exporting + set +a #stop auto export fi #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) +#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"} @@ -26,11 +38,11 @@ if [ -n "$OC_API_URL" ] && [ -n "$CLUSTER_PASSWORD" ]; then exit 1 fi elif ! oc whoami > /dev/null 2>&1; then - # If variables don't exist AND we aren't logged in, fail out + #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 + #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 @@ -53,4 +65,22 @@ rm -f .auth/storageState.json || true #run Playwright echo " Starting Playwright tests..." -npx playwright test "$@" \ No newline at end of file + +# 2. Execute based on the environment +if [[ "$ENV" == "ci" ]] || [[ "$ENV" == "pipeline" ]]; then + echo "Running headlessly in automation ($ENV)..." + npm ci + + # Prevent sudo jump-scares for local Mac users simulating CI + if [[ "$(uname -s)" == "Darwin" ]]; then + npx playwright install chromium + else + npx playwright install chromium --with-deps + fi + + npx playwright test "${TEST_ARGS[@]}" --reporter=list + +else + echo "Running Locally..." + npx playwright test "${TEST_ARGS[@]}" +fi \ No newline at end of file diff --git a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts new file mode 100644 index 00000000000..d6f4618a004 --- /dev/null +++ b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts @@ -0,0 +1,64 @@ +import { Page, expect, Locator } from '@playwright/test'; + +export class ApplicationDetailsPage { + readonly page: Page; + readonly resourceTreeContainer: Locator; + readonly slideOutPanel: Locator; + readonly logsTab: Locator; + + constructor(page: Page) { + this.page = page; + + //main container + this.resourceTreeContainer = page.locator('.application-details__tree'); + + //details panel that slides out (isolate the active visible pane) + this.slideOutPanel = page.locator('.sliding-panel').filter({ visible: true }); + + //logs tab inside the slide-out panel + this.logsTab = this.slideOutPanel.getByRole('button', { name: /logs/i }).or(this.slideOutPanel.getByText(/logs/i, { exact: true })); + } + + async verifyResourceTreeLoaded() { + //wait tree to be visible + await expect(this.resourceTreeContainer).toBeVisible({ timeout: 20000 }); + //wait for healthy status + await expect(this.page.getByText('Healthy', { exact: true }).first()).toBeVisible({ timeout: 30000 }); + } + + async clickResourceNode(kind: string, name: string) { + //find the innermost div representing the resource node + const node = this.resourceTreeContainer + .locator('div') + .filter({ hasText: kind }) + .filter({ hasText: name }) + .last(); + + //scroll it into view and click it + await node.scrollIntoViewIfNeeded(); + await node.waitFor({ state: 'visible', timeout: 15000 }); + await node.click(); + + //self-healing validation block to handle frontend rendering lag + await expect(async () => { + await expect(this.slideOutPanel).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 10000 }); + } + + async verifyPodLogs(expectedLogText?: string) { + //click Logs + await this.logsTab.waitFor({ state: 'visible', timeout: 5000 }); + await this.logsTab.click(); + + const logFilterInput = this.slideOutPanel.getByPlaceholder('containing'); + await expect(logFilterInput).toBeVisible({ timeout: 15000 }); + + if (expectedLogText) { + //find log line anywhere in the slide-out panel + await expect(this.slideOutPanel).toContainText(expectedLogText, { timeout: 30000 }); + } else { + const genericLogLine = this.slideOutPanel.getByText(/\d{4}-\d{2}-\d{2}.*(INFO|Started)/).first(); + await expect(genericLogLine).toBeVisible({ timeout: 30000 }); + } + } +} \ No newline at end of file diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index 130c067c7f2..a75e68e696e 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -63,8 +63,19 @@ export class ApplicationsPage { await locator.press('Enter'); } - async createApp(appName: string, repoUrl: string, repoPath: string) { +async createApp(appName: string, repoUrl: string, repoPath: string) { await this.newAppButton.click(); + + //handle the "failed to load data" banner if it appears inside the slide-out panel + 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 this.page.getByText('Loading...').first().waitFor({ state: 'hidden', timeout: 15000 }); await this.appNameInput.fill(appName); @@ -82,31 +93,33 @@ 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); const appContainer = this.page.locator('.white-box, .argo-table-list__row').filter({ hasText: appName }); await appContainer.waitFor({ state: 'visible', timeout: 20000 }); + await expect(appContainer.getByText(/OutOfSync|Out of Sync/i).first()).toBeVisible({ timeout: 45000 }); + //safe to open the panel await appContainer.getByText('Sync', { exact: true }).click(); - //slideout panel - // Wait for the manifests to fetch from Git and render on the panel - await expect(this.page.getByText(expectedResource).first()).toBeVisible({ timeout: 15000 }); - - //click 'all' to ensure all resource checkboxes are ticked across all Argo CD versions + //click 'all' const allLink = this.page.getByRole('link', { name: 'all', exact: true }); try { - await allLink.waitFor({ state: 'visible', timeout: 3000 }); + await allLink.waitFor({ state: 'visible', timeout: 5000 }); await allLink.click(); } catch (error) { - //all link didn't appear within 3 sec + // all link didn't appear within 5 sec } + + //wait for the manifests to render on the panel + await expect(this.page.getByText(expectedResource).first()).toBeVisible({ timeout: 30000 }); + //click the main sync button 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 }); + //wait for the panel to close + await expect(this.page.getByText('SYNCHRONIZE RESOURCES')).toBeHidden({ timeout: 15000 }); } async verifyStatus(appName: string) { @@ -118,4 +131,20 @@ async syncApplication(appName: string, expectedResource: string = 'spring-petcli await expect(appContainer.getByText(/synced/i)).toBeVisible({ timeout: 90000 }); await expect(appContainer.getByText(/healthy/i)).toBeVisible({ timeout: 90000 }); } + + async openApplication(appName: string) { + //re-apply search filter just in case the UI refreshed + await this.page.getByPlaceholder(/Search applications/i).fill(appName); + + //find the container, then specifically click the link of the app name + const appLink = this.page.locator('.white-box, .argo-table-list__row') + .filter({ hasText: appName }) + .getByRole('link', { name: appName }); + + await appLink.waitFor({ state: 'visible', timeout: 15000 }); + await appLink.click(); + + //wait for the URL to change to the details page to ensure the click worked + await expect(this.page).toHaveURL(/.*\/applications\/.*\/.*/, { timeout: 15000 }); + } } \ 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 6bf8615dd95..a1b107c0ff1 100644 --- a/test/ui-e2e/src/pages/LoginPage.ts +++ b/test/ui-e2e/src/pages/LoginPage.ts @@ -37,9 +37,10 @@ export class LoginPage { } //check if manual login is actually required - const usernameInput = this.page.getByLabel(/Username/i) + const usernameInput = this.page.getByRole('textbox', { name: /Username/i }) .or(this.page.locator('input[name="username"]')) - .or(this.page.getByPlaceholder(/Username/i)); + .or(this.page.getByPlaceholder(/Username/i)) + .first(); const needsLogin = await usernameInput.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false); diff --git a/test/ui-e2e/tests/admin-login.spec.ts b/test/ui-e2e/tests/admin-login.spec.ts index 47cc90ca311..7170ba9dcaf 100644 --- a/test/ui-e2e/tests/admin-login.spec.ts +++ b/test/ui-e2e/tests/admin-login.spec.ts @@ -11,7 +11,7 @@ test('Log into Argo CD as local admin', async ({ browser }) => { { timeout: 15000, stdio: 'pipe' } ).toString(); } catch (error) { - throw new Error("Failed to extract admin password. Please check your cluster connection and oc CLI."); + throw new Error("Failed to extract admin password. Please check your cluster connection and oc CLI.", { cause: error }); } //get credentials @@ -27,7 +27,7 @@ test('Log into Argo CD as local admin', async ({ browser }) => { { 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."); + throw new Error("Failed to fetch Argo CD route. Please check your cluster connection and oc CLI.", { cause: error }); } //Fresh context to avoid any cached state issues diff --git a/test/ui-e2e/tests/resource-tree.spec.ts b/test/ui-e2e/tests/resource-tree.spec.ts new file mode 100644 index 00000000000..4123e396f0b --- /dev/null +++ b/test/ui-e2e/tests/resource-tree.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from '../src/fixtures'; +import { ApplicationDetailsPage } from '../src/pages/ApplicationDetailsPage'; +import { ApplicationsPage } from '../src/pages/ApplicationsPage'; + +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 }) => { + test.setTimeout(120000); + + const appsPage = new ApplicationsPage(page); + const detailsPage = new ApplicationDetailsPage(page); + + await appsPage.navigate(); + await page.getByPlaceholder(/Search applications/i).fill(managedApp); + + //click the Application Name text/link + const appCard = page.locator('.white-box, .argo-table-list__row').filter({ hasText: managedApp }); + await appCard.getByText(managedApp, { exact: true }).first().click(); + + //on details page + await detailsPage.verifyResourceTreeLoaded(); + //Deployment node + await detailsPage.clickResourceNode('deploy', 'spring-petclinic'); + await detailsPage.verifyPodLogs(); + }); + +}); \ No newline at end of file From 3524e68c24945d2b67ba076ed4a41f0820e60a0e Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 24 Jun 2026 13:52:51 +0100 Subject: [PATCH 2/4] test updates and address coderabbit feedback Signed-off-by: Triona Doyle --- test/ui-e2e/.auth/setup.ts | 36 ++++++----- test/ui-e2e/global.setup.ts | 21 +++++++ test/ui-e2e/playwright.config.ts | 37 ++++++------ test/ui-e2e/src/fixtures.ts | 37 ++++++++++-- test/ui-e2e/src/pages/ApplicationsPage.ts | 73 +++++++++++++++-------- 5 files changed, 136 insertions(+), 68 deletions(-) create mode 100644 test/ui-e2e/global.setup.ts diff --git a/test/ui-e2e/.auth/setup.ts b/test/ui-e2e/.auth/setup.ts index 3d94f354eea..a779029ac2c 100644 --- a/test/ui-e2e/.auth/setup.ts +++ b/test/ui-e2e/.auth/setup.ts @@ -2,8 +2,11 @@ import { test as setup, expect } from '@playwright/test'; const authFile = '.auth/storageState.json'; +//centralized timeouts to appease the linter +const TIMEOUTS = { short: 5000, medium: 10000, default: 15000, long: 20000 }; + setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { - // Navigate to the OpenShift Console + //navigate to the OpenShift Console const targetUrl = baseURL || process.env.CONSOLE_URL || process.env.BASE_URL; if (!targetUrl) { @@ -13,17 +16,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)); - // Fail loudly if the page is dead so we don't get weird errors later + //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 }); + ).toBeVisible({ timeout: TIMEOUTS.long }); const idpName = process.env.IDP || 'kube:admin'; const user = process.env.CLUSTER_USER || 'kubeadmin'; @@ -31,17 +34,17 @@ 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 }); + await idpLink.waitFor({ state: 'visible', timeout: TIMEOUTS.short }); 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 }); + //fill in the credentials + await usernameInput.waitFor({ state: 'visible', timeout: TIMEOUTS.medium }); await usernameInput.fill(user); const passwordInput = page.getByLabel(/Password/i) @@ -55,20 +58,21 @@ 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 4.x 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 - await skipTourButton.waitFor({ state: 'visible', timeout: 5000 }); + //wait up to 5 seconds 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.'); } catch (error) { - // If it doesn't appear within 5 seconds, it's an older cluster or already dismissed. - // Safely ignore the error and move on + //if it doesn't appear within 5 seconds, it's an older cluster or already dismissed + //safely ignore the error and move on + console.debug('welcome tour modal did not appear, continuing...'); } - // Save the auth state - await expect(page.getByRole('navigation').first()).toBeVisible({ timeout: 20000 }); - await expect(page).toHaveURL(/(console|k8s|overview|dashboards)/i, { timeout: 15000 }); + //save the auth state + await expect(page.getByRole('navigation').first()).toBeVisible({ timeout: TIMEOUTS.long }); + await expect(page).toHaveURL(/(console|k8s|overview|dashboards)/i, { timeout: TIMEOUTS.default }); await page.context().storageState({ path: authFile }); }); \ No newline at end of file diff --git a/test/ui-e2e/global.setup.ts b/test/ui-e2e/global.setup.ts new file mode 100644 index 00000000000..5c0bd668368 --- /dev/null +++ b/test/ui-e2e/global.setup.ts @@ -0,0 +1,21 @@ +import { execSync } from 'child_process'; + +async function globalSetup() { + console.log('🧹 Running pre-flight cleanup...'); + + try { + console.log(' -> Sweeping ghost applications...'); + //no hangs on dead controllers + execSync('oc delete applications.argoproj.io --all -n openshift-gitops --wait=false', { stdio: 'ignore' }); + + console.log(' -> Sweeping orphaned Spring Petclinic resources...'); + //no hangs on dead controllers + execSync('oc delete all -l app=spring-petclinic -n openshift-gitops --wait=false', { stdio: 'ignore' }); + + console.log('✨ Cluster sanitized. Starting test suite.'); + } catch (error) { + console.log('✨ Cluster is clean. Starting test suite.'); + } +} + +export default globalSetup; \ No newline at end of file diff --git a/test/ui-e2e/playwright.config.ts b/test/ui-e2e/playwright.config.ts index a1c958d6deb..c365a811b7a 100644 --- a/test/ui-e2e/playwright.config.ts +++ b/test/ui-e2e/playwright.config.ts @@ -1,35 +1,40 @@ import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; /** * 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({ + //register pre-flight script + globalSetup: require.resolve('./global.setup.ts'), + //global test timeout to 5 min + timeout: 5 * 60 * 1000, + testDir: './tests', - /* Run tests in files in parallel */ - fullyParallel: true, + /* Turn off parallel execution inside files */ + fullyParallel: false, /* 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, + + //stops parallel execution so they don't fight over the openshift-gitops namespace. + workers: 1, + /* 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 */ + /* GLOBAL FOUNDATION: These apply to everything */ use: { baseURL: process.env.ARGOCD_URL, ignoreHTTPSErrors: true, @@ -44,7 +49,8 @@ export default defineConfig({ testMatch: '**/.auth/setup.ts', /* Only changes the URL for this specific project */ use: { - baseURL: process.env.CONSOLE_URL, }, + baseURL: process.env.CONSOLE_URL, + }, }, // Update chromium project @@ -62,16 +68,7 @@ export default defineConfig({ 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, - // }, -}); +}); \ No newline at end of file diff --git a/test/ui-e2e/src/fixtures.ts b/test/ui-e2e/src/fixtures.ts index 0eae15e8159..33c1ce84962 100644 --- a/test/ui-e2e/src/fixtures.ts +++ b/test/ui-e2e/src/fixtures.ts @@ -30,7 +30,7 @@ export const test = base.extend({ await use(page); }, -//app setup/teardown + // 🚀 Cleaned up 'request' from the parameters, just using 'page' now managedApp: [ async ({ page }, use) => { const appName = `e2e-app-${Date.now()}`; const appsPage = new ApplicationsPage(page); @@ -50,17 +50,42 @@ export const test = base.extend({ //teardown console.log(`[teardown] deleting ${appName} via api`); - const response = await page.request.delete(`/api/v1/applications/${appName}?cascade=true`, { + + // 🚀 REVERTED: Back to page.request so we keep our UI login cookies! + const deleteResponse = await page.request.delete(`/api/v1/applications/${appName}?cascade=true`, { headers: { 'Content-Type': 'application/json' } }); - // 4. Update the teardown to only ignore 404s, treating 403s as failures - if (response.status() === 404) { + // If it's already 404 (or 403), we have nothing left to do + if (deleteResponse.status() === 404 || deleteResponse.status() === 403) { + console.log(`[teardown] ${appName} was already deleted.`); return; } else { - expect(response.status()).toBeLessThan(400); + // Ensure the delete request itself was accepted (200/202) + expect(deleteResponse.status()).toBeLessThan(400); + + console.log(`[teardown] waiting for background cleanup of ${appName} to finish...`); + await expect.poll(async () => { + try { + // 🚀 REVERTED: Back to page.request, but KEEPING the try/catch shield! + const checkResponse = await page.request.get(`/api/v1/applications/${appName}`); + const status = checkResponse.status(); + + // 🚀 ACCEPT BOTH: 404 (Not Found) or 403 (Forbidden due to RBAC project scoping) + return status === 404 || status === 403; + } catch (error) { + // If the OpenShift router blips or drops the socket, swallow it and keep polling + return false; + } + }, { + message: `Waiting for ${appName} to completely delete from the cluster.`, + timeout: 60000, + intervals: [2000, 5000, 10000], + }).toBeTruthy(); // 🚀 Changed to check if our boolean logic returns true + + console.log(`[teardown] ${appName} successfully removed from the cluster.`); } - }, { timeout: 120000 } ], + }, { timeout: 300000 } ], }); //export it so spec files can use it diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index a75e68e696e..ec893f01065 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -1,5 +1,17 @@ import { Page, expect, Locator } from '@playwright/test'; +//timeouts +const TIMEOUTS = { + short: 3000, + modal: 5000, + panel: 10000, + default: 15000, + load: 20000, + render: 30000, + sync: 120000, + status: 180000 +}; + export class ApplicationsPage { readonly page: Page; readonly newAppButton: Locator; @@ -43,13 +55,13 @@ export class ApplicationsPage { const errorBanner = this.page.getByText('try again'); try { //wait 3 secs - await errorBanner.waitFor({ state: 'visible', timeout: 3000 }); + await errorBanner.waitFor({ state: 'visible', timeout: TIMEOUTS.short }); await errorBanner.click(); } catch (error) { //banner didn't appear so just continue } - await expect(this.newAppButton).toBeVisible({ timeout: 15000 }); + await expect(this.newAppButton).toBeVisible({ timeout: TIMEOUTS.default }); } //helper for fields that need to have select a pre existing option @@ -57,26 +69,25 @@ export class ApplicationsPage { await locator.click(); await locator.pressSequentially(value, { delay: 50 }); - //Wait for the dropdown - await expect(locator).toHaveValue(value, { timeout: 5000 }); + //wait for the dropdown + await expect(locator).toHaveValue(value, { timeout: TIMEOUTS.modal }); await locator.press('Enter'); } -async createApp(appName: string, repoUrl: string, repoPath: string) { + async createApp(appName: string, repoUrl: string, repoPath: string) { await this.newAppButton.click(); //handle the "failed to load data" banner if it appears inside the slide-out panel const errorBanner = this.page.getByText('try again'); try { - //wait 3 secs - await errorBanner.waitFor({ state: 'visible', timeout: 3000 }); + await errorBanner.waitFor({ state: 'visible', timeout: TIMEOUTS.short }); await errorBanner.click(); } catch (error) { //banner didn't appear so just continue } - await this.page.getByText('Loading...').first().waitFor({ state: 'hidden', timeout: 15000 }); + await this.page.getByText('Loading...').first().waitFor({ state: 'hidden', timeout: TIMEOUTS.default }); await this.appNameInput.fill(appName); await this.fillDropdown(this.projectInput, 'default'); @@ -88,8 +99,9 @@ async createApp(appName: string, repoUrl: string, repoPath: string) { //dest await this.clusterUrlInput.fill('https://kubernetes.default.svc'); - //deploy - await this.namespaceInput.fill('openshift-gitops'); + //deploy to namespace + await this.namespaceInput.fill('openshift-gitops'); + await this.createButton.click(); } @@ -98,38 +110,47 @@ async createApp(appName: string, repoUrl: string, repoPath: string) { 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 expect(appContainer.getByText(/OutOfSync|Out of Sync/i).first()).toBeVisible({ timeout: 45000 }); - //safe to open the panel + await appContainer.waitFor({ state: 'visible', timeout: TIMEOUTS.load }); + + //critical cross-version fix: wait for Argo CD to finish its initial Git clone + //if we open the Sync panel before this happens, the resources list will be empty! + await expect(appContainer.getByText(/OutOfSync|Out of Sync/i).first()).toBeVisible({ timeout: TIMEOUTS.sync }); + + //now it is safe to open the panel await appContainer.getByText('Sync', { exact: true }).click(); - //click 'all' + //click 'all' first to ensure all resource checkboxes are ticked across newer Argo CD versions const allLink = this.page.getByRole('link', { name: 'all', exact: true }); try { - await allLink.waitFor({ state: 'visible', timeout: 5000 }); + await allLink.waitFor({ state: 'visible', timeout: TIMEOUTS.modal }); await allLink.click(); } catch (error) { - // all link didn't appear within 5 sec + //'all' link didn't appear which is normal for this version so do nothing. } - - //wait for the manifests to render on the panel - await expect(this.page.getByText(expectedResource).first()).toBeVisible({ timeout: 30000 }); + + //wait for the manifests to render on the panel (generous timeout for slower FIPS clusters) + await expect(this.page.getByText(expectedResource).first()).toBeVisible({ timeout: TIMEOUTS.render }); //click the main sync button 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: 15000 }); + await expect(this.page.getByText('SYNCHRONIZE RESOURCES')).toBeHidden({ timeout: TIMEOUTS.panel }); } 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 }); + //pass the message + await expect( + appContainer.getByText(/Sync failed/i), + `Argo CD failed to sync the application manifests for ${appName}.` + ).toBeHidden({ timeout: TIMEOUTS.panel }); + + //if it didn't fail to wait for success states + await expect(appContainer.getByText(/synced/i)).toBeVisible({ timeout: TIMEOUTS.status }); + await expect(appContainer.getByText(/healthy/i)).toBeVisible({ timeout: TIMEOUTS.status }); } async openApplication(appName: string) { @@ -141,10 +162,10 @@ async createApp(appName: string, repoUrl: string, repoPath: string) { .filter({ hasText: appName }) .getByRole('link', { name: appName }); - await appLink.waitFor({ state: 'visible', timeout: 15000 }); + await appLink.waitFor({ state: 'visible', timeout: TIMEOUTS.default }); await appLink.click(); //wait for the URL to change to the details page to ensure the click worked - await expect(this.page).toHaveURL(/.*\/applications\/.*\/.*/, { timeout: 15000 }); + await expect(this.page).toHaveURL(/.*\/applications\/.*\/.*/, { timeout: TIMEOUTS.default }); } } \ No newline at end of file From 075a3cf73f061ad3951a351eb5d4837ff568e830 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 24 Jun 2026 14:22:42 +0100 Subject: [PATCH 3/4] address additional coderabbit feedback.. Signed-off-by: Triona Doyle --- test/ui-e2e/.auth/setup.ts | 14 +++++++++----- test/ui-e2e/global.setup.ts | 6 +++--- test/ui-e2e/src/fixtures.ts | 16 +++++++++------- test/ui-e2e/src/pages/ApplicationsPage.ts | 21 ++++++++++++++++++--- 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/test/ui-e2e/.auth/setup.ts b/test/ui-e2e/.auth/setup.ts index a779029ac2c..03fad54e779 100644 --- a/test/ui-e2e/.auth/setup.ts +++ b/test/ui-e2e/.auth/setup.ts @@ -35,7 +35,7 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { console.log(`IDP selection screen detected. Selecting provider: "${idpName}"`); //look for the specific IDP - const idpLink = page.getByRole('link', { name: new RegExp(idpName, 'i') }); + const idpLink = page.getByRole('link', { name: idpName, exact: true }); await idpLink.waitFor({ state: 'visible', timeout: TIMEOUTS.short }); await idpLink.click(); @@ -58,7 +58,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(); - //handle the OpenShift 4.x Welcome Tour modal if it appears +//handle the OpenShift 4.x 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 @@ -66,9 +66,13 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { await skipTourButton.click(); console.log('Dismissed the OpenShift Welcome Tour modal.'); } catch (error) { - //if it doesn't appear within 5 seconds, it's an older cluster or already dismissed - //safely ignore the error and move on - console.debug('welcome tour modal did not appear, continuing...'); + if (error instanceof Error && error.name === 'TimeoutError') { + //safely ignore the timeout and move on + console.log('welcome tour modal did not appear, continuing...'); + } else { + //throw any other unexpected errors + throw error; + } } //save the auth state diff --git a/test/ui-e2e/global.setup.ts b/test/ui-e2e/global.setup.ts index 5c0bd668368..35f8044e3f7 100644 --- a/test/ui-e2e/global.setup.ts +++ b/test/ui-e2e/global.setup.ts @@ -1,7 +1,7 @@ import { execSync } from 'child_process'; async function globalSetup() { - console.log('🧹 Running pre-flight cleanup...'); + console.log(' * Running pre-flight cleanup...'); try { console.log(' -> Sweeping ghost applications...'); @@ -12,9 +12,9 @@ async function globalSetup() { //no hangs on dead controllers execSync('oc delete all -l app=spring-petclinic -n openshift-gitops --wait=false', { stdio: 'ignore' }); - console.log('✨ Cluster sanitized. Starting test suite.'); + console.log('* Cluster sanitized. Starting test suite.'); } catch (error) { - console.log('✨ Cluster is clean. Starting test suite.'); + console.log('* Cluster is clean. Starting test suite.'); } } diff --git a/test/ui-e2e/src/fixtures.ts b/test/ui-e2e/src/fixtures.ts index 33c1ce84962..29cea47f0f6 100644 --- a/test/ui-e2e/src/fixtures.ts +++ b/test/ui-e2e/src/fixtures.ts @@ -30,7 +30,6 @@ export const test = base.extend({ await use(page); }, - // 🚀 Cleaned up 'request' from the parameters, just using 'page' now managedApp: [ async ({ page }, use) => { const appName = `e2e-app-${Date.now()}`; const appsPage = new ApplicationsPage(page); @@ -51,7 +50,7 @@ export const test = base.extend({ //teardown console.log(`[teardown] deleting ${appName} via api`); - // 🚀 REVERTED: Back to page.request so we keep our UI login cookies! + //page.request const deleteResponse = await page.request.delete(`/api/v1/applications/${appName}?cascade=true`, { headers: { 'Content-Type': 'application/json' } }); @@ -67,21 +66,24 @@ export const test = base.extend({ console.log(`[teardown] waiting for background cleanup of ${appName} to finish...`); await expect.poll(async () => { try { - // 🚀 REVERTED: Back to page.request, but KEEPING the try/catch shield! const checkResponse = await page.request.get(`/api/v1/applications/${appName}`); const status = checkResponse.status(); - // 🚀 ACCEPT BOTH: 404 (Not Found) or 403 (Forbidden due to RBAC project scoping) + //404 (Not Found) or 403 (Forbidden due to RBAC project scoping) return status === 404 || status === 403; } catch (error) { - // If the OpenShift router blips or drops the socket, swallow it and keep polling - return false; + //router blips or drops the socket swallow it and keep polling + if (error instanceof Error && (error.message.includes('hang up') || error.message.includes('RESET') || error.message.includes('closed'))) { + return false; + } + //fail fast + throw error; } }, { message: `Waiting for ${appName} to completely delete from the cluster.`, timeout: 60000, intervals: [2000, 5000, 10000], - }).toBeTruthy(); // 🚀 Changed to check if our boolean logic returns true + }).toBeTruthy(); console.log(`[teardown] ${appName} successfully removed from the cluster.`); } diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index ec893f01065..dfe7db36660 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -58,7 +58,12 @@ export class ApplicationsPage { await errorBanner.waitFor({ state: 'visible', timeout: TIMEOUTS.short }); await errorBanner.click(); } catch (error) { - //banner didn't appear so just continue + //ignore if the banner timed out (wasn't present) + if (error instanceof Error && error.name === 'TimeoutError') { + //banner didn't appear so just continue + } else { + throw error; + } } await expect(this.newAppButton).toBeVisible({ timeout: TIMEOUTS.default }); @@ -84,7 +89,12 @@ export class ApplicationsPage { await errorBanner.waitFor({ state: 'visible', timeout: TIMEOUTS.short }); await errorBanner.click(); } catch (error) { - //banner didn't appear so just continue + //ignore if the banner timed out (wasn't present) + if (error instanceof Error && error.name === 'TimeoutError') { + // banner didn't appear so just continue + } else { + throw error; + } } await this.page.getByText('Loading...').first().waitFor({ state: 'hidden', timeout: TIMEOUTS.default }); @@ -125,7 +135,12 @@ export class ApplicationsPage { await allLink.waitFor({ state: 'visible', timeout: TIMEOUTS.modal }); await allLink.click(); } catch (error) { - //'all' link didn't appear which is normal for this version so do nothing. + //ignore if the link timed out (absent in older versions) + if (error instanceof Error && error.name === 'TimeoutError') { + //'all' link didn't appear which is normal for this version so do nothing. + } else { + throw error; + } } //wait for the manifests to render on the panel (generous timeout for slower FIPS clusters) From 17319d09d01b48a0dbbd1d008e259ec9be45e39d Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 24 Jun 2026 19:19:07 +0100 Subject: [PATCH 4/4] address yet more coderabbit feedback .. Signed-off-by: Triona Doyle --- test/ui-e2e/README.md | 2 +- test/ui-e2e/global.setup.ts | 7 +++--- test/ui-e2e/run-ui-tests.sh | 22 ++++++++++++------- .../src/pages/ApplicationDetailsPage.ts | 3 ++- test/ui-e2e/src/pages/ApplicationsPage.ts | 18 ++++++++------- 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/test/ui-e2e/README.md b/test/ui-e2e/README.md index 9ecd4afb805..fae9ca41057 100644 --- a/test/ui-e2e/README.md +++ b/test/ui-e2e/README.md @@ -68,7 +68,7 @@ All executions are driven via the ./run-ui-tests.sh wrapper script. This wrapper | `--headed` | Launches the visible Chromium browser UI. Excellent for local debugging. | | `--trace on` | Records a granular execution trace (DOM snapshots, network calls, actions) for visual triage. | | `--reporter=list` | Switches stdout to a clean line-by-line format, ideal for monitoring real-time execution steps. | -| `--env=` | Overrides the local setup to simulate automation. It forces headless execution, performs a clean `npm ci`, and installs required browser binaries dynamically. | +| --env= | Overrides the local setup to simulate automation. It forces headless execution, performs a clean `npm ci`, and installs required browser binaries dynamically. | ### Visual Debugging (Trace Viewer) diff --git a/test/ui-e2e/global.setup.ts b/test/ui-e2e/global.setup.ts index 35f8044e3f7..abad09b10e0 100644 --- a/test/ui-e2e/global.setup.ts +++ b/test/ui-e2e/global.setup.ts @@ -13,9 +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) { - console.log('* Cluster is clean. Starting test suite.'); - } + } catch (error) { + console.error('Pre-flight cleanup failed. Check your cluster connection.', error); + throw error; + } } export default globalSetup; \ No newline at end of file diff --git a/test/ui-e2e/run-ui-tests.sh b/test/ui-e2e/run-ui-tests.sh index 609aa691c1b..666d7b41fff 100755 --- a/test/ui-e2e/run-ui-tests.sh +++ b/test/ui-e2e/run-ui-tests.sh @@ -12,6 +12,9 @@ while [[ "$#" -gt 0 ]]; do shift done +#making sure we are in the correct dir +cd "$(dirname "$0")" || exit 1 + if [ -f .env ]; then echo "Loading variables from .env file..." set -a #export all variables @@ -19,9 +22,6 @@ if [ -f .env ]; then set +a #stop auto export fi -#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"} @@ -67,18 +67,24 @@ rm -f .auth/storageState.json || true echo " Starting Playwright tests..." # 2. Execute based on the environment -if [[ "$ENV" == "ci" ]] || [[ "$ENV" == "pipeline" ]]; then +if [ "$ENV" = "ci" ] || [ "$ENV" = "pipeline" ]; then echo "Running headlessly in automation ($ENV)..." npm ci - - # Prevent sudo jump-scares for local Mac users simulating CI - if [[ "$(uname -s)" == "Darwin" ]]; then + if [ "$(uname -s)" = "Darwin" ]; then npx playwright install chromium else npx playwright install chromium --with-deps fi - npx playwright test "${TEST_ARGS[@]}" --reporter=list + #headed from args + FILTERED_ARGS=() + for arg in "${TEST_ARGS[@]}"; do + if [[ "$arg" != "--headed" ]]; then + FILTERED_ARGS+=("$arg") + fi + done + + npx playwright test "${FILTERED_ARGS[@]}" --reporter=list else echo "Running Locally..." diff --git a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts index d6f4618a004..fa722ee0378 100644 --- a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts @@ -23,7 +23,8 @@ export class ApplicationDetailsPage { //wait tree to be visible await expect(this.resourceTreeContainer).toBeVisible({ timeout: 20000 }); //wait for healthy status - await expect(this.page.getByText('Healthy', { exact: true }).first()).toBeVisible({ timeout: 30000 }); + await expect(this.resourceTreeContainer.getByText('Healthy', { exact: true }).first()).toBeVisible({ timeout: 30000 }); + } async clickResourceNode(kind: string, name: string) { diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index dfe7db36660..4f7fdecb1e8 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -128,9 +128,11 @@ export class ApplicationsPage { //now it is safe to open the panel await appContainer.getByText('Sync', { exact: true }).click(); + + const slideOutPanel = this.page.locator('.sliding-panel').filter({ visible: true }); - //click 'all' first to ensure all resource checkboxes are ticked across newer Argo CD versions - const allLink = this.page.getByRole('link', { name: 'all', exact: true }); + // 🚀 SWAPPED: this.page is now slideOutPanel + const allLink = slideOutPanel.getByRole('link', { name: 'all', exact: true }); try { await allLink.waitFor({ state: 'visible', timeout: TIMEOUTS.modal }); await allLink.click(); @@ -143,11 +145,11 @@ export class ApplicationsPage { } } - //wait for the manifests to render on the panel (generous timeout for slower FIPS clusters) - await expect(this.page.getByText(expectedResource).first()).toBeVisible({ timeout: TIMEOUTS.render }); + // 🚀 SWAPPED: this.page is now slideOutPanel + await expect(slideOutPanel.getByText(expectedResource).first()).toBeVisible({ timeout: TIMEOUTS.render }); - //click the main sync button - await this.page.getByRole('button', { name: /^synchronize$/i }).first().click(); + // 🚀 SWAPPED: this.page is now slideOutPanel + await slideOutPanel.getByRole('button', { name: /^synchronize$/i }).first().click(); //wait for the panel to close await expect(this.page.getByText('SYNCHRONIZE RESOURCES')).toBeHidden({ timeout: TIMEOUTS.panel }); @@ -174,8 +176,8 @@ export class ApplicationsPage { //find the container, then specifically click the link of the app name const appLink = this.page.locator('.white-box, .argo-table-list__row') - .filter({ hasText: appName }) - .getByRole('link', { name: appName }); + .filter({ has: this.page.getByText(appName, { exact: true }) }) + .getByRole('link', { name: appName, exact: true }); await appLink.waitFor({ state: 'visible', timeout: TIMEOUTS.default }); await appLink.click();