Skip to content
77 changes: 77 additions & 0 deletions test/ui-e2e/src/pages/ApplicationsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,81 @@ 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();
}

//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 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 ?? ''}`;
}
135 changes: 135 additions & 0 deletions test/ui-e2e/tests/dry-run-sync.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading