From 2aef6c2d5e4450f599c58e6505502ee2f3f73d79 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 4 Nov 2026 10:34:21 +0000 Subject: [PATCH 1/6] App-history-and-ui-rollback-ui-test Signed-off-by: Triona Doyle --- .../src/pages/ApplicationDetailsPage.ts | 89 ++++++ .../ui-e2e/tests/history-and-rollback.spec.ts | 254 ++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 test/ui-e2e/tests/history-and-rollback.spec.ts diff --git a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts index f1d670d1b81..65e232b24c0 100644 --- a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts @@ -164,4 +164,93 @@ export class ApplicationDetailsPage { this.syncPolicyRow(/SELF HEAL/i).getByRole('button', { name: /^Disable$/i }) ).toBeVisible(); } + + private historyPanel(): Locator { + return this.page.locator('.application-deployment-history'); + } + + private historyOpened(): Locator { + return this.page.locator('.sliding-panel--opened').filter({ hasText: /Deployed At/i }); + } + + async openHistoryAndRollback() { + await expect(this.resourceTreeContainer).toBeVisible({ timeout: 20000 }); + + const historyBtn = this.page.getByRole('button', { name: /history and rollback/i }); + await expect(historyBtn).toBeEnabled({ timeout: 15000 }); + await historyBtn.click(); + + try { + await expect(this.historyOpened()).toBeVisible({ timeout: 8000 }); + } catch { + //toolbar click is a no-op if ?rollback=0 is dropped; open the previous entry directly + const url = new URL(this.page.url()); + url.searchParams.set('rollback', '1'); + await this.page.goto(url.toString(), { waitUntil: 'domcontentloaded' }); + await expect(this.historyOpened()).toBeVisible({ timeout: 15000 }); + } + } + + async rollbackToPreviousRevision(appName: string, previousRevision: string) { + const items = this.historyPanel().locator('.application-deployment-history__item'); + await expect + .poll(async () => items.count(), { + timeout: 15000, + message: 'waiting for at least two history entries', + }) + .toBeGreaterThanOrEqual(2); + + const shortRev = previousRevision.slice(0, 7); + const byRevision = items.filter({ hasText: new RegExp(shortRev, 'i') }); + const previousItem = (await byRevision.count()) > 0 ? byRevision.first() : items.nth(1); + await previousItem.scrollIntoViewIfNeeded(); + + //need a new op, not the last cli sync still on the app + const previousOp = await this.applicationOperation(appName); + const previousId = operationId(previousOp); + + const kebab = previousItem.locator('.application-deployment-history__item-menu button').first(); + await expect(kebab).toBeVisible({ timeout: 10000 }); + await kebab.click(); + + const rollbackItem = this.page + .getByRole('listitem', { name: /^Rollback$/ }) + .or(this.page.getByText('Rollback', { exact: true })); + await expect(rollbackItem.first()).toBeVisible({ timeout: 10000 }); + await rollbackItem.first().click(); + + await this.confirmArgoPopup(); + + await expect + .poll( + async () => { + const op = await this.applicationOperation(appName); + const rolledBack = + operationId(op) !== previousId && + String(op?.operation?.sync?.revision || '').startsWith(shortRev); + return rolledBack ? String(op?.phase || '') : ''; + }, + { timeout: 120000, message: `waiting for rollback to ${shortRev} to succeed` } + ) + .toMatch(/Succeeded/i); + } + + async assertOutOfSyncHealthy() { + const panel = this.page.locator('.application-status-panel'); + await expect(panel.getByText(/OutOfSync|Out of Sync/i).first()).toBeVisible({ timeout: 30000 }); + await expect(panel.getByText(/Healthy/i).first()).toBeVisible({ timeout: 30000 }); + } + + private async applicationOperation(appName: string) { + const res = await this.page.request.get( + `/api/v1/applications/${encodeURIComponent(appName)}` + ); + if (!res.ok()) return undefined; + const app = await res.json(); + return app?.status?.operationState; + } +} + +function operationId(op: { startedAt?: string; finishedAt?: string } | undefined) { + return `${op?.startedAt ?? ''}|${op?.finishedAt ?? ''}`; } diff --git a/test/ui-e2e/tests/history-and-rollback.spec.ts b/test/ui-e2e/tests/history-and-rollback.spec.ts new file mode 100644 index 00000000000..2f12f96f5ce --- /dev/null +++ b/test/ui-e2e/tests/history-and-rollback.spec.ts @@ -0,0 +1,254 @@ +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('Application History and Rollback', () => { + const stamp = Date.now(); + const appName = `ui-rollback-${stamp}`; + const destNs = `ui-rollback-ns-${stamp}`; + //two guestbook commits with different images so rollback leaves live state behind git head + const previousCommit = '68657670d9131dc5bc5f538b14c1de3377d74591'; + const currentCommit = '8088f4c0d970abb09e250248cc97e35623447cb5'; + const previousImage = 'quay.io/argoprojlabs/argocd-e2e-container:0.2'; + + 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; + }; + + const applyApp = (targetRevision: string) => { + const appYaml = ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: ${appName} + namespace: openshift-gitops +spec: + destination: + namespace: ${destNs} + server: https://kubernetes.default.svc + project: default + source: + path: guestbook + repoURL: https://github.com/argoproj/argocd-example-apps.git + targetRevision: ${targetRevision} +`; + execFileSync('oc', ['apply', '-f', '-'], { input: appYaml, stdio: 'pipe', timeout: 15000 }); + }; + + const triggerSync = () => { + execFileSync( + 'oc', + [ + 'patch', 'application', appName, '-n', 'openshift-gitops', + '--type', 'merge', + '-p', '{"operation":{"initiatedBy":{"username":"admin"},"sync":{}}}', + ], + { stdio: 'pipe', timeout: 15000 } + ); + }; + + const guestbookImage = (): string => + ocGet([ + 'get', 'deploy', 'guestbook-ui', '-n', destNs, '--ignore-not-found', + '-o', 'jsonpath={.spec.template.spec.containers[0].image}', + ]); + + test.beforeAll(async ({}, testInfo) => { + testInfo.setTimeout(300000); + console.log(`\n[setup] Deploying '${appName}' with two guestbook syncs...`); + + 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 } + ); + + let rbacReady = false; + for (let i = 1; i <= 30; i++) { + const rbs = ocGet(['get', 'rolebinding', '-n', destNs, '-o', 'name']); + if (/argocd-application-controller/i.test(rbs)) { + rbacReady = true; + break; + } + await new Promise((r) => setTimeout(r, 2000)); + } + if (!rbacReady) { + throw new Error( + `Namespace '${destNs}' never received openshift-gitops application-controller RoleBinding.` + ); + } + + applyApp(previousCommit); + + let lastSync = ''; + let lastResource = ''; + for (let i = 1; i <= 30; i++) { + lastSync = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.status.sync.status}', + ]); + lastResource = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.status.resources[0].name}', + ]); + console.log(`[setup] compared sync='${lastSync || '-'}' resource='${lastResource || '-'}' (${i}/30)`); + if (lastSync === 'OutOfSync' && lastResource) break; + await new Promise((r) => setTimeout(r, 3000)); + } + if (lastSync !== 'OutOfSync' || !lastResource) { + throw new Error( + `Application '${appName}' never reached OutOfSync with compared resources ` + + `(sync='${lastSync || '-'}' resource='${lastResource || '-'}').` + ); + } + + triggerSync(); + + let firstHealth = ''; + let firstSynced = false; + for (let i = 1; i <= 40; i++) { + lastSync = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.status.sync.status}', + ]); + firstHealth = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.status.health.status}', + ]); + console.log(`[setup] first sync='${lastSync || '-'}' health='${firstHealth || '-'}' (${i}/40)`); + if (lastSync === 'Synced' && firstHealth === 'Healthy') { + firstSynced = true; + break; + } + await new Promise((r) => setTimeout(r, 3000)); + } + if (!firstSynced) { + throw new Error( + `First revision never reached Synced/Healthy ` + + `(sync='${lastSync || '-'}' health='${firstHealth || '-'}').` + ); + } + + applyApp(currentCommit); + + let drifted = false; + for (let i = 1; i <= 30; i++) { + lastSync = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.status.sync.status}', + ]); + console.log(`[setup] after bump sync='${lastSync || '-'}' (${i}/30)`); + if (lastSync === 'OutOfSync') { + drifted = true; + break; + } + await new Promise((r) => setTimeout(r, 3000)); + } + if (!drifted) { + throw new Error(`Application '${appName}' never drifted after bumping targetRevision.`); + } + + triggerSync(); + + let secondSynced = false; + let history = ''; + for (let i = 1; i <= 40; i++) { + lastSync = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.status.sync.status}', + ]); + history = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.status.history[*].revision}', + ]); + const hasPrevious = history.includes(previousCommit); + const hasCurrent = history.includes(currentCommit); + console.log(`[setup] second sync='${lastSync || '-'}' history='${history || '-'}' (${i}/40)`); + if (lastSync === 'Synced' && hasPrevious && hasCurrent) { + secondSynced = true; + break; + } + await new Promise((r) => setTimeout(r, 3000)); + } + if (!secondSynced) { + throw new Error( + `Second revision never reached Synced with two history entries ` + + `(sync='${lastSync || '-'}' history='${history || '-'}').` + ); + } + }); + + test.afterAll(async ({}, testInfo) => { + testInfo.setTimeout(90000); + console.log(`\n[teardown] Cleaning up '${appName}' and '${destNs}'...`); + + 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}`); + } + + execFileSync( + 'oc', + ['delete', 'namespace', destNs, '--ignore-not-found', '--wait=false'], + { stdio: 'pipe', timeout: 15000 } + ); + + 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('Rollback to a previous revision from History and Rollback', async ({ page }) => { + test.setTimeout(240000); + + const appsPage = new ApplicationsPage(page); + const detailsPage = new ApplicationDetailsPage(page); + + await appsPage.navigate(); + await appsPage.openApplication(appName); + await detailsPage.openHistoryAndRollback(); + await detailsPage.rollbackToPreviousRevision(appName, previousCommit); + + await expect + .poll( + () => { + const sync = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.status.sync.status}', + ]); + const health = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.status.health.status}', + ]); + return `${sync},${health}`; + }, + { timeout: 180000, message: 'waiting for OutOfSync + Healthy after rollback' } + ) + .toBe('OutOfSync,Healthy'); + + expect(guestbookImage(), 'rollback should restore the previous guestbook image').toBe(previousImage); + + await detailsPage.assertOutOfSyncHealthy(); + }); +}); From 2f34ca60a3ee3d73119661c3e9a300c36a1d06d2 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 4 Nov 2026 14:48:44 +0000 Subject: [PATCH 2/6] address Coderabbit's feedback Signed-off-by: Triona Doyle --- .../ui-e2e/tests/history-and-rollback.spec.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/test/ui-e2e/tests/history-and-rollback.spec.ts b/test/ui-e2e/tests/history-and-rollback.spec.ts index 2f12f96f5ce..4ee46cdbdd7 100644 --- a/test/ui-e2e/tests/history-and-rollback.spec.ts +++ b/test/ui-e2e/tests/history-and-rollback.spec.ts @@ -15,6 +15,15 @@ test.describe('Application History and Rollback', () => { const ocGet = (args: string[]): string => execFileSync('oc', args, { stdio: 'pipe', timeout: 5000 }).toString().trim(); + //failed oc read → empty so this attempt stays not-ready + const tryOcGet = (args: string[]): string => { + try { + return ocGet(args); + } catch { + return ''; + } + }; + const applicationExists = (): boolean => { const out = ocGet([ 'get', 'application', appName, '-n', 'openshift-gitops', '--ignore-not-found', '-o', 'name', @@ -61,7 +70,7 @@ spec: ]); test.beforeAll(async ({}, testInfo) => { - testInfo.setTimeout(300000); + testInfo.setTimeout(600000); console.log(`\n[setup] Deploying '${appName}' with two guestbook syncs...`); execFileSync('oc', ['create', 'namespace', destNs], { stdio: 'pipe', timeout: 15000 }); @@ -73,7 +82,7 @@ spec: let rbacReady = false; for (let i = 1; i <= 30; i++) { - const rbs = ocGet(['get', 'rolebinding', '-n', destNs, '-o', 'name']); + const rbs = tryOcGet(['get', 'rolebinding', '-n', destNs, '-o', 'name']); if (/argocd-application-controller/i.test(rbs)) { rbacReady = true; break; @@ -91,11 +100,11 @@ spec: let lastSync = ''; let lastResource = ''; for (let i = 1; i <= 30; i++) { - lastSync = ocGet([ + lastSync = tryOcGet([ 'get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.sync.status}', ]); - lastResource = ocGet([ + lastResource = tryOcGet([ 'get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.resources[0].name}', ]); @@ -115,11 +124,11 @@ spec: let firstHealth = ''; let firstSynced = false; for (let i = 1; i <= 40; i++) { - lastSync = ocGet([ + lastSync = tryOcGet([ 'get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.sync.status}', ]); - firstHealth = ocGet([ + firstHealth = tryOcGet([ 'get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.health.status}', ]); @@ -141,7 +150,7 @@ spec: let drifted = false; for (let i = 1; i <= 30; i++) { - lastSync = ocGet([ + lastSync = tryOcGet([ 'get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.sync.status}', ]); @@ -161,11 +170,11 @@ spec: let secondSynced = false; let history = ''; for (let i = 1; i <= 40; i++) { - lastSync = ocGet([ + lastSync = tryOcGet([ 'get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.sync.status}', ]); - history = ocGet([ + history = tryOcGet([ 'get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.history[*].revision}', ]); From 23e95b2ad7e265aa2fade59be7e4d449ffcb4a9c Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 4 Nov 2026 14:58:56 +0000 Subject: [PATCH 3/6] address further coderabbit feedback Signed-off-by: Triona Doyle --- test/ui-e2e/tests/history-and-rollback.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ui-e2e/tests/history-and-rollback.spec.ts b/test/ui-e2e/tests/history-and-rollback.spec.ts index 4ee46cdbdd7..9d92ae2917c 100644 --- a/test/ui-e2e/tests/history-and-rollback.spec.ts +++ b/test/ui-e2e/tests/history-and-rollback.spec.ts @@ -229,7 +229,7 @@ spec: }); test('Rollback to a previous revision from History and Rollback', async ({ page }) => { - test.setTimeout(240000); + test.setTimeout(480000); const appsPage = new ApplicationsPage(page); const detailsPage = new ApplicationDetailsPage(page); From 22f562dfb3fc04582d8cea0e3217e9a37fa65bc8 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 4 Nov 2026 16:56:42 +0000 Subject: [PATCH 4/6] address even more coderabbit feedback Signed-off-by: Triona Doyle --- test/ui-e2e/src/pages/ApplicationDetailsPage.ts | 17 +++++++++++------ test/ui-e2e/tests/history-and-rollback.spec.ts | 4 ++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts index 65e232b24c0..3ab40ed2a2f 100644 --- a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts @@ -242,12 +242,17 @@ export class ApplicationDetailsPage { } private async applicationOperation(appName: string) { - const res = await this.page.request.get( - `/api/v1/applications/${encodeURIComponent(appName)}` - ); - if (!res.ok()) return undefined; - const app = await res.json(); - return app?.status?.operationState; + try { + const res = await this.page.request.get( + `/api/v1/applications/${encodeURIComponent(appName)}` + ); + if (!res.ok()) return undefined; + const app = await res.json(); + return app?.status?.operationState; + } catch { + //keep polling on a transient api blip + return undefined; + } } } diff --git a/test/ui-e2e/tests/history-and-rollback.spec.ts b/test/ui-e2e/tests/history-and-rollback.spec.ts index 9d92ae2917c..b8ad779cc10 100644 --- a/test/ui-e2e/tests/history-and-rollback.spec.ts +++ b/test/ui-e2e/tests/history-and-rollback.spec.ts @@ -242,11 +242,11 @@ spec: await expect .poll( () => { - const sync = ocGet([ + const sync = tryOcGet([ 'get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.sync.status}', ]); - const health = ocGet([ + const health = tryOcGet([ 'get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.health.status}', ]); From 03a981c614992aefc4b5b0acb21be51f3523f035 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 4 Nov 2026 17:26:11 +0000 Subject: [PATCH 5/6] address further coderabbit feedback Signed-off-by: Triona Doyle --- test/ui-e2e/tests/history-and-rollback.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/ui-e2e/tests/history-and-rollback.spec.ts b/test/ui-e2e/tests/history-and-rollback.spec.ts index b8ad779cc10..96a68d45a75 100644 --- a/test/ui-e2e/tests/history-and-rollback.spec.ts +++ b/test/ui-e2e/tests/history-and-rollback.spec.ts @@ -64,7 +64,7 @@ spec: }; const guestbookImage = (): string => - ocGet([ + tryOcGet([ 'get', 'deploy', 'guestbook-ui', '-n', destNs, '--ignore-not-found', '-o', 'jsonpath={.spec.template.spec.containers[0].image}', ]); @@ -256,7 +256,9 @@ spec: ) .toBe('OutOfSync,Healthy'); - expect(guestbookImage(), 'rollback should restore the previous guestbook image').toBe(previousImage); + await expect + .poll(guestbookImage, { timeout: 180000, message: 'waiting for guestbook image after rollback' }) + .toBe(previousImage); await detailsPage.assertOutOfSyncHealthy(); }); From 2cca3e0d206d7691cdbe082ed26e76265f58eb9b Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Wed, 4 Nov 2026 20:25:37 +0000 Subject: [PATCH 6/6] address even more coderabbit feedback Signed-off-by: Triona Doyle --- test/ui-e2e/src/pages/ApplicationDetailsPage.ts | 2 +- test/ui-e2e/tests/history-and-rollback.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts index 3ab40ed2a2f..47f04d3d296 100644 --- a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts @@ -238,7 +238,7 @@ export class ApplicationDetailsPage { async assertOutOfSyncHealthy() { const panel = this.page.locator('.application-status-panel'); await expect(panel.getByText(/OutOfSync|Out of Sync/i).first()).toBeVisible({ timeout: 30000 }); - await expect(panel.getByText(/Healthy/i).first()).toBeVisible({ timeout: 30000 }); + await expect(panel.getByText('Healthy', { exact: true }).first()).toBeVisible({ timeout: 30000 }); } private async applicationOperation(appName: string) { diff --git a/test/ui-e2e/tests/history-and-rollback.spec.ts b/test/ui-e2e/tests/history-and-rollback.spec.ts index 96a68d45a75..deff85cc83f 100644 --- a/test/ui-e2e/tests/history-and-rollback.spec.ts +++ b/test/ui-e2e/tests/history-and-rollback.spec.ts @@ -196,7 +196,7 @@ spec: }); test.afterAll(async ({}, testInfo) => { - testInfo.setTimeout(90000); + testInfo.setTimeout(180000); console.log(`\n[teardown] Cleaning up '${appName}' and '${destNs}'...`); try {