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
19 changes: 14 additions & 5 deletions test/ui-e2e/.auth/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,26 @@ 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 });
await skipTour.or(closeBtn).first().waitFor({ state: 'visible', timeout: TIMEOUTS.medium });
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;
}
}
Expand Down
17 changes: 14 additions & 3 deletions test/ui-e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,18 @@ export CLUSTER_USER="kubeadmin"
export CLUSTER_PASSWORD="<your_cluster_password>"
export OC_API_URL="<your_cluster_server_url>"
export IDP="kube:admin" # (Optional) Defaults to kube:admin

# Optional — private-repo.spec.ts (credentials in Bitwarden)
export PRIVATE_REPO_URL="<private_git_https_url>"
export PRIVATE_REPO_USERNAME="<username>"
export PRIVATE_REPO_TOKEN="<token_or_password>"
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
Expand Down Expand Up @@ -91,11 +98,15 @@ 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
│ ├── ApplicationDetailsPage.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
```
Expand All @@ -113,4 +124,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.
* **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.
130 changes: 114 additions & 16 deletions test/ui-e2e/src/pages/ApplicationDetailsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -66,4 +61,107 @@ export class ApplicationDetailsPage {
await expect(genericLogLine).toBeVisible({ timeout: 30000 });
}
}
}

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/${encodeURIComponent(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}`);
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;
}

await expect(enableBtn).toBeVisible({ timeout: 15000 });
await enableBtn.click();
await this.confirmArgoPopup();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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();
}
}
86 changes: 46 additions & 40 deletions test/ui-e2e/src/pages/ApplicationsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,25 +48,47 @@ 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 (wait only for late banners)
private async dismissLoadErrorBanner(waitForLateMs = 0) {
const errorBanner = this.page.getByText(/try again/i);
if (await errorBanner.isVisible()) {
await errorBanner.click();
return;
}
if (waitForLateMs <= 0) return;
try {
//wait 3 secs
await errorBanner.waitFor({ state: 'visible', timeout: TIMEOUTS.short });
await errorBanner.click();
await errorBanner.waitFor({ state: 'visible', timeout: waitForLateMs });
} 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;
}
await errorBanner.click();
}

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
Expand All @@ -82,20 +104,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 });

Expand Down Expand Up @@ -167,18 +176,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 }).filter({ visible: true }).first();

await expect(appNameLink).toBeVisible({ timeout: TIMEOUTS.load });
await appNameLink.click();
await expect(this.page).toHaveURL(/.*\/applications\/.*\/.*/, { timeout: TIMEOUTS.default });
}
}
Loading
Loading