From 6863e12d19bc9ef3b5d98464922673ae4d13bdd3 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 24 Jun 2026 13:52:51 +0100 Subject: [PATCH 01/17] test updates and address coderabbit feedback Signed-off-by: Triona Doyle --- test/ui-e2e/src/fixtures.ts | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/test/ui-e2e/src/fixtures.ts b/test/ui-e2e/src/fixtures.ts index f64787d22a1..29cea47f0f6 100644 --- a/test/ui-e2e/src/fixtures.ts +++ b/test/ui-e2e/src/fixtures.ts @@ -5,7 +5,6 @@ import { ApplicationsPage } from './pages/ApplicationsPage'; //define custom fixture types type MyFixtures = { managedApp: string; - argoVersion: string; }; export const test = base.extend({ @@ -31,33 +30,6 @@ export const test = base.extend({ await use(page); }, - //get target argocd version - argoVersion: async ({ page }, use) => { - try { - //get version - const response = await page.request.get('/api/version'); - - if (!response.ok()) { - throw new Error(`API returned status: ${response.status()}`); - } - - const data = await response.json(); - const fullVersion = data.Version || 'Unknown'; - - //extract the major.minor version (e.g., "v2.10.1" -> "2.10") - const match = fullVersion.match(/v(\d+\.\d+)/); - const version = match ? match[1] : '3.0'; - - //for debugging/CI logs - console.log(`TARGETING ARGO CD VERSION: ${fullVersion}`); - - await use(version); - } catch (error) { - console.warn(`\n[warn] Failed to fetch Argo CD version from API. Defaulting to 3.0. Reason: ${error instanceof Error ? error.message : 'Unknown'}\n`); - await use('3.0'); // Default to 3.0 - } - }, - managedApp: [ async ({ page }, use) => { const appName = `e2e-app-${Date.now()}`; const appsPage = new ApplicationsPage(page); From 7d6c8326e25ed77176201f1ebb9a1985276d10ad Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Fri, 26 Jun 2026 11:13:58 +0100 Subject: [PATCH 02/17] add Argocd version check and harden app health locators Signed-off-by: Triona Doyle --- test/ui-e2e/src/fixtures.ts | 28 +++++++++++++++++++++++++ test/ui-e2e/tests/resource-tree.spec.ts | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/test/ui-e2e/src/fixtures.ts b/test/ui-e2e/src/fixtures.ts index 29cea47f0f6..f64787d22a1 100644 --- a/test/ui-e2e/src/fixtures.ts +++ b/test/ui-e2e/src/fixtures.ts @@ -5,6 +5,7 @@ import { ApplicationsPage } from './pages/ApplicationsPage'; //define custom fixture types type MyFixtures = { managedApp: string; + argoVersion: string; }; export const test = base.extend({ @@ -30,6 +31,33 @@ export const test = base.extend({ await use(page); }, + //get target argocd version + argoVersion: async ({ page }, use) => { + try { + //get version + const response = await page.request.get('/api/version'); + + if (!response.ok()) { + throw new Error(`API returned status: ${response.status()}`); + } + + const data = await response.json(); + const fullVersion = data.Version || 'Unknown'; + + //extract the major.minor version (e.g., "v2.10.1" -> "2.10") + const match = fullVersion.match(/v(\d+\.\d+)/); + const version = match ? match[1] : '3.0'; + + //for debugging/CI logs + console.log(`TARGETING ARGO CD VERSION: ${fullVersion}`); + + await use(version); + } catch (error) { + console.warn(`\n[warn] Failed to fetch Argo CD version from API. Defaulting to 3.0. Reason: ${error instanceof Error ? error.message : 'Unknown'}\n`); + await use('3.0'); // Default to 3.0 + } + }, + managedApp: [ async ({ page }, use) => { const appName = `e2e-app-${Date.now()}`; const appsPage = new ApplicationsPage(page); diff --git a/test/ui-e2e/tests/resource-tree.spec.ts b/test/ui-e2e/tests/resource-tree.spec.ts index 4123e396f0b..e262fa9b221 100644 --- a/test/ui-e2e/tests/resource-tree.spec.ts +++ b/test/ui-e2e/tests/resource-tree.spec.ts @@ -6,7 +6,7 @@ test.describe('Argo CD Resource Tree and Pod Logs', () => { test.use({ storageState: '.auth/storageState.json' }); - test('Navigate to app details, open a Pod, and verify logs stream', async ({ page, managedApp }) => { + test('Navigate to app details, open a Pod, and verify logs stream', async ({ page, managedApp, argoVersion }) => { test.setTimeout(120000); const appsPage = new ApplicationsPage(page); From cfc7674ff53c7a631065ebbcb39f43b07fb9d400 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Tue, 21 Jul 2026 15:05:11 +0100 Subject: [PATCH 03/17] add UI test for clean application deletion Signed-off-by: Triona Doyle --- test/ui-e2e/tests/app-deletion.spec.ts | 157 +++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 test/ui-e2e/tests/app-deletion.spec.ts diff --git a/test/ui-e2e/tests/app-deletion.spec.ts b/test/ui-e2e/tests/app-deletion.spec.ts new file mode 100644 index 00000000000..b1bbc0c529e --- /dev/null +++ b/test/ui-e2e/tests/app-deletion.spec.ts @@ -0,0 +1,157 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { LoginPage } from '../src/pages/LoginPage'; + +test.describe('Clean Application Deletion (Pruning)', () => { + //make app name unique for test isolation + const appName = `ui-deletion-${Date.now()}`; + //pin revision to immutable commit SHA for reproducibility + const targetCommit = '8088f4c0d970abb09e250248cc97e35623447cb5'; + + test.beforeAll(async ({}, testInfo) => { + //set timeout to 120s + testInfo.setTimeout(120000); + console.log(`\n[setup] Deploying dummy application '${appName}' via CLI...`); + + //define standard guestbook app yaml targeting openshift-gitops namespace + const appYaml = ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: ${appName} + namespace: openshift-gitops +spec: + destination: + namespace: openshift-gitops + server: https://kubernetes.default.svc + project: default + source: + path: guestbook + repoURL: https://github.com/argoproj/argocd-example-apps.git + targetRevision: ${targetCommit} + syncPolicy: + automated: + prune: true + selfHeal: true +`; + try { + //deploy dummy app via cli with process timeout + execSync(`echo "${appYaml}" | oc apply -f -`, { stdio: 'pipe', timeout: 15000 }); + + //poll until argo cd controller populates sync status (bounded loop) + let isSynced = false; + for (let i = 1; i <= 15; i++) { + try { + const syncStatus = execSync(`oc get application ${appName} -n openshift-gitops -o jsonpath='{.status.sync.status}'`, { stdio: 'pipe', timeout: 3000 }).toString().trim(); + console.log(`[setup] Checking sync status (Attempt ${i}/15): '${syncStatus || 'Initializing...'}'`); + if (syncStatus === 'Synced') { + isSynced = true; + break; + } + } catch (e) { + console.log(`[setup] Checking sync status (Attempt ${i}/15): Waiting for resource...`); + } + await new Promise(resolve => setTimeout(resolve, 3000)); + } + + if (!isSynced) { + throw new Error(`Dummy application '${appName}' never reached Synced status.`); + } + } catch (e) { + console.error('Failed to pre-deploy dummy app', e); + throw e; + } + }); + + test.afterAll(async ({}, testInfo) => { + //set hook timeout to 60s + testInfo.setTimeout(60000); + console.log('\n[teardown] Ensuring application is cleaned up...'); + + //attempt fallback cleanup if ui deletion failed or was skipped + try { + execSync(`oc delete application ${appName} -n openshift-gitops --ignore-not-found --wait=true`, { stdio: 'pipe', timeout: 15000 }); + } catch (e) { + console.warn(`[teardown] Initial cleanup command failed: ${(e as Error).message}`); + } + + //verify resource is completely absent from cluster + let isDeleted = false; + for (let i = 1; i <= 5; i++) { + try { + execSync(`oc get application ${appName} -n openshift-gitops`, { stdio: 'pipe', timeout: 2000 }); + //if command succeeds resource still exists + await new Promise(resolve => setTimeout(resolve, 2000)); + } catch (e) { + //resource no longer exists + isDeleted = true; + break; + } + } + + if (!isDeleted) { + throw new Error(`[teardown] Cleanup verification failed: '${appName}' still exists on cluster.`); + } + }); + + test('Delete application via UI and verify cascading deletion', async ({ page }) => { + //set explicit test timeout budget + test.setTimeout(90000); + + //log into argo cd ui + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.loginViaOpenShift( + process.env.CLUSTER_USER!, + process.env.CLUSTER_PASSWORD!, + process.env.IDP || 'kube:admin' + ); + + //locate application card specifically bound to appName without broad div scanning + const appTile = page.locator('.application-tile, [class*="application-tile"], [class*="applications-list__entry"]') + .filter({ hasText: appName }); + + //ensure application tile appears on dashboard + await expect(appTile).toBeVisible({ timeout: 30000 }); + + //click delete button scoped specifically to this app card + const deleteBtn = appTile.locator('[qe-id="applications-tiles-button-delete"]'); + await deleteBtn.click(); + + //locate modal container via dialog role or confirmation prompt text + const modal = page.getByRole('dialog') + .or(page.locator('div').filter({ hasText: /to confirm the deletion/i })) + .first(); + await expect(modal).toBeVisible({ timeout: 15000 }); + + //type application name into confirmation field + const confirmInput = modal.getByRole('textbox').or(modal.locator('input')).first(); + await confirmInput.fill(appName); + + //confirm deletion + const okBtn = modal.getByRole('button', { name: /^ok$/i }).or(modal.locator('button').filter({ hasText: /^ok$/i })).first(); + await okBtn.click(); + + //assert modal closes after confirming + await expect(modal).toBeHidden({ timeout: 15000 }); + + //assert app tile disappears from ui dashboard + await expect(appTile).toBeHidden({ timeout: 30000 }); + + //verify backend cr deletion via cli directly within test block before teardown + let backendDeleted = false; + for (let i = 1; i <= 10; i++) { + try { + execSync(`oc get application ${appName} -n openshift-gitops`, { stdio: 'pipe', timeout: 2000 }); + //resource still present on cluster, wait before checking again + await new Promise(resolve => setTimeout(resolve, 2000)); + } catch (e) { + //oc command threw an error, meaning resource was deleted from kubernetes api + backendDeleted = true; + break; + } + } + + expect(backendDeleted).toBe(true); + }); +}); \ No newline at end of file From 80ced7ca19196dc14f2c7eeafb391f3d15a05266 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 24 Jun 2026 13:52:51 +0100 Subject: [PATCH 04/17] test updates and address coderabbit feedback Signed-off-by: Triona Doyle --- test/ui-e2e/src/fixtures.ts | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/test/ui-e2e/src/fixtures.ts b/test/ui-e2e/src/fixtures.ts index f64787d22a1..29cea47f0f6 100644 --- a/test/ui-e2e/src/fixtures.ts +++ b/test/ui-e2e/src/fixtures.ts @@ -5,7 +5,6 @@ import { ApplicationsPage } from './pages/ApplicationsPage'; //define custom fixture types type MyFixtures = { managedApp: string; - argoVersion: string; }; export const test = base.extend({ @@ -31,33 +30,6 @@ export const test = base.extend({ await use(page); }, - //get target argocd version - argoVersion: async ({ page }, use) => { - try { - //get version - const response = await page.request.get('/api/version'); - - if (!response.ok()) { - throw new Error(`API returned status: ${response.status()}`); - } - - const data = await response.json(); - const fullVersion = data.Version || 'Unknown'; - - //extract the major.minor version (e.g., "v2.10.1" -> "2.10") - const match = fullVersion.match(/v(\d+\.\d+)/); - const version = match ? match[1] : '3.0'; - - //for debugging/CI logs - console.log(`TARGETING ARGO CD VERSION: ${fullVersion}`); - - await use(version); - } catch (error) { - console.warn(`\n[warn] Failed to fetch Argo CD version from API. Defaulting to 3.0. Reason: ${error instanceof Error ? error.message : 'Unknown'}\n`); - await use('3.0'); // Default to 3.0 - } - }, - managedApp: [ async ({ page }, use) => { const appName = `e2e-app-${Date.now()}`; const appsPage = new ApplicationsPage(page); From 891c18ea52869d608396e5fcf2c81156bd315e6a Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Fri, 26 Jun 2026 11:13:58 +0100 Subject: [PATCH 05/17] add Argocd version check and harden app health locators Signed-off-by: Triona Doyle --- test/ui-e2e/src/fixtures.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/ui-e2e/src/fixtures.ts b/test/ui-e2e/src/fixtures.ts index 29cea47f0f6..f64787d22a1 100644 --- a/test/ui-e2e/src/fixtures.ts +++ b/test/ui-e2e/src/fixtures.ts @@ -5,6 +5,7 @@ import { ApplicationsPage } from './pages/ApplicationsPage'; //define custom fixture types type MyFixtures = { managedApp: string; + argoVersion: string; }; export const test = base.extend({ @@ -30,6 +31,33 @@ export const test = base.extend({ await use(page); }, + //get target argocd version + argoVersion: async ({ page }, use) => { + try { + //get version + const response = await page.request.get('/api/version'); + + if (!response.ok()) { + throw new Error(`API returned status: ${response.status()}`); + } + + const data = await response.json(); + const fullVersion = data.Version || 'Unknown'; + + //extract the major.minor version (e.g., "v2.10.1" -> "2.10") + const match = fullVersion.match(/v(\d+\.\d+)/); + const version = match ? match[1] : '3.0'; + + //for debugging/CI logs + console.log(`TARGETING ARGO CD VERSION: ${fullVersion}`); + + await use(version); + } catch (error) { + console.warn(`\n[warn] Failed to fetch Argo CD version from API. Defaulting to 3.0. Reason: ${error instanceof Error ? error.message : 'Unknown'}\n`); + await use('3.0'); // Default to 3.0 + } + }, + managedApp: [ async ({ page }, use) => { const appName = `e2e-app-${Date.now()}`; const appsPage = new ApplicationsPage(page); From 6081821228df5f4c3e4d75367eed0e1c3e21f1e8 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 24 Jun 2026 13:52:51 +0100 Subject: [PATCH 06/17] test updates and address coderabbit feedback Signed-off-by: Triona Doyle --- test/ui-e2e/src/fixtures.ts | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/test/ui-e2e/src/fixtures.ts b/test/ui-e2e/src/fixtures.ts index f64787d22a1..29cea47f0f6 100644 --- a/test/ui-e2e/src/fixtures.ts +++ b/test/ui-e2e/src/fixtures.ts @@ -5,7 +5,6 @@ import { ApplicationsPage } from './pages/ApplicationsPage'; //define custom fixture types type MyFixtures = { managedApp: string; - argoVersion: string; }; export const test = base.extend({ @@ -31,33 +30,6 @@ export const test = base.extend({ await use(page); }, - //get target argocd version - argoVersion: async ({ page }, use) => { - try { - //get version - const response = await page.request.get('/api/version'); - - if (!response.ok()) { - throw new Error(`API returned status: ${response.status()}`); - } - - const data = await response.json(); - const fullVersion = data.Version || 'Unknown'; - - //extract the major.minor version (e.g., "v2.10.1" -> "2.10") - const match = fullVersion.match(/v(\d+\.\d+)/); - const version = match ? match[1] : '3.0'; - - //for debugging/CI logs - console.log(`TARGETING ARGO CD VERSION: ${fullVersion}`); - - await use(version); - } catch (error) { - console.warn(`\n[warn] Failed to fetch Argo CD version from API. Defaulting to 3.0. Reason: ${error instanceof Error ? error.message : 'Unknown'}\n`); - await use('3.0'); // Default to 3.0 - } - }, - managedApp: [ async ({ page }, use) => { const appName = `e2e-app-${Date.now()}`; const appsPage = new ApplicationsPage(page); From 02ec641d0860ba6d48de966f33f607520f376eb6 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Fri, 26 Jun 2026 11:13:58 +0100 Subject: [PATCH 07/17] add Argocd version check and harden app health locators Signed-off-by: Triona Doyle --- test/ui-e2e/src/fixtures.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/ui-e2e/src/fixtures.ts b/test/ui-e2e/src/fixtures.ts index 29cea47f0f6..f64787d22a1 100644 --- a/test/ui-e2e/src/fixtures.ts +++ b/test/ui-e2e/src/fixtures.ts @@ -5,6 +5,7 @@ import { ApplicationsPage } from './pages/ApplicationsPage'; //define custom fixture types type MyFixtures = { managedApp: string; + argoVersion: string; }; export const test = base.extend({ @@ -30,6 +31,33 @@ export const test = base.extend({ await use(page); }, + //get target argocd version + argoVersion: async ({ page }, use) => { + try { + //get version + const response = await page.request.get('/api/version'); + + if (!response.ok()) { + throw new Error(`API returned status: ${response.status()}`); + } + + const data = await response.json(); + const fullVersion = data.Version || 'Unknown'; + + //extract the major.minor version (e.g., "v2.10.1" -> "2.10") + const match = fullVersion.match(/v(\d+\.\d+)/); + const version = match ? match[1] : '3.0'; + + //for debugging/CI logs + console.log(`TARGETING ARGO CD VERSION: ${fullVersion}`); + + await use(version); + } catch (error) { + console.warn(`\n[warn] Failed to fetch Argo CD version from API. Defaulting to 3.0. Reason: ${error instanceof Error ? error.message : 'Unknown'}\n`); + await use('3.0'); // Default to 3.0 + } + }, + managedApp: [ async ({ page }, use) => { const appName = `e2e-app-${Date.now()}`; const appsPage = new ApplicationsPage(page); From d8c58975b11fa64a13cc81ae41cc8ffe9a3ca0b5 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 24 Jun 2026 13:52:51 +0100 Subject: [PATCH 08/17] test updates and address coderabbit feedback Signed-off-by: Triona Doyle --- test/ui-e2e/src/fixtures.ts | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/test/ui-e2e/src/fixtures.ts b/test/ui-e2e/src/fixtures.ts index f64787d22a1..29cea47f0f6 100644 --- a/test/ui-e2e/src/fixtures.ts +++ b/test/ui-e2e/src/fixtures.ts @@ -5,7 +5,6 @@ import { ApplicationsPage } from './pages/ApplicationsPage'; //define custom fixture types type MyFixtures = { managedApp: string; - argoVersion: string; }; export const test = base.extend({ @@ -31,33 +30,6 @@ export const test = base.extend({ await use(page); }, - //get target argocd version - argoVersion: async ({ page }, use) => { - try { - //get version - const response = await page.request.get('/api/version'); - - if (!response.ok()) { - throw new Error(`API returned status: ${response.status()}`); - } - - const data = await response.json(); - const fullVersion = data.Version || 'Unknown'; - - //extract the major.minor version (e.g., "v2.10.1" -> "2.10") - const match = fullVersion.match(/v(\d+\.\d+)/); - const version = match ? match[1] : '3.0'; - - //for debugging/CI logs - console.log(`TARGETING ARGO CD VERSION: ${fullVersion}`); - - await use(version); - } catch (error) { - console.warn(`\n[warn] Failed to fetch Argo CD version from API. Defaulting to 3.0. Reason: ${error instanceof Error ? error.message : 'Unknown'}\n`); - await use('3.0'); // Default to 3.0 - } - }, - managedApp: [ async ({ page }, use) => { const appName = `e2e-app-${Date.now()}`; const appsPage = new ApplicationsPage(page); From 076cbfd6338cf42edeceab9c13e830f111a17e6a Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Fri, 26 Jun 2026 11:13:58 +0100 Subject: [PATCH 09/17] add Argocd version check and harden app health locators Signed-off-by: Triona Doyle --- test/ui-e2e/src/fixtures.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/ui-e2e/src/fixtures.ts b/test/ui-e2e/src/fixtures.ts index 29cea47f0f6..f64787d22a1 100644 --- a/test/ui-e2e/src/fixtures.ts +++ b/test/ui-e2e/src/fixtures.ts @@ -5,6 +5,7 @@ import { ApplicationsPage } from './pages/ApplicationsPage'; //define custom fixture types type MyFixtures = { managedApp: string; + argoVersion: string; }; export const test = base.extend({ @@ -30,6 +31,33 @@ export const test = base.extend({ await use(page); }, + //get target argocd version + argoVersion: async ({ page }, use) => { + try { + //get version + const response = await page.request.get('/api/version'); + + if (!response.ok()) { + throw new Error(`API returned status: ${response.status()}`); + } + + const data = await response.json(); + const fullVersion = data.Version || 'Unknown'; + + //extract the major.minor version (e.g., "v2.10.1" -> "2.10") + const match = fullVersion.match(/v(\d+\.\d+)/); + const version = match ? match[1] : '3.0'; + + //for debugging/CI logs + console.log(`TARGETING ARGO CD VERSION: ${fullVersion}`); + + await use(version); + } catch (error) { + console.warn(`\n[warn] Failed to fetch Argo CD version from API. Defaulting to 3.0. Reason: ${error instanceof Error ? error.message : 'Unknown'}\n`); + await use('3.0'); // Default to 3.0 + } + }, + managedApp: [ async ({ page }, use) => { const appName = `e2e-app-${Date.now()}`; const appsPage = new ApplicationsPage(page); From 7e58a78abed91e539b8de9a3d79aea8b9d0a1b80 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Mon, 10 Aug 2026 12:23:53 +0100 Subject: [PATCH 10/17] address inital codeRabbit feedback Signed-off-by: Triona Doyle --- test/ui-e2e/tests/app-deletion.spec.ts | 140 +++++++++++++++++------- test/ui-e2e/tests/resource-tree.spec.ts | 2 +- 2 files changed, 102 insertions(+), 40 deletions(-) diff --git a/test/ui-e2e/tests/app-deletion.spec.ts b/test/ui-e2e/tests/app-deletion.spec.ts index b1bbc0c529e..37b7d87448c 100644 --- a/test/ui-e2e/tests/app-deletion.spec.ts +++ b/test/ui-e2e/tests/app-deletion.spec.ts @@ -1,6 +1,5 @@ -import { test, expect } from '@playwright/test'; -import { execSync } from 'child_process'; -import { LoginPage } from '../src/pages/LoginPage'; +import { test, expect } from '../src/fixtures'; +import { execFileSync } from 'child_process'; test.describe('Clean Application Deletion (Pruning)', () => { //make app name unique for test isolation @@ -8,11 +7,37 @@ test.describe('Clean Application Deletion (Pruning)', () => { //pin revision to immutable commit SHA for reproducibility const targetCommit = '8088f4c0d970abb09e250248cc97e35623447cb5'; + //returns true when the application cr is still present + const applicationExists = (): boolean => { + const out = execFileSync( + 'oc', + ['get', 'application', appName, '-n', 'openshift-gitops', '--ignore-not-found', '-o', 'name'], + { stdio: 'pipe', timeout: 5000 } + ).toString().trim(); + return out.length > 0; + }; + + //guestbook always creates deploy/svc named guestbook-ui. use --ignore-not-found -o name + //(empty = gone; real oc errors still throw). avoids instance-label lookups that fail under annotation tracking. + const remainingChildResources = (): string => { + const kinds = ['deploy', 'svc'] as const; + return kinds + .map((kind) => + execFileSync( + 'oc', + ['get', kind, 'guestbook-ui', '-n', 'openshift-gitops', '--ignore-not-found', '-o', 'name'], + { stdio: 'pipe', timeout: 5000 } + ).toString().trim() + ) + .filter(Boolean) + .join('\n'); + }; + test.beforeAll(async ({}, testInfo) => { - //set timeout to 120s - testInfo.setTimeout(120000); + //set timeout to 150s (sync poll can take ~90s after a fresh install) + testInfo.setTimeout(150000); console.log(`\n[setup] Deploying dummy application '${appName}' via CLI...`); - + //define standard guestbook app yaml targeting openshift-gitops namespace const appYaml = ` apiVersion: argoproj.io/v1alpha1 @@ -35,27 +60,58 @@ spec: selfHeal: true `; try { + //clear leftover guestbook children that can leave a new app stuck Unknown/OutOfSync + for (const kind of ['deploy', 'svc'] as const) { + execFileSync( + 'oc', + ['delete', kind, 'guestbook-ui', '-n', 'openshift-gitops', '--ignore-not-found', '--wait=false'], + { stdio: 'pipe', timeout: 15000 } + ); + } + //deploy dummy app via cli with process timeout - execSync(`echo "${appYaml}" | oc apply -f -`, { stdio: 'pipe', timeout: 15000 }); - - //poll until argo cd controller populates sync status (bounded loop) + execFileSync('oc', ['apply', '-f', '-'], { input: appYaml, stdio: 'pipe', timeout: 15000 }); + + //poll until argo cd reports synced (unknown is common while repo-server warms up) let isSynced = false; - for (let i = 1; i <= 15; i++) { + let lastSync = ''; + let lastHealth = ''; + let lastMessage = ''; + for (let i = 1; i <= 30; i++) { try { - const syncStatus = execSync(`oc get application ${appName} -n openshift-gitops -o jsonpath='{.status.sync.status}'`, { stdio: 'pipe', timeout: 3000 }).toString().trim(); - console.log(`[setup] Checking sync status (Attempt ${i}/15): '${syncStatus || 'Initializing...'}'`); - if (syncStatus === 'Synced') { + lastSync = execFileSync( + 'oc', + ['get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.sync.status}'], + { stdio: 'pipe', timeout: 3000 } + ).toString().trim(); + lastHealth = execFileSync( + 'oc', + ['get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.health.status}'], + { stdio: 'pipe', timeout: 3000 } + ).toString().trim(); + lastMessage = execFileSync( + 'oc', + ['get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.conditions[0].message}'], + { stdio: 'pipe', timeout: 3000 } + ).toString().trim(); + console.log( + `[setup] Checking sync status (Attempt ${i}/30): sync='${lastSync || 'Initializing...'}' health='${lastHealth || '-'}'` + ); + if (lastSync === 'Synced') { isSynced = true; break; } } catch (e) { - console.log(`[setup] Checking sync status (Attempt ${i}/15): Waiting for resource...`); + console.log(`[setup] Checking sync status (Attempt ${i}/30): Waiting for resource...`); } await new Promise(resolve => setTimeout(resolve, 3000)); } if (!isSynced) { - throw new Error(`Dummy application '${appName}' never reached Synced status.`); + throw new Error( + `Dummy application '${appName}' never reached Synced status ` + + `(last sync='${lastSync || '-'}' health='${lastHealth || '-'}' message='${lastMessage || '-'}').` + ); } } catch (e) { console.error('Failed to pre-deploy dummy app', e); @@ -67,10 +123,14 @@ spec: //set hook timeout to 60s testInfo.setTimeout(60000); console.log('\n[teardown] Ensuring application is cleaned up...'); - + //attempt fallback cleanup if ui deletion failed or was skipped try { - execSync(`oc delete application ${appName} -n openshift-gitops --ignore-not-found --wait=true`, { stdio: 'pipe', timeout: 15000 }); + execFileSync( + 'oc', + ['delete', 'application', appName, '-n', 'openshift-gitops', '--ignore-not-found', '--wait=true'], + { stdio: 'pipe', timeout: 15000 } + ); } catch (e) { console.warn(`[teardown] Initial cleanup command failed: ${(e as Error).message}`); } @@ -78,15 +138,12 @@ spec: //verify resource is completely absent from cluster let isDeleted = false; for (let i = 1; i <= 5; i++) { - try { - execSync(`oc get application ${appName} -n openshift-gitops`, { stdio: 'pipe', timeout: 2000 }); - //if command succeeds resource still exists - await new Promise(resolve => setTimeout(resolve, 2000)); - } catch (e) { - //resource no longer exists + if (!applicationExists()) { isDeleted = true; break; } + //resource still exists, wait before checking again + await new Promise(resolve => setTimeout(resolve, 2000)); } if (!isDeleted) { @@ -98,15 +155,6 @@ spec: //set explicit test timeout budget test.setTimeout(90000); - //log into argo cd ui - const loginPage = new LoginPage(page); - await loginPage.goto(); - await loginPage.loginViaOpenShift( - process.env.CLUSTER_USER!, - process.env.CLUSTER_PASSWORD!, - process.env.IDP || 'kube:admin' - ); - //locate application card specifically bound to appName without broad div scanning const appTile = page.locator('.application-tile, [class*="application-tile"], [class*="applications-list__entry"]') .filter({ hasText: appName }); @@ -114,6 +162,9 @@ spec: //ensure application tile appears on dashboard await expect(appTile).toBeVisible({ timeout: 30000 }); + //confirm guestbook children exist before delete so cascade assertion is meaningful + expect(remainingChildResources()).not.toBe(''); + //click delete button scoped specifically to this app card const deleteBtn = appTile.locator('[qe-id="applications-tiles-button-delete"]'); await deleteBtn.click(); @@ -141,17 +192,28 @@ spec: //verify backend cr deletion via cli directly within test block before teardown let backendDeleted = false; for (let i = 1; i <= 10; i++) { - try { - execSync(`oc get application ${appName} -n openshift-gitops`, { stdio: 'pipe', timeout: 2000 }); - //resource still present on cluster, wait before checking again - await new Promise(resolve => setTimeout(resolve, 2000)); - } catch (e) { - //oc command threw an error, meaning resource was deleted from kubernetes api + if (!applicationExists()) { backendDeleted = true; break; } + //resource still present on cluster, wait before checking again + await new Promise(resolve => setTimeout(resolve, 2000)); } expect(backendDeleted).toBe(true); + + //verify cascading deletion removed guestbook deploy/svc + let childrenGone = false; + for (let i = 1; i <= 10; i++) { + const remaining = remainingChildResources(); + if (remaining === '') { + childrenGone = true; + break; + } + console.log(`[verify] Waiting for child resources to prune (Attempt ${i}/10): ${remaining}`); + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + expect(childrenGone).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/test/ui-e2e/tests/resource-tree.spec.ts b/test/ui-e2e/tests/resource-tree.spec.ts index e262fa9b221..4123e396f0b 100644 --- a/test/ui-e2e/tests/resource-tree.spec.ts +++ b/test/ui-e2e/tests/resource-tree.spec.ts @@ -6,7 +6,7 @@ test.describe('Argo CD Resource Tree and Pod Logs', () => { test.use({ storageState: '.auth/storageState.json' }); - test('Navigate to app details, open a Pod, and verify logs stream', async ({ page, managedApp, argoVersion }) => { + test('Navigate to app details, open a Pod, and verify logs stream', async ({ page, managedApp }) => { test.setTimeout(120000); const appsPage = new ApplicationsPage(page); From cde1db762ef00f2669c931ab6f814410168fb39b Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Mon, 10 Aug 2026 14:47:29 +0100 Subject: [PATCH 11/17] address further codeRabbit feedback for timeout Signed-off-by: Triona Doyle --- test/ui-e2e/tests/app-deletion.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/ui-e2e/tests/app-deletion.spec.ts b/test/ui-e2e/tests/app-deletion.spec.ts index 37b7d87448c..9991611fae8 100644 --- a/test/ui-e2e/tests/app-deletion.spec.ts +++ b/test/ui-e2e/tests/app-deletion.spec.ts @@ -152,8 +152,8 @@ spec: }); test('Delete application via UI and verify cascading deletion', async ({ page }) => { - //set explicit test timeout budget - test.setTimeout(90000); + //covers ui waits plus backend/child prune polling budgets + test.setTimeout(180000); //locate application card specifically bound to appName without broad div scanning const appTile = page.locator('.application-tile, [class*="application-tile"], [class*="applications-list__entry"]') From 7541c51604d2cca09c4d6fafd2a32012c0eb89d5 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 12 Aug 2026 16:53:42 +0100 Subject: [PATCH 12/17] Add new tests: auto sync self and private repo Signed-off-by: Triona Doyle --- test/ui-e2e/.auth/setup.ts | 18 +- test/ui-e2e/README.md | 16 +- .../src/pages/ApplicationDetailsPage.ts | 101 +++++- test/ui-e2e/src/pages/ApplicationsPage.ts | 19 +- .../src/pages/SettingsRepositoriesPage.ts | 322 ++++++++++++++++++ test/ui-e2e/tests/auto-sync-self-heal.spec.ts | 121 +++++++ test/ui-e2e/tests/private-repo.spec.ts | 28 ++ test/ui-e2e/tests/resource-tree.spec.ts | 16 +- 8 files changed, 610 insertions(+), 31 deletions(-) create mode 100644 test/ui-e2e/src/pages/SettingsRepositoriesPage.ts create mode 100644 test/ui-e2e/tests/auto-sync-self-heal.spec.ts create mode 100644 test/ui-e2e/tests/private-repo.spec.ts diff --git a/test/ui-e2e/.auth/setup.ts b/test/ui-e2e/.auth/setup.ts index 8cf9728ee04..7c26b334911 100644 --- a/test/ui-e2e/.auth/setup.ts +++ b/test/ui-e2e/.auth/setup.ts @@ -60,17 +60,25 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { //handle the openshift welcome tour modal if it appears try { - const skipTourButton = page.getByRole('button', { name: /skip tour/i }); - //wait briefly for the modal to pop up - await skipTourButton.waitFor({ state: 'visible', timeout: TIMEOUTS.short }); - await skipTourButton.click(); + const welcomeDialog = page.getByRole('dialog').filter({ + has: page.getByRole('heading', { name: /Welcome to the new OpenShift experience/i }) + }); + await welcomeDialog.waitFor({ state: 'visible', timeout: TIMEOUTS.default }); + const skipTour = welcomeDialog.getByRole('button', { name: /skip tour/i }); + const closeBtn = welcomeDialog.getByRole('button', { name: /^close$/i }); + if (await skipTour.isVisible()) { + await skipTour.click(); + } else { + await closeBtn.click(); + } + await expect(welcomeDialog).toBeHidden({ timeout: TIMEOUTS.medium }); console.log('Dismissed the OpenShift Welcome Tour modal.'); } catch (error) { 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 any other unexpected errors throw error; } } diff --git a/test/ui-e2e/README.md b/test/ui-e2e/README.md index d9e1b9092a1..5ab80aab6d1 100644 --- a/test/ui-e2e/README.md +++ b/test/ui-e2e/README.md @@ -38,11 +38,18 @@ export CLUSTER_USER="kubeadmin" export CLUSTER_PASSWORD="" export OC_API_URL="" export IDP="kube:admin" # (Optional) Defaults to kube:admin + +# Optional — private-repo.spec.ts (credentials in Bitwarden) +export PRIVATE_REPO_URL="" +export PRIVATE_REPO_USERNAME="" +export PRIVATE_REPO_TOKEN="" EOF ``` > **Security Warning:** The `.env` file is explicitly ignored by Git. Please don't commit credentials to the repository. +The private repository test is **skipped** unless `PRIVATE_REPO_URL` and `PRIVATE_REPO_TOKEN` (or `PRIVATE_REPO_PASSWORD`) are set. Shared values are available from **Bitwarden**. + --- ## Execution Commands @@ -91,11 +98,14 @@ npx playwright show-trace test-results/create-application-chromium/trace.zip │ └── setup.ts # Orchestrates global OCP authentication & saves storageState.json ├── src/ │ └── pages/ # Page Object Models (POM) isolating UI selectors from spec logic -│ └── ApplicationsPage.ts +│ ├── ApplicationsPage.ts +│ └── SettingsRepositoriesPage.ts ├── tests/ # Test specs organized by feature epic │ ├── admin-login.spec.ts │ ├── create-application.spec.ts -│ └── resource-tree.spec.ts +│ ├── resource-tree.spec.ts +│ ├── auto-sync-self-heal.spec.ts +│ └── private-repo.spec.ts ├── .env # Local runtime environment overrides (Git ignored) └── run-ui-tests.sh # Context-aware orchestrator & URL discovery engine ``` @@ -113,4 +123,4 @@ npx playwright show-trace test-results/create-application-chromium/trace.zip ### Symptom: Playwright targets the wrong cluster version * **Cause:** The wrapper script handles cross-cluster contexts dynamically. If your terminal environment variables don't match your local `~/.kube/config` cache, your terminal may fall back to cached sessions. -* **Resolution:** Ensure you either run `source .env` inside your terminal window to reset active shell contexts, or verify that the variables declared within your `.env` file match your active target system configuration. \ No newline at end of file +* **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/src/pages/ApplicationDetailsPage.ts b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts index ff9a75b4d05..80adbcc65bf 100644 --- a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts @@ -66,4 +66,103 @@ export class ApplicationDetailsPage { await expect(genericLogLine).toBeVisible({ timeout: 30000 }); } } -} \ No newline at end of file + + async openAppDetailsPanel() { + await this.page.getByText('Details', { exact: true }).first().click(); + await expect(this.page.getByText('SYNC POLICY')).toBeVisible({ timeout: 15000 }); + } + + private async confirmArgoPopup() { + const ok = this.page.locator('[qe-id="argo-popup-ok-button"]'); + await expect(ok).toBeVisible({ timeout: 10000 }); + await ok.click(); + await expect(ok).toBeHidden({ timeout: 15000 }); + } + + private syncPolicyRow(label: RegExp): Locator { + return this.page + .locator('.row.white-box__details-row, .white-box__details-row') + .filter({ hasText: label }); + } + + private async waitForAutomatedFlag(appName: string, flag: 'prune' | 'selfHeal') { + await expect + .poll( + async () => { + const res = await this.page.request.get(`/api/v1/applications/${appName}`); + if (!res.ok()) return false; + const app = await res.json(); + return app?.spec?.syncPolicy?.automated?.[flag] === true; + }, + { timeout: 30000, message: `waiting for automated.${flag}=true on ${appName}` } + ) + .toBeTruthy(); + } + + //1.19: Enable buttons; 1.20+: checkboxes + private async enablePruneOrSelfHeal(kind: 'prune' | 'selfHeal') { + const checkboxId = kind === 'prune' ? 'prune-resources' : 'self-heal'; + const rowLabel = kind === 'prune' ? /PRUNE RESOURCES/i : /SELF HEAL/i; + const checkbox = this.page.locator(`#${checkboxId}`); + + if (await checkbox.isVisible()) { + await checkbox.click(); + await this.confirmArgoPopup(); + return; + } + + const enableBtn = this.syncPolicyRow(rowLabel).getByRole('button', { name: /^Enable$/i }); + await expect(enableBtn).toBeVisible({ timeout: 15000 }); + await enableBtn.click(); + await this.confirmArgoPopup(); + } + + async enableAutoSyncWithPruneAndSelfHeal(appName: string) { + //1.19: Enable Auto-Sync button; 1.20+: #enable-auto-sync + const enableBtn = this.page.getByRole('button', { name: /^Enable Auto-Sync$/i }); + const autoSyncCheckbox = this.page.locator('#enable-auto-sync'); + + await expect(enableBtn.or(autoSyncCheckbox).first()).toBeVisible({ timeout: 15000 }); + if (await enableBtn.isVisible()) { + await enableBtn.click(); + } else { + await autoSyncCheckbox.click(); + } + await this.confirmArgoPopup(); + + await expect(this.page.getByText('AUTOMATED', { exact: true })).toBeVisible({ timeout: 15000 }); + + const pruneCheckbox = this.page.locator('#prune-resources'); + const pruneEnableBtn = this.syncPolicyRow(/PRUNE RESOURCES/i).getByRole('button', { name: /^Enable$/i }); + await expect(pruneCheckbox.or(pruneEnableBtn).first()).toBeVisible({ timeout: 15000 }); + + await this.enablePruneOrSelfHeal('prune'); + await this.waitForAutomatedFlag(appName, 'prune'); + + await this.enablePruneOrSelfHeal('selfHeal'); + await this.waitForAutomatedFlag(appName, 'selfHeal'); + await this.waitForAutomatedFlag(appName, 'prune'); + } + + async assertAutoSyncPruneSelfHealEnabled() { + await expect(this.page.getByText('AUTOMATED', { exact: true })).toBeVisible(); + + const pruneCheckbox = this.page.locator('#prune-resources'); + const selfHealCheckbox = this.page.locator('#self-heal'); + const autoSyncCheckbox = this.page.locator('#enable-auto-sync'); + + if (await pruneCheckbox.isVisible() && await selfHealCheckbox.isVisible()) { + await expect(autoSyncCheckbox).toBeChecked(); + await expect(pruneCheckbox).toBeChecked(); + await expect(selfHealCheckbox).toBeChecked(); + return; + } + + await expect( + this.syncPolicyRow(/PRUNE RESOURCES/i).getByRole('button', { name: /^Disable$/i }) + ).toBeVisible(); + await expect( + this.syncPolicyRow(/SELF HEAL/i).getByRole('button', { name: /^Disable$/i }) + ).toBeVisible(); + } +} diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index d882139ef9e..27a08ac6941 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -167,18 +167,15 @@ export class ApplicationsPage { } 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({ has: this.page.getByText(appName, { exact: true }) }) - .getByRole('link', { name: appName, exact: true }); - - 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 + + const appCard = this.page + .locator('.white-box, .argo-table-list__row, .application-tile, [class*="application-tile"], [class*="applications-list__entry"]') + .filter({ hasText: appName }); + const appNameLink = appCard.getByText(appName, { exact: true }).first(); + + await expect(appNameLink).toBeVisible({ timeout: TIMEOUTS.load }); + await appNameLink.click(); await expect(this.page).toHaveURL(/.*\/applications\/.*\/.*/, { timeout: TIMEOUTS.default }); } } \ No newline at end of file diff --git a/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts b/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts new file mode 100644 index 00000000000..e618afea988 --- /dev/null +++ b/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts @@ -0,0 +1,322 @@ +import { Page, expect, Locator } from '@playwright/test'; +import { execSync } from 'node:child_process'; + +export class SettingsRepositoriesPage { + readonly page: Page; + readonly connectRepoButton: Locator; + + constructor(page: Page) { + this.page = page; + this.connectRepoButton = page + .getByRole('button', { name: /Connect Repo/i }) + .or(page.getByText('Connect Repo', { exact: true })); + } + + private passwordField() { + return this.page.getByLabel(/^Password/i); + } + + private usernameField() { + return this.page.getByLabel(/Username/i); + } + + private slidingPanel(): Locator { + return this.page.locator('.sliding-panel--opened, .sliding-panel').filter({ visible: true }).first(); + } + + private repoRow(repoUrl: string): Locator { + return this.page.locator('.argo-table-list__row, .white-box, tr').filter({ hasText: repoUrl }).first(); + } + + //redact to avoid leaking tokens + private redact(text: string): string { + let out = text; + for (const secret of [ + process.env.PRIVATE_REPO_TOKEN, + process.env.PRIVATE_REPO_PASSWORD, + process.env.CLUSTER_PASSWORD, + ]) { + if (secret && secret.length > 0) { + out = out.split(secret).join(''); + } + } + return out; + } + + private async clearSecretsFromForm() { + await this.passwordField().fill('', { timeout: 2000 }).catch(() => undefined); + await this.usernameField().fill('', { timeout: 2000 }).catch(() => undefined); + } + + //SSO api delete often 403/415 — remove matching repo secrets via oc + private deleteRepoSecretsViaOc(repoUrl: string): number { + let raw: string; + try { + raw = execSync('oc get secrets -n openshift-gitops -o json', { + encoding: 'utf8', + timeout: 60000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (e) { + console.warn(`[private-repo] oc get secrets failed: ${(e as Error).message}`); + return 0; + } + + const data = JSON.parse(raw) as { + items?: Array<{ metadata?: { name?: string }; data?: Record }>; + }; + const toDelete: string[] = []; + for (const secret of data.items || []) { + const name = secret.metadata?.name; + const b64 = secret.data || {}; + if (!name) continue; + + let urlVal = ''; + if (b64.url) { + try { + urlVal = Buffer.from(b64.url, 'base64').toString('utf8').trim(); + } catch { + urlVal = ''; + } + } + if (urlVal === repoUrl) { + toDelete.push(name); + continue; + } + + let blob = ''; + for (const v of Object.values(b64)) { + try { + blob += Buffer.from(v, 'base64').toString('utf8') + '\n'; + } catch { + /* ignore */ + } + } + if (blob.includes(repoUrl)) { + toDelete.push(name); + } + } + + let deleted = 0; + for (const name of [...new Set(toDelete)]) { + try { + execSync(`oc delete secret ${name} -n openshift-gitops --wait=true`, { + encoding: 'utf8', + timeout: 60000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + deleted += 1; + console.log(`[private-repo] deleted repo secret via oc (${deleted})`); + } catch (e) { + console.warn(`[private-repo] oc delete secret failed: ${(e as Error).message}`); + } + } + return deleted; + } + + private async tryDeleteRepoViaApi(repoUrl: string): Promise { + const encodings = [ + encodeURIComponent(repoUrl), + encodeURIComponent(encodeURIComponent(repoUrl)), + ]; + let lastStatus = 0; + for (const encoded of encodings) { + for (const headers of [ + { 'Content-Type': 'application/json' }, + { 'Content-Type': 'application/json', Accept: 'application/json' }, + ]) { + const response = await this.page.request.delete(`/api/v1/repositories/${encoded}`, { + headers, + data: {}, + }); + lastStatus = response.status(); + if (response.ok() || lastStatus === 404) { + return true; + } + } + } + console.warn(`[private-repo] api delete unavailable (last status ${lastStatus})`); + return false; + } + + private async tryDeleteRepoViaUi(repoUrl: string): Promise { + await this.page.goto('/settings/repos'); + await expect(this.connectRepoButton).toBeVisible({ timeout: 20000 }); + await expect(this.page.getByText('Loading...', { exact: true })).toHaveCount(0, { timeout: 60000 }); + + const row = this.repoRow(repoUrl); + if (!(await row.isVisible().catch(() => false))) { + return true; + } + + console.log('[private-repo] removing repo via ui'); + const menuBtn = row + .getByRole('button') + .filter({ hasNotText: /Successful|Failed|Unknown/i }) + .last() + .or(row.locator('button').last()); + await menuBtn.click(); + + const disconnect = this.page + .getByRole('button', { name: /Disconnect|Remove|Delete/i }) + .or(this.page.getByText(/Disconnect|Remove repository|Delete/i)) + .first(); + await expect(disconnect).toBeVisible({ timeout: 10000 }); + await disconnect.click(); + + const ok = this.page.locator('[qe-id="argo-popup-ok-button"]').or( + this.page.getByRole('button', { name: /^(OK|Confirm|Remove|Disconnect)$/i }) + ); + if (await ok.first().isVisible().catch(() => false)) { + await ok.first().click(); + } + + await expect(row).toBeHidden({ timeout: 30000 }); + return true; + } + + async ensureRepoRemoved(repoUrl: string): Promise { + console.log('[private-repo] ensuring repo is fully removed'); + await this.clearSecretsFromForm().catch(() => undefined); + + const cancel = this.slidingPanel().getByRole('button', { name: /^Cancel$/i }); + if (await cancel.isVisible().catch(() => false)) { + await cancel.click().catch(() => undefined); + } + + const apiOk = await this.tryDeleteRepoViaApi(repoUrl); + if (!apiOk) { + try { + await this.tryDeleteRepoViaUi(repoUrl); + } catch (e) { + console.warn(`[private-repo] ui delete failed: ${(e as Error).message}`); + } + } + + this.deleteRepoSecretsViaOc(repoUrl); + + await this.page.goto('/settings/repos'); + await expect(this.connectRepoButton).toBeVisible({ timeout: 20000 }); + await expect(this.page.getByText('Loading...', { exact: true })).toHaveCount(0, { timeout: 60000 }); + await expect(this.page.getByText(repoUrl, { exact: true })).toHaveCount(0, { timeout: 30000 }); + + const leftover = this.deleteRepoSecretsViaOc(repoUrl); + if (leftover > 0) { + await this.page.reload(); + await expect(this.page.getByText('Loading...', { exact: true })).toHaveCount(0, { timeout: 60000 }); + await expect(this.page.getByText(repoUrl, { exact: true })).toHaveCount(0, { timeout: 30000 }); + } + console.log('[private-repo] repo cleanup verified'); + } + + async navigate() { + console.log('[private-repo] opening settings/repos'); + await this.page.goto('/settings/repos'); + await expect(this.connectRepoButton).toBeVisible({ timeout: 20000 }); + await expect(this.page.getByText('Loading...', { exact: true })).toHaveCount(0, { timeout: 60000 }); + } + + private async selectHttpsConnectionMethod() { + console.log('[private-repo] selecting VIA HTTP/HTTPS'); + await expect(async () => { + if (!(await this.slidingPanel().isVisible().catch(() => false))) { + await this.page.goto('/settings/repos?addRepo=true'); + await expect(this.slidingPanel()).toBeVisible({ timeout: 20000 }); + } + const panel = this.slidingPanel(); + if (await panel.getByText(/CONNECT REPO USING HTTP\/HTTPS/i).isVisible().catch(() => false)) { + return; + } + + const methodTrigger = panel.locator('p').filter({ hasText: /VIA\s+/i }).first(); + await expect(methodTrigger).toBeVisible({ timeout: 5000 }); + await methodTrigger.click(); + + const httpsOption = this.page + .locator('.argo-dropdown__content li') + .filter({ hasText: /VIA\s*HTTP\/HTTPS/i }) + .first(); + await expect(httpsOption).toBeVisible({ timeout: 5000 }); + //portaled dropdown options often never become Playwright-stable + await httpsOption.click({ force: true }); + + await expect(this.slidingPanel().getByText(/CONNECT REPO USING HTTP\/HTTPS/i)).toBeVisible({ + timeout: 5000, + }); + }).toPass({ timeout: 45000, intervals: [500, 1000, 2000] }); + } + + async connectHttpsRepo(repoUrl: string, username: string, password: string) { + await this.ensureRepoRemoved(repoUrl); + + console.log('[private-repo] opening connect repo panel'); + await this.page.goto('/settings/repos?addRepo=true'); + await expect(this.page).toHaveURL(/addRepo=true/, { timeout: 10000 }); + await expect(this.slidingPanel()).toBeVisible({ timeout: 20000 }); + await expect(this.slidingPanel().getByText(/Choose your connection method/i)).toBeVisible({ + timeout: 15000, + }); + + await this.selectHttpsConnectionMethod(); + await expect(this.slidingPanel().getByText(/CONNECT REPO USING HTTP\/HTTPS/i)).toBeVisible({ + timeout: 10000, + }); + + console.log('[private-repo] filling https form'); + const repoUrlField = this.slidingPanel() + .getByLabel(/Repository URL/i) + .or(this.slidingPanel().getByPlaceholder(/https:\/\/github\.com/i)); + await repoUrlField.fill(repoUrl); + if (username) { + await this.usernameField().fill(username); + } + await this.passwordField().fill(password); + + const forceBasic = this.slidingPanel().getByLabel(/Force HTTP basic auth/i); + if (await forceBasic.isVisible().catch(() => false)) { + if (!(await forceBasic.isChecked())) { + await forceBasic.check(); + } + } + + //gitlab.cee internal CA is not trusted by repo-server by default + const skipTls = this.slidingPanel().getByLabel(/Skip server verification/i); + if (await skipTls.isVisible().catch(() => false)) { + if (!(await skipTls.isChecked())) { + console.log('[private-repo] enabling skip server verification'); + await skipTls.check(); + } + } + + console.log('[private-repo] clicking connect'); + await this.slidingPanel().getByRole('button', { name: /^Connect$/i }).click(); + } + + async assertConnectionSuccessful(repoUrl: string) { + console.log('[private-repo] waiting for successful connection (max 60s)'); + const connectError = this.page.getByText(/Unable to connect HTTPS repository/i); + const row = this.repoRow(repoUrl); + + try { + await Promise.race([ + row.waitFor({ state: 'visible', timeout: 60000 }), + connectError.waitFor({ state: 'visible', timeout: 60000 }).then(async () => { + const detail = ( + await this.page + .locator('.notifications-list, .toast, [class*="notification"]') + .first() + .innerText() + .catch(() => '') + ).trim(); + throw new Error( + this.redact(`argo failed to connect private repo${detail ? `: ${detail}` : ''}`) + ); + }), + ]); + await expect(row.getByText(/Successful/i)).toBeVisible({ timeout: 30000 }); + console.log('[private-repo] connection successful'); + } finally { + await this.clearSecretsFromForm(); + } + } +} diff --git a/test/ui-e2e/tests/auto-sync-self-heal.spec.ts b/test/ui-e2e/tests/auto-sync-self-heal.spec.ts new file mode 100644 index 00000000000..ee12779eb48 --- /dev/null +++ b/test/ui-e2e/tests/auto-sync-self-heal.spec.ts @@ -0,0 +1,121 @@ +import { test, expect } from '../src/fixtures'; +import { execFileSync } from 'child_process'; +import { ApplicationsPage } from '../src/pages/ApplicationsPage'; +import { ApplicationDetailsPage } from '../src/pages/ApplicationDetailsPage'; + +test.describe('Auto-Sync and Self-Healing', () => { + const appName = `ui-autosync-${Date.now()}`; + const targetCommit = '8088f4c0d970abb09e250248cc97e35623447cb5'; + + const ocGet = (args: string[]): string => + execFileSync('oc', args, { stdio: 'pipe', timeout: 5000 }).toString().trim(); + + const applicationExists = (): boolean => { + const out = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', '--ignore-not-found', '-o', 'name' + ]); + return out.length > 0; + }; + + //guestbook children share fixed names + const deleteGuestbookChildren = () => { + for (const kind of ['deploy', 'svc'] as const) { + execFileSync( + 'oc', + ['delete', kind, 'guestbook-ui', '-n', 'openshift-gitops', '--ignore-not-found', '--wait=false'], + { stdio: 'pipe', timeout: 15000 } + ); + } + }; + + test.beforeAll(async ({}, testInfo) => { + testInfo.setTimeout(60000); + console.log(`\n[setup] Deploying '${appName}' via CLI (manual sync policy)...`); + + deleteGuestbookChildren(); + + //manual sync — UI enables automated policy + const appYaml = ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: ${appName} + namespace: openshift-gitops +spec: + destination: + namespace: openshift-gitops + server: https://kubernetes.default.svc + project: default + source: + path: guestbook + repoURL: https://github.com/argoproj/argocd-example-apps.git + targetRevision: ${targetCommit} +`; + execFileSync('oc', ['apply', '-f', '-'], { input: appYaml, stdio: 'pipe', timeout: 15000 }); + + for (let i = 1; i <= 15; i++) { + if (applicationExists()) break; + if (i === 15) throw new Error(`Application '${appName}' never appeared`); + await new Promise((r) => setTimeout(r, 2000)); + } + }); + + test.afterAll(async ({}, testInfo) => { + testInfo.setTimeout(90000); + console.log(`\n[teardown] Cleaning up '${appName}' and guestbook children...`); + + try { + execFileSync( + 'oc', + ['delete', 'application', appName, '-n', 'openshift-gitops', '--ignore-not-found', '--wait=true'], + { stdio: 'pipe', timeout: 45000 } + ); + } catch (e) { + console.warn(`[teardown] app delete failed: ${(e as Error).message}`); + } + + deleteGuestbookChildren(); + + let gone = false; + for (let i = 1; i <= 10; i++) { + if (!applicationExists()) { + gone = true; + break; + } + await new Promise((r) => setTimeout(r, 2000)); + } + if (!gone) { + throw new Error(`[teardown] '${appName}' still exists on cluster`); + } + }); + + test('Enable Auto-Sync, Prune, and Self Heal from App Details', async ({ page }) => { + test.setTimeout(120000); + + const appsPage = new ApplicationsPage(page); + const detailsPage = new ApplicationDetailsPage(page); + + await appsPage.navigate(); + await appsPage.openApplication(appName); + await detailsPage.openAppDetailsPanel(); + await detailsPage.enableAutoSyncWithPruneAndSelfHeal(appName); + await detailsPage.assertAutoSyncPruneSelfHealEnabled(); + + await expect + .poll( + () => { + const prune = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.spec.syncPolicy.automated.prune}' + ]); + const selfHeal = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.spec.syncPolicy.automated.selfHeal}' + ]); + return `${prune},${selfHeal}`; + }, + { timeout: 30000, message: 'waiting for prune+selfHeal on Application CR' } + ) + .toBe('true,true'); + }); +}); diff --git a/test/ui-e2e/tests/private-repo.spec.ts b/test/ui-e2e/tests/private-repo.spec.ts new file mode 100644 index 00000000000..e24ef161b94 --- /dev/null +++ b/test/ui-e2e/tests/private-repo.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '../src/fixtures'; +import { SettingsRepositoriesPage } from '../src/pages/SettingsRepositoriesPage'; + +test.describe('Private Git Repository Connection', () => { + const repoUrl = process.env.PRIVATE_REPO_URL || ''; + const username = process.env.PRIVATE_REPO_USERNAME || 'x-access-token'; + const password = process.env.PRIVATE_REPO_PASSWORD || process.env.PRIVATE_REPO_TOKEN || ''; + + test.beforeEach(() => { + test.skip(!repoUrl || !password, 'requires PRIVATE_REPO_URL and PRIVATE_REPO_PASSWORD (or PRIVATE_REPO_TOKEN)'); + }); + + test.afterEach(async ({ page }) => { + if (!repoUrl) return; + console.log('[teardown] removing configured private repository'); + const reposPage = new SettingsRepositoriesPage(page); + await reposPage.ensureRepoRemoved(repoUrl); + }); + + test('Connect a private HTTPS repository via Settings', async ({ page }) => { + test.setTimeout(180000); + + const reposPage = new SettingsRepositoriesPage(page); + await reposPage.navigate(); + await reposPage.connectHttpsRepo(repoUrl, username, password); + await reposPage.assertConnectionSuccessful(repoUrl); + }); +}); diff --git a/test/ui-e2e/tests/resource-tree.spec.ts b/test/ui-e2e/tests/resource-tree.spec.ts index 4123e396f0b..92c2574abe7 100644 --- a/test/ui-e2e/tests/resource-tree.spec.ts +++ b/test/ui-e2e/tests/resource-tree.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../src/fixtures'; +import { test } from '../src/fixtures'; import { ApplicationDetailsPage } from '../src/pages/ApplicationDetailsPage'; import { ApplicationsPage } from '../src/pages/ApplicationsPage'; @@ -7,23 +7,17 @@ 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); + 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(); + await appsPage.openApplication(managedApp); - //on details page await detailsPage.verifyResourceTreeLoaded(); - //Deployment node await detailsPage.clickResourceNode('deploy', 'spring-petclinic'); await detailsPage.verifyPodLogs(); }); -}); \ No newline at end of file +}); From bb7366564f16a7259bb0c5dcdff64304c7beddc8 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Thu, 13 Aug 2026 10:18:57 +0100 Subject: [PATCH 13/17] Update HA to warn of possible environment/capacity issues for test execution Signed-off-by: Triona Doyle --- test/ui-e2e/src/utils/ha-manager.ts | 91 +++++++++++++++++++++++------ 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/test/ui-e2e/src/utils/ha-manager.ts b/test/ui-e2e/src/utils/ha-manager.ts index 456d667b39a..d10884b0029 100644 --- a/test/ui-e2e/src/utils/ha-manager.ts +++ b/test/ui-e2e/src/utils/ha-manager.ts @@ -1,37 +1,80 @@ import { execSync } from 'child_process'; -//private helper test file doesn't need to see it -function isClusterResourceConstrained(): boolean { - if (process.env.REDUCE_HA_RESOURCES === 'true') return true; +const MIN_WORKERS = 3; +const MIN_WORKER_MEM_KI = 12 * 1024 * 1024; // 12Gi + +type HaCapacityAssessment = { + constrained: boolean; + reasons: string[]; + workerCount: number; + workerMemSummary: string; +}; + +function assessHaCapacity(): HaCapacityAssessment { + const reasons: string[] = []; + let workerCount = 0; + let workerMemSummary = 'unknown'; + + if (process.env.REDUCE_HA_RESOURCES === 'true') { + reasons.push('REDUCE_HA_RESOURCES=true'); + } + try { const raw = execSync('oc get nodes -l node-role.kubernetes.io/worker -o json', { stdio: 'pipe' }).toString(); const workers = JSON.parse(raw).items || []; - if (workers.length < 3) return true; + workerCount = workers.length; + const memParts: string[] = []; + for (const node of workers) { + const name = node.metadata?.name || 'worker'; const memKi = parseInt(node.status?.allocatable?.memory || '0', 10); - if (memKi > 0 && memKi < 12 * 1024 * 1024) return true; + memParts.push(`${name}=${memKi > 0 ? `${Math.round(memKi / 1024 / 1024)}Gi` : '?'}`); + if (memKi > 0 && memKi < MIN_WORKER_MEM_KI) { + reasons.push(`${name} allocatable memory ${Math.round(memKi / 1024 / 1024)}Gi < 12Gi`); + } + } + workerMemSummary = memParts.join(', ') || 'none'; + + if (workerCount < MIN_WORKERS) { + reasons.push(`worker count ${workerCount} < ${MIN_WORKERS}`); } - } catch {} - return false; + } catch (e: any) { + reasons.push(`could not inspect workers (${e?.message || e})`); + } + + return { + constrained: reasons.length > 0, + reasons, + workerCount, + workerMemSummary, + }; } export async function enableHA() { console.log('\n[setup] Enabling High Availability (HA) for Argo CD...'); - - const reduceResources = isClusterResourceConstrained(); + + const capacity = assessHaCapacity(); + const reduceResources = capacity.constrained; const lowRes = { requests: { cpu: '50m', memory: '64Mi' }, limits: { cpu: '250m', memory: '128Mi' } }; const patchObj = reduceResources ? { spec: { ha: { enabled: true, resources: lowRes }, redis: { resources: lowRes } } } : { spec: { ha: { enabled: true } } }; if (reduceResources) { - console.log('[setup] Resource-constrained cluster detected. Applying reduced specs for HA patch...'); + console.warn( + [ + '[setup] WARNING: cluster may lack capacity for reliable HA (continuing anyway; HA is still required).', + ` workers=${capacity.workerCount} mem=[${capacity.workerMemSummary}]`, + ` reasons: ${capacity.reasons.join('; ')}`, + ' Applying reduced HA/redis resource specs. If redis-ha rollout times out, treat as an environment/capacity issue — not a UI login bug.', + ].join('\n') + ); } execSync(`oc patch argocd openshift-gitops -n openshift-gitops --type=merge -p '${JSON.stringify(patchObj)}'`, { stdio: 'inherit', timeout: 30000 }); console.log('[setup] Polling cluster for new HA deployment (this may take a few minutes)...'); - let retries = 30; + let retries = 30; let podsReady = false; while (retries > 0 && !podsReady) { @@ -45,12 +88,26 @@ export async function enableHA() { } } - if (!podsReady) throw new Error('HA proxy deployment never appeared or became available after polling.'); + if (!podsReady) { + const hint = reduceResources + ? ' ENV_CAPACITY_WARNING: cluster was flagged as constrained before enableHA; prefer investigating redis-ha/capacity over UI selectors.' + : ''; + throw new Error('HA proxy deployment never appeared or became available after polling.' + hint); + } console.log('[setup] Waiting for Operator to roll out HA-aware components...'); - execSync('oc rollout status statefulset/openshift-gitops-redis-ha-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); - execSync('oc rollout status deployment/openshift-gitops-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); - execSync('oc rollout status deployment/openshift-gitops-dex-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + try { + execSync('oc rollout status statefulset/openshift-gitops-redis-ha-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + execSync('oc rollout status deployment/openshift-gitops-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + execSync('oc rollout status deployment/openshift-gitops-dex-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + } catch (e: any) { + if (reduceResources) { + throw new Error( + `HA rollout failed after capacity warning (likely environment/capacity, not UI). Underlying: ${e?.message || e}` + ); + } + throw e; + } console.log('[setup] Rollouts complete. Giving cluster time to stabilize network routes...'); await new Promise(resolve => setTimeout(resolve, 10000)); @@ -59,7 +116,7 @@ export async function enableHA() { export async function disableHA() { console.log('\n[teardown] Disabling High Availability (HA) to restore cluster state...'); - + //setting resources to get rid of any overrides from the setup const disablePatch = { spec: { ha: { enabled: false, resources: null }, redis: { resources: null } } }; execSync(`oc patch argocd openshift-gitops -n openshift-gitops --type=merge -p '${JSON.stringify(disablePatch)}'`, { stdio: 'inherit', timeout: 30000 }); @@ -84,4 +141,4 @@ export async function disableHA() { await new Promise(resolve => setTimeout(resolve, 5000)); console.log('[teardown] Cluster successfully restored to non-HA state.'); -} \ No newline at end of file +} From 1e9174030d49f7a3626a70f66b4c6d906172576b Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Thu, 13 Aug 2026 11:06:41 +0100 Subject: [PATCH 14/17] address coderabbit feedback Signed-off-by: Triona Doyle --- test/ui-e2e/.auth/setup.ts | 1 + test/ui-e2e/README.md | 1 + .../src/pages/ApplicationDetailsPage.ts | 8 ++++-- .../src/pages/SettingsRepositoriesPage.ts | 26 ++++++----------- test/ui-e2e/src/utils/ha-manager.ts | 28 +++++++++++++++++-- test/ui-e2e/tests/app-deletion.spec.ts | 3 +- test/ui-e2e/tests/auto-sync-self-heal.spec.ts | 2 +- test/ui-e2e/tests/private-repo.spec.ts | 2 +- 8 files changed, 46 insertions(+), 25 deletions(-) diff --git a/test/ui-e2e/.auth/setup.ts b/test/ui-e2e/.auth/setup.ts index 7c26b334911..b870070cbf6 100644 --- a/test/ui-e2e/.auth/setup.ts +++ b/test/ui-e2e/.auth/setup.ts @@ -66,6 +66,7 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => { await welcomeDialog.waitFor({ state: 'visible', timeout: TIMEOUTS.default }); const skipTour = welcomeDialog.getByRole('button', { name: /skip tour/i }); const closeBtn = welcomeDialog.getByRole('button', { name: /^close$/i }); + await skipTour.or(closeBtn).first().waitFor({ state: 'visible', timeout: TIMEOUTS.medium }); if (await skipTour.isVisible()) { await skipTour.click(); } else { diff --git a/test/ui-e2e/README.md b/test/ui-e2e/README.md index 5ab80aab6d1..99ca40bde86 100644 --- a/test/ui-e2e/README.md +++ b/test/ui-e2e/README.md @@ -99,6 +99,7 @@ npx playwright show-trace test-results/create-application-chromium/trace.zip ├── src/ │ └── pages/ # Page Object Models (POM) isolating UI selectors from spec logic │ ├── ApplicationsPage.ts +│ ├── ApplicationDetailsPage.ts │ └── SettingsRepositoriesPage.ts ├── tests/ # Test specs organized by feature epic │ ├── admin-login.spec.ts diff --git a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts index 80adbcc65bf..dbf8798733e 100644 --- a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts @@ -89,7 +89,9 @@ export class ApplicationDetailsPage { await expect .poll( async () => { - const res = await this.page.request.get(`/api/v1/applications/${appName}`); + const res = await this.page.request.get( + `/api/v1/applications/${encodeURIComponent(appName)}` + ); if (!res.ok()) return false; const app = await res.json(); return app?.spec?.syncPolicy?.automated?.[flag] === true; @@ -104,14 +106,16 @@ export class ApplicationDetailsPage { const checkboxId = kind === 'prune' ? 'prune-resources' : 'self-heal'; const rowLabel = kind === 'prune' ? /PRUNE RESOURCES/i : /SELF HEAL/i; const checkbox = this.page.locator(`#${checkboxId}`); + const enableBtn = this.syncPolicyRow(rowLabel).getByRole('button', { name: /^Enable$/i }); + //wait for checkbox or Enable button + await expect(checkbox.or(enableBtn).first()).toBeVisible({ timeout: 15000 }); if (await checkbox.isVisible()) { await checkbox.click(); await this.confirmArgoPopup(); return; } - const enableBtn = this.syncPolicyRow(rowLabel).getByRole('button', { name: /^Enable$/i }); await expect(enableBtn).toBeVisible({ timeout: 15000 }); await enableBtn.click(); await this.confirmArgoPopup(); diff --git a/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts b/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts index e618afea988..bf4f6c3d4e8 100644 --- a/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts +++ b/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts @@ -1,5 +1,5 @@ import { Page, expect, Locator } from '@playwright/test'; -import { execSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; export class SettingsRepositoriesPage { readonly page: Page; @@ -63,13 +63,18 @@ export class SettingsRepositoriesPage { } const data = JSON.parse(raw) as { - items?: Array<{ metadata?: { name?: string }; data?: Record }>; + items?: Array<{ + metadata?: { name?: string; labels?: Record }; + data?: Record; + }>; }; const toDelete: string[] = []; for (const secret of data.items || []) { const name = secret.metadata?.name; + const labels = secret.metadata?.labels || {}; const b64 = secret.data || {}; - if (!name) continue; + //repo secrets only + if (!name || labels['argocd.argoproj.io/secret-type'] !== 'repository') continue; let urlVal = ''; if (b64.url) { @@ -81,26 +86,13 @@ export class SettingsRepositoriesPage { } if (urlVal === repoUrl) { toDelete.push(name); - continue; - } - - let blob = ''; - for (const v of Object.values(b64)) { - try { - blob += Buffer.from(v, 'base64').toString('utf8') + '\n'; - } catch { - /* ignore */ - } - } - if (blob.includes(repoUrl)) { - toDelete.push(name); } } let deleted = 0; for (const name of [...new Set(toDelete)]) { try { - execSync(`oc delete secret ${name} -n openshift-gitops --wait=true`, { + execFileSync('oc', ['delete', 'secret', name, '-n', 'openshift-gitops', '--wait=true'], { encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/test/ui-e2e/src/utils/ha-manager.ts b/test/ui-e2e/src/utils/ha-manager.ts index d10884b0029..f251569cc3f 100644 --- a/test/ui-e2e/src/utils/ha-manager.ts +++ b/test/ui-e2e/src/utils/ha-manager.ts @@ -8,12 +8,33 @@ type HaCapacityAssessment = { reasons: string[]; workerCount: number; workerMemSummary: string; + inspectionFailed: boolean; }; +//Ki/Mi/Gi -> Ki +function memoryToKi(quantity: string): number { + const match = /^(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)?$/.exec(quantity.trim()); + if (!match) return 0; + const value = parseFloat(match[1]); + const unit = match[2] || 'Ki'; + switch (unit) { + case 'Ti': + return Math.round(value * 1024 * 1024 * 1024); + case 'Gi': + return Math.round(value * 1024 * 1024); + case 'Mi': + return Math.round(value * 1024); + case 'Ki': + default: + return Math.round(value); + } +} + function assessHaCapacity(): HaCapacityAssessment { const reasons: string[] = []; let workerCount = 0; let workerMemSummary = 'unknown'; + let inspectionFailed = false; if (process.env.REDUCE_HA_RESOURCES === 'true') { reasons.push('REDUCE_HA_RESOURCES=true'); @@ -27,7 +48,7 @@ function assessHaCapacity(): HaCapacityAssessment { for (const node of workers) { const name = node.metadata?.name || 'worker'; - const memKi = parseInt(node.status?.allocatable?.memory || '0', 10); + const memKi = memoryToKi(node.status?.allocatable?.memory || '0'); memParts.push(`${name}=${memKi > 0 ? `${Math.round(memKi / 1024 / 1024)}Gi` : '?'}`); if (memKi > 0 && memKi < MIN_WORKER_MEM_KI) { reasons.push(`${name} allocatable memory ${Math.round(memKi / 1024 / 1024)}Gi < 12Gi`); @@ -39,7 +60,9 @@ function assessHaCapacity(): HaCapacityAssessment { reasons.push(`worker count ${workerCount} < ${MIN_WORKERS}`); } } catch (e: any) { - reasons.push(`could not inspect workers (${e?.message || e})`); + //don't treat oc errors as low capacity + inspectionFailed = true; + console.warn(`[setup] could not inspect workers (${e?.message || e}); skipping capacity reduction`); } return { @@ -47,6 +70,7 @@ function assessHaCapacity(): HaCapacityAssessment { reasons, workerCount, workerMemSummary, + inspectionFailed, }; } diff --git a/test/ui-e2e/tests/app-deletion.spec.ts b/test/ui-e2e/tests/app-deletion.spec.ts index 9991611fae8..4ace24c7386 100644 --- a/test/ui-e2e/tests/app-deletion.spec.ts +++ b/test/ui-e2e/tests/app-deletion.spec.ts @@ -34,8 +34,7 @@ test.describe('Clean Application Deletion (Pruning)', () => { }; test.beforeAll(async ({}, testInfo) => { - //set timeout to 150s (sync poll can take ~90s after a fresh install) - testInfo.setTimeout(150000); + testInfo.setTimeout(180000); console.log(`\n[setup] Deploying dummy application '${appName}' via CLI...`); //define standard guestbook app yaml targeting openshift-gitops namespace diff --git a/test/ui-e2e/tests/auto-sync-self-heal.spec.ts b/test/ui-e2e/tests/auto-sync-self-heal.spec.ts index ee12779eb48..cf0cd49656d 100644 --- a/test/ui-e2e/tests/auto-sync-self-heal.spec.ts +++ b/test/ui-e2e/tests/auto-sync-self-heal.spec.ts @@ -29,7 +29,7 @@ test.describe('Auto-Sync and Self-Healing', () => { }; test.beforeAll(async ({}, testInfo) => { - testInfo.setTimeout(60000); + testInfo.setTimeout(120000); console.log(`\n[setup] Deploying '${appName}' via CLI (manual sync policy)...`); deleteGuestbookChildren(); diff --git a/test/ui-e2e/tests/private-repo.spec.ts b/test/ui-e2e/tests/private-repo.spec.ts index e24ef161b94..62f9ebabb65 100644 --- a/test/ui-e2e/tests/private-repo.spec.ts +++ b/test/ui-e2e/tests/private-repo.spec.ts @@ -11,7 +11,7 @@ test.describe('Private Git Repository Connection', () => { }); test.afterEach(async ({ page }) => { - if (!repoUrl) return; + if (!repoUrl || !password) return; console.log('[teardown] removing configured private repository'); const reposPage = new SettingsRepositoriesPage(page); await reposPage.ensureRepoRemoved(repoUrl); From 591a2510fcb9b5308d49b4583246b4db94b66c25 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Thu, 13 Aug 2026 13:03:20 +0100 Subject: [PATCH 15/17] address further coderabbit feedback Signed-off-by: Triona Doyle --- test/ui-e2e/src/utils/ha-manager.ts | 4 --- test/ui-e2e/tests/app-deletion.spec.ts | 26 +++++++++++-------- test/ui-e2e/tests/auto-sync-self-heal.spec.ts | 17 ++++++++---- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/test/ui-e2e/src/utils/ha-manager.ts b/test/ui-e2e/src/utils/ha-manager.ts index f251569cc3f..9619046db70 100644 --- a/test/ui-e2e/src/utils/ha-manager.ts +++ b/test/ui-e2e/src/utils/ha-manager.ts @@ -8,7 +8,6 @@ type HaCapacityAssessment = { reasons: string[]; workerCount: number; workerMemSummary: string; - inspectionFailed: boolean; }; //Ki/Mi/Gi -> Ki @@ -34,7 +33,6 @@ function assessHaCapacity(): HaCapacityAssessment { const reasons: string[] = []; let workerCount = 0; let workerMemSummary = 'unknown'; - let inspectionFailed = false; if (process.env.REDUCE_HA_RESOURCES === 'true') { reasons.push('REDUCE_HA_RESOURCES=true'); @@ -61,7 +59,6 @@ function assessHaCapacity(): HaCapacityAssessment { } } catch (e: any) { //don't treat oc errors as low capacity - inspectionFailed = true; console.warn(`[setup] could not inspect workers (${e?.message || e}); skipping capacity reduction`); } @@ -70,7 +67,6 @@ function assessHaCapacity(): HaCapacityAssessment { reasons, workerCount, workerMemSummary, - inspectionFailed, }; } diff --git a/test/ui-e2e/tests/app-deletion.spec.ts b/test/ui-e2e/tests/app-deletion.spec.ts index 4ace24c7386..193f5b8aa4c 100644 --- a/test/ui-e2e/tests/app-deletion.spec.ts +++ b/test/ui-e2e/tests/app-deletion.spec.ts @@ -2,8 +2,9 @@ import { test, expect } from '../src/fixtures'; import { execFileSync } from 'child_process'; test.describe('Clean Application Deletion (Pruning)', () => { - //make app name unique for test isolation - const appName = `ui-deletion-${Date.now()}`; + const stamp = Date.now(); + const appName = `ui-deletion-${stamp}`; + const destNs = `ui-deletion-ns-${stamp}`; //pin revision to immutable commit SHA for reproducibility const targetCommit = '8088f4c0d970abb09e250248cc97e35623447cb5'; @@ -25,7 +26,7 @@ test.describe('Clean Application Deletion (Pruning)', () => { .map((kind) => execFileSync( 'oc', - ['get', kind, 'guestbook-ui', '-n', 'openshift-gitops', '--ignore-not-found', '-o', 'name'], + ['get', kind, 'guestbook-ui', '-n', destNs, '--ignore-not-found', '-o', 'name'], { stdio: 'pipe', timeout: 5000 } ).toString().trim() ) @@ -37,7 +38,6 @@ test.describe('Clean Application Deletion (Pruning)', () => { testInfo.setTimeout(180000); console.log(`\n[setup] Deploying dummy application '${appName}' via CLI...`); - //define standard guestbook app yaml targeting openshift-gitops namespace const appYaml = ` apiVersion: argoproj.io/v1alpha1 kind: Application @@ -46,7 +46,7 @@ metadata: namespace: openshift-gitops spec: destination: - namespace: openshift-gitops + namespace: ${destNs} server: https://kubernetes.default.svc project: default source: @@ -59,11 +59,13 @@ spec: selfHeal: true `; try { + execFileSync('oc', ['create', 'namespace', destNs], { stdio: 'pipe', timeout: 15000 }); + //clear leftover guestbook children that can leave a new app stuck Unknown/OutOfSync for (const kind of ['deploy', 'svc'] as const) { execFileSync( 'oc', - ['delete', kind, 'guestbook-ui', '-n', 'openshift-gitops', '--ignore-not-found', '--wait=false'], + ['delete', kind, 'guestbook-ui', '-n', destNs, '--ignore-not-found', '--wait=false'], { stdio: 'pipe', timeout: 15000 } ); } @@ -119,11 +121,9 @@ spec: }); test.afterAll(async ({}, testInfo) => { - //set hook timeout to 60s testInfo.setTimeout(60000); - console.log('\n[teardown] Ensuring application is cleaned up...'); + console.log(`\n[teardown] Ensuring '${appName}' and '${destNs}' are cleaned up...`); - //attempt fallback cleanup if ui deletion failed or was skipped try { execFileSync( 'oc', @@ -134,14 +134,18 @@ spec: console.warn(`[teardown] Initial cleanup command failed: ${(e as Error).message}`); } - //verify resource is completely absent from cluster + execFileSync( + 'oc', + ['delete', 'namespace', destNs, '--ignore-not-found', '--wait=false'], + { stdio: 'pipe', timeout: 15000 } + ); + let isDeleted = false; for (let i = 1; i <= 5; i++) { if (!applicationExists()) { isDeleted = true; break; } - //resource still exists, wait before checking again await new Promise(resolve => setTimeout(resolve, 2000)); } diff --git a/test/ui-e2e/tests/auto-sync-self-heal.spec.ts b/test/ui-e2e/tests/auto-sync-self-heal.spec.ts index cf0cd49656d..1209fcb2a15 100644 --- a/test/ui-e2e/tests/auto-sync-self-heal.spec.ts +++ b/test/ui-e2e/tests/auto-sync-self-heal.spec.ts @@ -4,7 +4,9 @@ import { ApplicationsPage } from '../src/pages/ApplicationsPage'; import { ApplicationDetailsPage } from '../src/pages/ApplicationDetailsPage'; test.describe('Auto-Sync and Self-Healing', () => { - const appName = `ui-autosync-${Date.now()}`; + const stamp = Date.now(); + const appName = `ui-autosync-${stamp}`; + const destNs = `ui-autosync-ns-${stamp}`; const targetCommit = '8088f4c0d970abb09e250248cc97e35623447cb5'; const ocGet = (args: string[]): string => @@ -17,12 +19,11 @@ test.describe('Auto-Sync and Self-Healing', () => { return out.length > 0; }; - //guestbook children share fixed names const deleteGuestbookChildren = () => { for (const kind of ['deploy', 'svc'] as const) { execFileSync( 'oc', - ['delete', kind, 'guestbook-ui', '-n', 'openshift-gitops', '--ignore-not-found', '--wait=false'], + ['delete', kind, 'guestbook-ui', '-n', destNs, '--ignore-not-found', '--wait=false'], { stdio: 'pipe', timeout: 15000 } ); } @@ -32,6 +33,7 @@ test.describe('Auto-Sync and Self-Healing', () => { testInfo.setTimeout(120000); console.log(`\n[setup] Deploying '${appName}' via CLI (manual sync policy)...`); + execFileSync('oc', ['create', 'namespace', destNs], { stdio: 'pipe', timeout: 15000 }); deleteGuestbookChildren(); //manual sync — UI enables automated policy @@ -43,7 +45,7 @@ metadata: namespace: openshift-gitops spec: destination: - namespace: openshift-gitops + namespace: ${destNs} server: https://kubernetes.default.svc project: default source: @@ -62,7 +64,7 @@ spec: test.afterAll(async ({}, testInfo) => { testInfo.setTimeout(90000); - console.log(`\n[teardown] Cleaning up '${appName}' and guestbook children...`); + console.log(`\n[teardown] Cleaning up '${appName}' and '${destNs}'...`); try { execFileSync( @@ -75,6 +77,11 @@ spec: } deleteGuestbookChildren(); + execFileSync( + 'oc', + ['delete', 'namespace', destNs, '--ignore-not-found', '--wait=false'], + { stdio: 'pipe', timeout: 15000 } + ); let gone = false; for (let i = 1; i <= 10; i++) { From d15ab3a3721eda330e5b7f33155a7f76d766f7a6 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Fri, 14 Aug 2026 12:33:19 +0100 Subject: [PATCH 16/17] Harden UI E2E navigation, app-deletion setup, and private-repo connect. Signed-off-by: Triona Doyle --- .../src/pages/ApplicationDetailsPage.ts | 25 ++--- test/ui-e2e/src/pages/ApplicationsPage.ts | 62 ++++++----- .../src/pages/SettingsRepositoriesPage.ts | 105 ++++++++++++++---- test/ui-e2e/src/utils/cluster-dns.ts | 35 ++++++ test/ui-e2e/tests/app-deletion.spec.ts | 40 ++++++- test/ui-e2e/tests/auto-sync-self-heal.spec.ts | 5 + test/ui-e2e/tests/private-repo.spec.ts | 14 ++- 7 files changed, 218 insertions(+), 68 deletions(-) create mode 100644 test/ui-e2e/src/utils/cluster-dns.ts diff --git a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts index dbf8798733e..f1d670d1b81 100644 --- a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts @@ -32,22 +32,17 @@ export class ApplicationDetailsPage { } 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 + //re-query on tree re-render await expect(async () => { - await expect(this.slideOutPanel).toBeVisible({ timeout: 2000 }); - }).toPass({ timeout: 10000 }); + const node = this.resourceTreeContainer + .locator('div') + .filter({ hasText: kind }) + .filter({ hasText: name }) + .last(); + await expect(node).toBeVisible({ timeout: 5000 }); + await node.click({ timeout: 5000 }); + await expect(this.slideOutPanel).toBeVisible({ timeout: 3000 }); + }).toPass({ timeout: 30000, intervals: [500, 1000, 2000] }); } async verifyPodLogs(expectedLogText?: string) { diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index 27a08ac6941..f5820e4b403 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -48,25 +48,42 @@ export class ApplicationsPage { } - async navigate() { - await this.page.goto('/applications'); - - //ignore the "failed to load data" banner if it appears - const errorBanner = this.page.getByText('try again'); + //dismiss intermittent load-error banner + private async dismissLoadErrorBanner(timeout = TIMEOUTS.short) { + const errorBanner = this.page.getByText(/try again/i); try { - //wait 3 secs - await errorBanner.waitFor({ state: 'visible', timeout: TIMEOUTS.short }); - await errorBanner.click(); + await errorBanner.waitFor({ state: 'visible', timeout }); + await errorBanner.click(); } catch (error) { - //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; + if (error instanceof Error && error.name === 'TimeoutError') return; + throw error; + } + } + + async navigate() { + const attempts = 3; + let lastError: unknown; + + for (let attempt = 1; attempt <= attempts; attempt++) { + await this.page.goto('/applications', { waitUntil: 'domcontentloaded' }); + await this.dismissLoadErrorBanner(); + + try { + await expect(this.newAppButton).toBeVisible({ timeout: TIMEOUTS.render }); + return; + } catch (error) { + lastError = error; + //dismiss late load-error banner + await this.dismissLoadErrorBanner(TIMEOUTS.modal); + if (attempt < attempts) { + console.log( + `[apps] NEW APP not ready on /applications (attempt ${attempt}/${attempts}); reloading...` + ); + } } } - - await expect(this.newAppButton).toBeVisible({ timeout: TIMEOUTS.default }); + + throw lastError; } //helper for fields that need to have select a pre existing option @@ -82,20 +99,7 @@ export class ApplicationsPage { 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 { - await errorBanner.waitFor({ state: 'visible', timeout: TIMEOUTS.short }); - await errorBanner.click(); - } catch (error) { - //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.dismissLoadErrorBanner(); await this.page.getByText('Loading...').first().waitFor({ state: 'hidden', timeout: TIMEOUTS.default }); diff --git a/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts b/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts index bf4f6c3d4e8..e83bd5f396d 100644 --- a/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts +++ b/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts @@ -264,14 +264,16 @@ export class SettingsRepositoriesPage { } await this.passwordField().fill(password); + //oauth2/token often needs force basic auth const forceBasic = this.slidingPanel().getByLabel(/Force HTTP basic auth/i); if (await forceBasic.isVisible().catch(() => false)) { if (!(await forceBasic.isChecked())) { + console.log('[private-repo] enabling force HTTP basic auth'); await forceBasic.check(); } } - //gitlab.cee internal CA is not trusted by repo-server by default + //internal CA not trusted by default const skipTls = this.slidingPanel().getByLabel(/Skip server verification/i); if (await skipTls.isVisible().catch(() => false)) { if (!(await skipTls.isChecked())) { @@ -284,29 +286,92 @@ export class SettingsRepositoriesPage { await this.slidingPanel().getByRole('button', { name: /^Connect$/i }).click(); } + private async repoPresentInApi(repoUrl: string): Promise { + try { + const response = await this.page.request.get('/api/v1/repositories'); + if (!response.ok()) return false; + const body = (await response.json()) as { items?: Array<{ repo?: string; url?: string }> }; + return (body.items || []).some((item) => item.repo === repoUrl || item.url === repoUrl); + } catch { + return false; + } + } + + private async visibleConnectionError(): Promise { + //real failure toasts only + const failureBanner = this.page + .getByText(/Unable to connect HTTPS repository/i) + .or(this.page.getByText(/Unable to connect repository/i)) + .or(this.page.getByText(/Failed to connect/i)) + .first(); + if (await failureBanner.isVisible().catch(() => false)) { + const detail = ( + await this.page + .locator('.notifications-list, .toast, [class*="notification"], .argo-notifications') + .first() + .innerText() + .catch(async () => failureBanner.innerText().catch(() => '')) + ).trim(); + return detail || 'Unable to connect repository'; + } + return ''; + } + async assertConnectionSuccessful(repoUrl: string) { - console.log('[private-repo] waiting for successful connection (max 60s)'); - const connectError = this.page.getByText(/Unable to connect HTTPS repository/i); + console.log('[private-repo] waiting for successful connection (max 90s)'); const row = this.repoRow(repoUrl); + const deadline = Date.now() + 90000; try { - await Promise.race([ - row.waitFor({ state: 'visible', timeout: 60000 }), - connectError.waitFor({ state: 'visible', timeout: 60000 }).then(async () => { - const detail = ( - await this.page - .locator('.notifications-list, .toast, [class*="notification"]') - .first() - .innerText() - .catch(() => '') - ).trim(); - throw new Error( - this.redact(`argo failed to connect private repo${detail ? `: ${detail}` : ''}`) - ); - }), - ]); - await expect(row.getByText(/Successful/i)).toBeVisible({ timeout: 30000 }); - console.log('[private-repo] connection successful'); + while (Date.now() < deadline) { + const err = await this.visibleConnectionError(); + if (err) { + throw new Error(this.redact(`argo failed to connect private repo: ${err}`)); + } + + if (await row.isVisible().catch(() => false)) { + await expect(row.getByText(/Successful/i)).toBeVisible({ timeout: 30000 }); + console.log('[private-repo] connection successful'); + return; + } + + if (await this.repoPresentInApi(repoUrl)) { + console.log('[private-repo] repo present via API; refreshing list'); + const refresh = this.page.getByRole('button', { name: /Refresh list/i }); + if (await refresh.isVisible().catch(() => false)) { + await refresh.click(); + } else { + await this.page.reload(); + } + await expect(this.page.getByText('Loading...', { exact: true })).toHaveCount(0, { + timeout: 60000, + }); + continue; + } + + //refresh if list stayed empty + const empty = this.page.getByText(/No repositories connected/i); + if (await empty.isVisible().catch(() => false)) { + const refresh = this.page.getByRole('button', { name: /Refresh list/i }); + if (await refresh.isVisible().catch(() => false)) { + await refresh.click(); + await expect(this.page.getByText('Loading...', { exact: true })).toHaveCount(0, { + timeout: 30000, + }); + } + } + + await new Promise((r) => setTimeout(r, 2000)); + } + + const err = await this.visibleConnectionError(); + const inApi = await this.repoPresentInApi(repoUrl); + throw new Error( + this.redact( + `private repo did not appear in Settings after Connect ` + + `(apiHasRepo=${inApi}${err ? `; uiError=${err}` : ''}).` + ) + ); } finally { await this.clearSecretsFromForm(); } diff --git a/test/ui-e2e/src/utils/cluster-dns.ts b/test/ui-e2e/src/utils/cluster-dns.ts new file mode 100644 index 00000000000..30c53d73d69 --- /dev/null +++ b/test/ui-e2e/src/utils/cluster-dns.ts @@ -0,0 +1,35 @@ +import { execFileSync } from 'child_process'; + +/** Resolve hostname from the Argo CD repo-server (same DNS path as git ls-remote). */ +export function clusterCanResolveHostname(hostname: string): boolean { + if (!hostname) return false; + try { + const out = execFileSync( + 'oc', + [ + 'exec', + '-n', + 'openshift-gitops', + 'deploy/openshift-gitops-repo-server', + '-c', + 'argocd-repo-server', + '--', + 'getent', + 'hosts', + hostname, + ], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20000 } + ); + return out.trim().length > 0; + } catch { + return false; + } +} + +export function hostnameFromRepoUrl(repoUrl: string): string { + try { + return new URL(repoUrl).hostname; + } catch { + return ''; + } +} diff --git a/test/ui-e2e/tests/app-deletion.spec.ts b/test/ui-e2e/tests/app-deletion.spec.ts index 193f5b8aa4c..ee953ed9302 100644 --- a/test/ui-e2e/tests/app-deletion.spec.ts +++ b/test/ui-e2e/tests/app-deletion.spec.ts @@ -35,7 +35,8 @@ test.describe('Clean Application Deletion (Pruning)', () => { }; test.beforeAll(async ({}, testInfo) => { - testInfo.setTimeout(180000); + //RBAC wait + sync can exceed 3m + testInfo.setTimeout(240000); console.log(`\n[setup] Deploying dummy application '${appName}' via CLI...`); const appYaml = ` @@ -60,6 +61,31 @@ spec: `; try { execFileSync('oc', ['create', 'namespace', destNs], { stdio: 'pipe', timeout: 15000 }); + //needed for controller write access + execFileSync( + 'oc', + ['label', 'namespace', destNs, 'argocd.argoproj.io/managed-by=openshift-gitops', '--overwrite'], + { stdio: 'pipe', timeout: 15000 } + ); + let rbacReady = false; + for (let i = 1; i <= 30; i++) { + const rbs = execFileSync( + 'oc', + ['get', 'rolebinding', '-n', destNs, '-o', 'name'], + { stdio: 'pipe', timeout: 5000 } + ).toString(); + if (/argocd-application-controller/i.test(rbs)) { + rbacReady = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + if (!rbacReady) { + throw new Error( + `Namespace '${destNs}' never received openshift-gitops application-controller RoleBinding ` + + `(label argocd.argoproj.io/managed-by=openshift-gitops).` + ); + } //clear leftover guestbook children that can leave a new app stuck Unknown/OutOfSync for (const kind of ['deploy', 'svc'] as const) { @@ -78,6 +104,7 @@ spec: let lastSync = ''; let lastHealth = ''; let lastMessage = ''; + let lastOpMessage = ''; for (let i = 1; i <= 30; i++) { try { lastSync = execFileSync( @@ -95,6 +122,14 @@ spec: ['get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.conditions[0].message}'], { stdio: 'pipe', timeout: 3000 } ).toString().trim(); + lastOpMessage = execFileSync( + 'oc', + [ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.status.operationState.message}', + ], + { stdio: 'pipe', timeout: 3000 } + ).toString().trim(); console.log( `[setup] Checking sync status (Attempt ${i}/30): sync='${lastSync || 'Initializing...'}' health='${lastHealth || '-'}'` ); @@ -111,7 +146,8 @@ spec: if (!isSynced) { throw new Error( `Dummy application '${appName}' never reached Synced status ` + - `(last sync='${lastSync || '-'}' health='${lastHealth || '-'}' message='${lastMessage || '-'}').` + `(last sync='${lastSync || '-'}' health='${lastHealth || '-'}' ` + + `condition='${lastMessage || '-'}' operation='${lastOpMessage || '-'}').` ); } } catch (e) { diff --git a/test/ui-e2e/tests/auto-sync-self-heal.spec.ts b/test/ui-e2e/tests/auto-sync-self-heal.spec.ts index 1209fcb2a15..b36fe6f926d 100644 --- a/test/ui-e2e/tests/auto-sync-self-heal.spec.ts +++ b/test/ui-e2e/tests/auto-sync-self-heal.spec.ts @@ -34,6 +34,11 @@ test.describe('Auto-Sync and Self-Healing', () => { console.log(`\n[setup] Deploying '${appName}' via CLI (manual sync policy)...`); execFileSync('oc', ['create', 'namespace', destNs], { stdio: 'pipe', timeout: 15000 }); + execFileSync( + 'oc', + ['label', 'namespace', destNs, 'argocd.argoproj.io/managed-by=openshift-gitops', '--overwrite'], + { stdio: 'pipe', timeout: 15000 } + ); deleteGuestbookChildren(); //manual sync — UI enables automated policy diff --git a/test/ui-e2e/tests/private-repo.spec.ts b/test/ui-e2e/tests/private-repo.spec.ts index 62f9ebabb65..d7143104deb 100644 --- a/test/ui-e2e/tests/private-repo.spec.ts +++ b/test/ui-e2e/tests/private-repo.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '../src/fixtures'; import { SettingsRepositoriesPage } from '../src/pages/SettingsRepositoriesPage'; +import { clusterCanResolveHostname, hostnameFromRepoUrl } from '../src/utils/cluster-dns'; test.describe('Private Git Repository Connection', () => { const repoUrl = process.env.PRIVATE_REPO_URL || ''; @@ -8,10 +9,19 @@ test.describe('Private Git Repository Connection', () => { test.beforeEach(() => { test.skip(!repoUrl || !password, 'requires PRIVATE_REPO_URL and PRIVATE_REPO_PASSWORD (or PRIVATE_REPO_TOKEN)'); + + const host = hostnameFromRepoUrl(repoUrl); + if (!clusterCanResolveHostname(host)) { + //skip is expected on clusters without corp/private DNS + const reason = + `skipped: this cluster has no DNS for ${host}, so the private-repo test is not run here`; + console.log(`[private-repo] ${reason}`); + test.skip(true, reason); + } }); - test.afterEach(async ({ page }) => { - if (!repoUrl || !password) return; + test.afterEach(async ({ page }, testInfo) => { + if (!repoUrl || !password || testInfo.status === 'skipped') return; console.log('[teardown] removing configured private repository'); const reposPage = new SettingsRepositoriesPage(page); await reposPage.ensureRepoRemoved(repoUrl); From 8f71c42a289e9e5b6a1c4e5b9230efb6825d87fc Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Fri, 14 Aug 2026 14:06:55 +0100 Subject: [PATCH 17/17] address coderabbit feedback after update Signed-off-by: Triona Doyle --- test/ui-e2e/src/pages/ApplicationsPage.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index f5820e4b403..a63be5c0c53 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -48,16 +48,21 @@ export class ApplicationsPage { } - //dismiss intermittent load-error banner - private async dismissLoadErrorBanner(timeout = TIMEOUTS.short) { + //dismiss intermittent load-error banner (wait only for late banners) + private async dismissLoadErrorBanner(waitForLateMs = 0) { const errorBanner = this.page.getByText(/try again/i); - try { - await errorBanner.waitFor({ state: 'visible', timeout }); + if (await errorBanner.isVisible()) { await errorBanner.click(); + return; + } + if (waitForLateMs <= 0) return; + try { + await errorBanner.waitFor({ state: 'visible', timeout: waitForLateMs }); } catch (error) { if (error instanceof Error && error.name === 'TimeoutError') return; throw error; } + await errorBanner.click(); } async navigate() { @@ -176,7 +181,7 @@ export class ApplicationsPage { const appCard = this.page .locator('.white-box, .argo-table-list__row, .application-tile, [class*="application-tile"], [class*="applications-list__entry"]') .filter({ hasText: appName }); - const appNameLink = appCard.getByText(appName, { exact: true }).first(); + const appNameLink = appCard.getByText(appName, { exact: true }).filter({ visible: true }).first(); await expect(appNameLink).toBeVisible({ timeout: TIMEOUTS.load }); await appNameLink.click();