Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions test/ui-e2e/src/pages/ApplicationDetailsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,98 @@ 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', { exact: true }).first()).toBeVisible({ timeout: 30000 });
}

private async applicationOperation(appName: string) {
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;
}
}
}

function operationId(op: { startedAt?: string; finishedAt?: string } | undefined) {
return `${op?.startedAt ?? ''}|${op?.finishedAt ?? ''}`;
}
265 changes: 265 additions & 0 deletions test/ui-e2e/tests/history-and-rollback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
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();

//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',
]);
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 =>
tryOcGet([
'get', 'deploy', 'guestbook-ui', '-n', destNs, '--ignore-not-found',
'-o', 'jsonpath={.spec.template.spec.containers[0].image}',
]);

test.beforeAll(async ({}, testInfo) => {
testInfo.setTimeout(600000);
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 = tryOcGet(['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 = tryOcGet([
'get', 'application', appName, '-n', 'openshift-gitops',
'-o', 'jsonpath={.status.sync.status}',
]);
lastResource = tryOcGet([
'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 = tryOcGet([
'get', 'application', appName, '-n', 'openshift-gitops',
'-o', 'jsonpath={.status.sync.status}',
]);
firstHealth = tryOcGet([
'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 = tryOcGet([
'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 = tryOcGet([
'get', 'application', appName, '-n', 'openshift-gitops',
'-o', 'jsonpath={.status.sync.status}',
]);
history = tryOcGet([
'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 || '-'}').`
);
}
Comment thread
trdoyle81 marked this conversation as resolved.
Resolved
});

test.afterAll(async ({}, testInfo) => {
testInfo.setTimeout(180000);
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(480000);

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 = tryOcGet([
'get', 'application', appName, '-n', 'openshift-gitops',
'-o', 'jsonpath={.status.sync.status}',
]);
const health = tryOcGet([
'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');

await expect
.poll(guestbookImage, { timeout: 180000, message: 'waiting for guestbook image after rollback' })
.toBe(previousImage);

await detailsPage.assertOutOfSyncHealthy();
});
});
Loading