From 0f7521f56ccc5dbdcc76e8b67e4420bc0d52ef1a Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Mon, 12 Oct 2026 13:07:41 +0100 Subject: [PATCH 1/3] Add dry-run Sync Playwright test Signed-off-by: Triona Doyle --- test/ui-e2e/src/pages/ApplicationsPage.ts | 66 +++++++++++ test/ui-e2e/tests/dry-run-sync.spec.ts | 135 ++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 test/ui-e2e/tests/dry-run-sync.spec.ts diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index a63be5c0c53..2fa32efd9c2 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -187,4 +187,70 @@ export class ApplicationsPage { await appNameLink.click(); await expect(this.page).toHaveURL(/.*\/applications\/.*\/.*/, { timeout: TIMEOUTS.default }); } + + private appCard(appName: string): Locator { + return this.page + .locator( + '.white-box, .argo-table-list__row, .application-tile, [class*="application-tile"], [class*="applications-list__entry"]' + ) + .filter({ hasText: appName }) + .first(); + } + + private syncPanel(): Locator { + return this.page.locator('.sliding-panel').filter({ visible: true }).first(); + } + + //v1.20–v1.22 (or argocd v3.3–v3.5) the heading and card sync control a bit + async openSyncPanel(appName: string) { + await this.page.getByPlaceholder(/Search applications/i).fill(appName); + const card = this.appCard(appName); + await card.waitFor({ state: 'visible', timeout: TIMEOUTS.load }); + await expect(card.getByText(/OutOfSync|Out of Sync/i).first()).toBeVisible({ + timeout: TIMEOUTS.sync, + }); + + const syncBtn = card + .getByRole('button', { name: /^sync$/i }) + .or(card.getByText('Sync', { exact: true })); + await syncBtn.first().click(); + + const heading = this.page.getByText(/SYNCHRONIZE RESOURCES/i); + await expect(this.syncPanel().or(heading).first()).toBeVisible({ timeout: TIMEOUTS.panel }); + } + + async dryRunSynchronize(appName: string) { + await this.openSyncPanel(appName); + const panel = this.syncPanel(); + + const dryRun = panel + .getByRole('checkbox', { name: /dry run/i }) + .or(panel.getByLabel(/dry run/i)) + .or(this.page.getByRole('checkbox', { name: /dry run/i })) + .or(this.page.locator('#dry-run, #dryRun, [qe-id*="dry-run" i]')) + .first(); + await expect(dryRun).toBeVisible({ timeout: TIMEOUTS.default }); + const alreadyOn = await dryRun.isChecked().catch(() => false); + if (!alreadyOn) { + await dryRun.click(); + } + + await panel.getByRole('button', { name: /^synchronize$/i }).first().click(); + + await expect + .poll( + async () => { + const res = await this.page.request.get( + `/api/v1/applications/${encodeURIComponent(appName)}` + ); + if (!res.ok()) return ''; + const app = await res.json(); + const op = app?.status?.operationState; + const dry = op?.operation?.sync?.dryRun === true; + return dry ? String(op?.phase || '') : ''; + }, + { timeout: TIMEOUTS.sync, message: 'waiting for dry-run operation to succeed' } + ) + .toMatch(/Succeeded/i); + } } \ No newline at end of file diff --git a/test/ui-e2e/tests/dry-run-sync.spec.ts b/test/ui-e2e/tests/dry-run-sync.spec.ts new file mode 100644 index 00000000000..d4937ec170a --- /dev/null +++ b/test/ui-e2e/tests/dry-run-sync.spec.ts @@ -0,0 +1,135 @@ +import { test, expect } from '../src/fixtures'; +import { execFileSync } from 'child_process'; +import { ApplicationsPage } from '../src/pages/ApplicationsPage'; + +test.describe('Dry Run Sync', () => { + const stamp = Date.now(); + const appName = `ui-dryrun-${stamp}`; + const destNs = `ui-dryrun-ns-${stamp}`; + //pin guestbook so 1.20/1.21/1.22 all resolve the same manifests + 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 live children — must stay absent after a dry run + const liveGuestbookResources = (): string => { + const kinds = ['deploy', 'svc'] as const; + return kinds + .map((kind) => + ocGet(['get', kind, 'guestbook-ui', '-n', destNs, '--ignore-not-found', '-o', 'name']) + ) + .filter(Boolean) + .join('\n'); + }; + + test.beforeAll(async ({}, testInfo) => { + testInfo.setTimeout(180000); + console.log(`\n[setup] Deploying '${appName}' via CLI (manual sync, for dry-run)...`); + + 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 } + ); + + 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: ${targetCommit} +`; + execFileSync('oc', ['apply', '-f', '-'], { input: appYaml, stdio: 'pipe', timeout: 15000 }); + + 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] 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 || '-'}').` + ); + } + + expect(liveGuestbookResources(), 'guestbook must not exist before dry-run').toBe(''); + }); + + 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('Dry-run sync from the UI does not change live cluster resources', async ({ page }) => { + test.setTimeout(180000); + + const appsPage = new ApplicationsPage(page); + await appsPage.navigate(); + await appsPage.dryRunSynchronize(appName); + + expect(liveGuestbookResources(), 'dry-run must not create guestbook deploy/svc').toBe(''); + + const syncStatus = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.status.sync.status}', + ]); + expect(syncStatus, 'app should stay OutOfSync after dry-run').toBe('OutOfSync'); + }); +}); From 08720e21de2c07b8f729d1276ebf2eacfd808c95 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Mon, 12 Oct 2026 14:36:16 +0100 Subject: [PATCH 2/3] address coderabbit nitpick Signed-off-by: Triona Doyle --- test/ui-e2e/src/pages/ApplicationsPage.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index 2fa32efd9c2..20b72cebf1f 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -235,6 +235,8 @@ export class ApplicationsPage { await dryRun.click(); } + //ignore a succeeded dry-run left on the app from an earlier attempt + const clickedAt = Date.now() - 1000; await panel.getByRole('button', { name: /^synchronize$/i }).first().click(); await expect @@ -246,7 +248,8 @@ export class ApplicationsPage { if (!res.ok()) return ''; const app = await res.json(); const op = app?.status?.operationState; - const dry = op?.operation?.sync?.dryRun === true; + const started = op?.startedAt ? Date.parse(op.startedAt) : 0; + const dry = op?.operation?.sync?.dryRun === true && started >= clickedAt; return dry ? String(op?.phase || '') : ''; }, { timeout: TIMEOUTS.sync, message: 'waiting for dry-run operation to succeed' } From b038329928d6cf3a1531a0dae9eaec6077c32165 Mon Sep 17 00:00:00 2001 From: Triona Doyle Date: Mon, 12 Oct 2026 14:44:58 +0100 Subject: [PATCH 3/3] address coderabbit feedback comment Signed-off-by: Triona Doyle --- test/ui-e2e/src/pages/ApplicationsPage.ts | 28 +++++++++++++++-------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index 20b72cebf1f..07e7bec6be9 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -235,25 +235,33 @@ export class ApplicationsPage { await dryRun.click(); } - //ignore a succeeded dry-run left on the app from an earlier attempt - const clickedAt = Date.now() - 1000; + //need a new op, not the last dry-run still on the app + const previousOp = await this.applicationOperation(appName); + const previousId = operationId(previousOp); await panel.getByRole('button', { name: /^synchronize$/i }).first().click(); await expect .poll( async () => { - const res = await this.page.request.get( - `/api/v1/applications/${encodeURIComponent(appName)}` - ); - if (!res.ok()) return ''; - const app = await res.json(); - const op = app?.status?.operationState; - const started = op?.startedAt ? Date.parse(op.startedAt) : 0; - const dry = op?.operation?.sync?.dryRun === true && started >= clickedAt; + const op = await this.applicationOperation(appName); + const dry = op?.operation?.sync?.dryRun === true && operationId(op) !== previousId; return dry ? String(op?.phase || '') : ''; }, { timeout: TIMEOUTS.sync, message: 'waiting for dry-run operation to succeed' } ) .toMatch(/Succeeded/i); } + + 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 ?? ''}`; } \ No newline at end of file