diff --git a/test/ui-e2e/.auth/setup.ts b/test/ui-e2e/.auth/setup.ts index 8cf9728ee04..b870070cbf6 100644 --- a/test/ui-e2e/.auth/setup.ts +++ b/test/ui-e2e/.auth/setup.ts @@ -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; } } diff --git a/test/ui-e2e/README.md b/test/ui-e2e/README.md index d9e1b9092a1..99ca40bde86 100644 --- a/test/ui-e2e/README.md +++ b/test/ui-e2e/README.md @@ -38,11 +38,18 @@ export CLUSTER_USER="kubeadmin" export CLUSTER_PASSWORD="" export OC_API_URL="" export IDP="kube:admin" # (Optional) Defaults to kube:admin + +# Optional — private-repo.spec.ts (credentials in Bitwarden) +export PRIVATE_REPO_URL="" +export PRIVATE_REPO_USERNAME="" +export PRIVATE_REPO_TOKEN="" 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 @@ -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 ``` @@ -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. \ No newline at end of file +* **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. diff --git a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts index ff9a75b4d05..f1d670d1b81 100644 --- a/test/ui-e2e/src/pages/ApplicationDetailsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationDetailsPage.ts @@ -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) { @@ -66,4 +61,107 @@ export class ApplicationDetailsPage { await expect(genericLogLine).toBeVisible({ timeout: 30000 }); } } -} \ No newline at end of file + + 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(); + } + + 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(); + } +} diff --git a/test/ui-e2e/src/pages/ApplicationsPage.ts b/test/ui-e2e/src/pages/ApplicationsPage.ts index d882139ef9e..a63be5c0c53 100644 --- a/test/ui-e2e/src/pages/ApplicationsPage.ts +++ b/test/ui-e2e/src/pages/ApplicationsPage.ts @@ -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 @@ -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 }); @@ -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 }); } } \ No newline at end of file diff --git a/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts b/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts new file mode 100644 index 00000000000..e83bd5f396d --- /dev/null +++ b/test/ui-e2e/src/pages/SettingsRepositoriesPage.ts @@ -0,0 +1,379 @@ +import { Page, expect, Locator } from '@playwright/test'; +import { execFileSync, execSync } from 'node:child_process'; + +export class SettingsRepositoriesPage { + readonly page: Page; + readonly connectRepoButton: Locator; + + constructor(page: Page) { + this.page = page; + this.connectRepoButton = page + .getByRole('button', { name: /Connect Repo/i }) + .or(page.getByText('Connect Repo', { exact: true })); + } + + private passwordField() { + return this.page.getByLabel(/^Password/i); + } + + private usernameField() { + return this.page.getByLabel(/Username/i); + } + + private slidingPanel(): Locator { + return this.page.locator('.sliding-panel--opened, .sliding-panel').filter({ visible: true }).first(); + } + + private repoRow(repoUrl: string): Locator { + return this.page.locator('.argo-table-list__row, .white-box, tr').filter({ hasText: repoUrl }).first(); + } + + //redact to avoid leaking tokens + private redact(text: string): string { + let out = text; + for (const secret of [ + process.env.PRIVATE_REPO_TOKEN, + process.env.PRIVATE_REPO_PASSWORD, + process.env.CLUSTER_PASSWORD, + ]) { + if (secret && secret.length > 0) { + out = out.split(secret).join(''); + } + } + return out; + } + + private async clearSecretsFromForm() { + await this.passwordField().fill('', { timeout: 2000 }).catch(() => undefined); + await this.usernameField().fill('', { timeout: 2000 }).catch(() => undefined); + } + + //SSO api delete often 403/415 — remove matching repo secrets via oc + private deleteRepoSecretsViaOc(repoUrl: string): number { + let raw: string; + try { + raw = execSync('oc get secrets -n openshift-gitops -o json', { + encoding: 'utf8', + timeout: 60000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (e) { + console.warn(`[private-repo] oc get secrets failed: ${(e as Error).message}`); + return 0; + } + + const data = JSON.parse(raw) as { + items?: Array<{ + metadata?: { name?: string; labels?: Record }; + data?: Record; + }>; + }; + const toDelete: string[] = []; + for (const secret of data.items || []) { + const name = secret.metadata?.name; + const labels = secret.metadata?.labels || {}; + const b64 = secret.data || {}; + //repo secrets only + if (!name || labels['argocd.argoproj.io/secret-type'] !== 'repository') continue; + + let urlVal = ''; + if (b64.url) { + try { + urlVal = Buffer.from(b64.url, 'base64').toString('utf8').trim(); + } catch { + urlVal = ''; + } + } + if (urlVal === repoUrl) { + toDelete.push(name); + } + } + + let deleted = 0; + for (const name of [...new Set(toDelete)]) { + try { + execFileSync('oc', ['delete', 'secret', name, '-n', 'openshift-gitops', '--wait=true'], { + encoding: 'utf8', + timeout: 60000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + deleted += 1; + console.log(`[private-repo] deleted repo secret via oc (${deleted})`); + } catch (e) { + console.warn(`[private-repo] oc delete secret failed: ${(e as Error).message}`); + } + } + return deleted; + } + + private async tryDeleteRepoViaApi(repoUrl: string): Promise { + const encodings = [ + encodeURIComponent(repoUrl), + encodeURIComponent(encodeURIComponent(repoUrl)), + ]; + let lastStatus = 0; + for (const encoded of encodings) { + for (const headers of [ + { 'Content-Type': 'application/json' }, + { 'Content-Type': 'application/json', Accept: 'application/json' }, + ]) { + const response = await this.page.request.delete(`/api/v1/repositories/${encoded}`, { + headers, + data: {}, + }); + lastStatus = response.status(); + if (response.ok() || lastStatus === 404) { + return true; + } + } + } + console.warn(`[private-repo] api delete unavailable (last status ${lastStatus})`); + return false; + } + + private async tryDeleteRepoViaUi(repoUrl: string): Promise { + await this.page.goto('/settings/repos'); + await expect(this.connectRepoButton).toBeVisible({ timeout: 20000 }); + await expect(this.page.getByText('Loading...', { exact: true })).toHaveCount(0, { timeout: 60000 }); + + const row = this.repoRow(repoUrl); + if (!(await row.isVisible().catch(() => false))) { + return true; + } + + console.log('[private-repo] removing repo via ui'); + const menuBtn = row + .getByRole('button') + .filter({ hasNotText: /Successful|Failed|Unknown/i }) + .last() + .or(row.locator('button').last()); + await menuBtn.click(); + + const disconnect = this.page + .getByRole('button', { name: /Disconnect|Remove|Delete/i }) + .or(this.page.getByText(/Disconnect|Remove repository|Delete/i)) + .first(); + await expect(disconnect).toBeVisible({ timeout: 10000 }); + await disconnect.click(); + + const ok = this.page.locator('[qe-id="argo-popup-ok-button"]').or( + this.page.getByRole('button', { name: /^(OK|Confirm|Remove|Disconnect)$/i }) + ); + if (await ok.first().isVisible().catch(() => false)) { + await ok.first().click(); + } + + await expect(row).toBeHidden({ timeout: 30000 }); + return true; + } + + async ensureRepoRemoved(repoUrl: string): Promise { + console.log('[private-repo] ensuring repo is fully removed'); + await this.clearSecretsFromForm().catch(() => undefined); + + const cancel = this.slidingPanel().getByRole('button', { name: /^Cancel$/i }); + if (await cancel.isVisible().catch(() => false)) { + await cancel.click().catch(() => undefined); + } + + const apiOk = await this.tryDeleteRepoViaApi(repoUrl); + if (!apiOk) { + try { + await this.tryDeleteRepoViaUi(repoUrl); + } catch (e) { + console.warn(`[private-repo] ui delete failed: ${(e as Error).message}`); + } + } + + this.deleteRepoSecretsViaOc(repoUrl); + + await this.page.goto('/settings/repos'); + await expect(this.connectRepoButton).toBeVisible({ timeout: 20000 }); + await expect(this.page.getByText('Loading...', { exact: true })).toHaveCount(0, { timeout: 60000 }); + await expect(this.page.getByText(repoUrl, { exact: true })).toHaveCount(0, { timeout: 30000 }); + + const leftover = this.deleteRepoSecretsViaOc(repoUrl); + if (leftover > 0) { + await this.page.reload(); + await expect(this.page.getByText('Loading...', { exact: true })).toHaveCount(0, { timeout: 60000 }); + await expect(this.page.getByText(repoUrl, { exact: true })).toHaveCount(0, { timeout: 30000 }); + } + console.log('[private-repo] repo cleanup verified'); + } + + async navigate() { + console.log('[private-repo] opening settings/repos'); + await this.page.goto('/settings/repos'); + await expect(this.connectRepoButton).toBeVisible({ timeout: 20000 }); + await expect(this.page.getByText('Loading...', { exact: true })).toHaveCount(0, { timeout: 60000 }); + } + + private async selectHttpsConnectionMethod() { + console.log('[private-repo] selecting VIA HTTP/HTTPS'); + await expect(async () => { + if (!(await this.slidingPanel().isVisible().catch(() => false))) { + await this.page.goto('/settings/repos?addRepo=true'); + await expect(this.slidingPanel()).toBeVisible({ timeout: 20000 }); + } + const panel = this.slidingPanel(); + if (await panel.getByText(/CONNECT REPO USING HTTP\/HTTPS/i).isVisible().catch(() => false)) { + return; + } + + const methodTrigger = panel.locator('p').filter({ hasText: /VIA\s+/i }).first(); + await expect(methodTrigger).toBeVisible({ timeout: 5000 }); + await methodTrigger.click(); + + const httpsOption = this.page + .locator('.argo-dropdown__content li') + .filter({ hasText: /VIA\s*HTTP\/HTTPS/i }) + .first(); + await expect(httpsOption).toBeVisible({ timeout: 5000 }); + //portaled dropdown options often never become Playwright-stable + await httpsOption.click({ force: true }); + + await expect(this.slidingPanel().getByText(/CONNECT REPO USING HTTP\/HTTPS/i)).toBeVisible({ + timeout: 5000, + }); + }).toPass({ timeout: 45000, intervals: [500, 1000, 2000] }); + } + + async connectHttpsRepo(repoUrl: string, username: string, password: string) { + await this.ensureRepoRemoved(repoUrl); + + console.log('[private-repo] opening connect repo panel'); + await this.page.goto('/settings/repos?addRepo=true'); + await expect(this.page).toHaveURL(/addRepo=true/, { timeout: 10000 }); + await expect(this.slidingPanel()).toBeVisible({ timeout: 20000 }); + await expect(this.slidingPanel().getByText(/Choose your connection method/i)).toBeVisible({ + timeout: 15000, + }); + + await this.selectHttpsConnectionMethod(); + await expect(this.slidingPanel().getByText(/CONNECT REPO USING HTTP\/HTTPS/i)).toBeVisible({ + timeout: 10000, + }); + + console.log('[private-repo] filling https form'); + const repoUrlField = this.slidingPanel() + .getByLabel(/Repository URL/i) + .or(this.slidingPanel().getByPlaceholder(/https:\/\/github\.com/i)); + await repoUrlField.fill(repoUrl); + if (username) { + await this.usernameField().fill(username); + } + await this.passwordField().fill(password); + + //oauth2/token often needs force basic auth + const forceBasic = this.slidingPanel().getByLabel(/Force HTTP basic auth/i); + if (await forceBasic.isVisible().catch(() => false)) { + if (!(await forceBasic.isChecked())) { + console.log('[private-repo] enabling force HTTP basic auth'); + await forceBasic.check(); + } + } + + //internal CA not trusted by default + const skipTls = this.slidingPanel().getByLabel(/Skip server verification/i); + if (await skipTls.isVisible().catch(() => false)) { + if (!(await skipTls.isChecked())) { + console.log('[private-repo] enabling skip server verification'); + await skipTls.check(); + } + } + + console.log('[private-repo] clicking connect'); + await this.slidingPanel().getByRole('button', { name: /^Connect$/i }).click(); + } + + private async repoPresentInApi(repoUrl: string): Promise { + try { + const response = await this.page.request.get('/api/v1/repositories'); + if (!response.ok()) return false; + const body = (await response.json()) as { items?: Array<{ repo?: string; url?: string }> }; + return (body.items || []).some((item) => item.repo === repoUrl || item.url === repoUrl); + } catch { + return false; + } + } + + private async visibleConnectionError(): Promise { + //real failure toasts only + const failureBanner = this.page + .getByText(/Unable to connect HTTPS repository/i) + .or(this.page.getByText(/Unable to connect repository/i)) + .or(this.page.getByText(/Failed to connect/i)) + .first(); + if (await failureBanner.isVisible().catch(() => false)) { + const detail = ( + await this.page + .locator('.notifications-list, .toast, [class*="notification"], .argo-notifications') + .first() + .innerText() + .catch(async () => failureBanner.innerText().catch(() => '')) + ).trim(); + return detail || 'Unable to connect repository'; + } + return ''; + } + + async assertConnectionSuccessful(repoUrl: string) { + console.log('[private-repo] waiting for successful connection (max 90s)'); + const row = this.repoRow(repoUrl); + const deadline = Date.now() + 90000; + + try { + while (Date.now() < deadline) { + const err = await this.visibleConnectionError(); + if (err) { + throw new Error(this.redact(`argo failed to connect private repo: ${err}`)); + } + + if (await row.isVisible().catch(() => false)) { + await expect(row.getByText(/Successful/i)).toBeVisible({ timeout: 30000 }); + console.log('[private-repo] connection successful'); + return; + } + + if (await this.repoPresentInApi(repoUrl)) { + console.log('[private-repo] repo present via API; refreshing list'); + const refresh = this.page.getByRole('button', { name: /Refresh list/i }); + if (await refresh.isVisible().catch(() => false)) { + await refresh.click(); + } else { + await this.page.reload(); + } + await expect(this.page.getByText('Loading...', { exact: true })).toHaveCount(0, { + timeout: 60000, + }); + continue; + } + + //refresh if list stayed empty + const empty = this.page.getByText(/No repositories connected/i); + if (await empty.isVisible().catch(() => false)) { + const refresh = this.page.getByRole('button', { name: /Refresh list/i }); + if (await refresh.isVisible().catch(() => false)) { + await refresh.click(); + await expect(this.page.getByText('Loading...', { exact: true })).toHaveCount(0, { + timeout: 30000, + }); + } + } + + await new Promise((r) => setTimeout(r, 2000)); + } + + const err = await this.visibleConnectionError(); + const inApi = await this.repoPresentInApi(repoUrl); + throw new Error( + this.redact( + `private repo did not appear in Settings after Connect ` + + `(apiHasRepo=${inApi}${err ? `; uiError=${err}` : ''}).` + ) + ); + } finally { + await this.clearSecretsFromForm(); + } + } +} diff --git a/test/ui-e2e/src/utils/cluster-dns.ts b/test/ui-e2e/src/utils/cluster-dns.ts new file mode 100644 index 00000000000..30c53d73d69 --- /dev/null +++ b/test/ui-e2e/src/utils/cluster-dns.ts @@ -0,0 +1,35 @@ +import { execFileSync } from 'child_process'; + +/** Resolve hostname from the Argo CD repo-server (same DNS path as git ls-remote). */ +export function clusterCanResolveHostname(hostname: string): boolean { + if (!hostname) return false; + try { + const out = execFileSync( + 'oc', + [ + 'exec', + '-n', + 'openshift-gitops', + 'deploy/openshift-gitops-repo-server', + '-c', + 'argocd-repo-server', + '--', + 'getent', + 'hosts', + hostname, + ], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20000 } + ); + return out.trim().length > 0; + } catch { + return false; + } +} + +export function hostnameFromRepoUrl(repoUrl: string): string { + try { + return new URL(repoUrl).hostname; + } catch { + return ''; + } +} diff --git a/test/ui-e2e/src/utils/ha-manager.ts b/test/ui-e2e/src/utils/ha-manager.ts index 456d667b39a..9619046db70 100644 --- a/test/ui-e2e/src/utils/ha-manager.ts +++ b/test/ui-e2e/src/utils/ha-manager.ts @@ -1,37 +1,100 @@ import { execSync } from 'child_process'; -//private helper test file doesn't need to see it -function isClusterResourceConstrained(): boolean { - if (process.env.REDUCE_HA_RESOURCES === 'true') return true; +const MIN_WORKERS = 3; +const MIN_WORKER_MEM_KI = 12 * 1024 * 1024; // 12Gi + +type HaCapacityAssessment = { + constrained: boolean; + reasons: string[]; + workerCount: number; + workerMemSummary: string; +}; + +//Ki/Mi/Gi -> Ki +function memoryToKi(quantity: string): number { + const match = /^(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)?$/.exec(quantity.trim()); + if (!match) return 0; + const value = parseFloat(match[1]); + const unit = match[2] || 'Ki'; + switch (unit) { + case 'Ti': + return Math.round(value * 1024 * 1024 * 1024); + case 'Gi': + return Math.round(value * 1024 * 1024); + case 'Mi': + return Math.round(value * 1024); + case 'Ki': + default: + return Math.round(value); + } +} + +function assessHaCapacity(): HaCapacityAssessment { + const reasons: string[] = []; + let workerCount = 0; + let workerMemSummary = 'unknown'; + + if (process.env.REDUCE_HA_RESOURCES === 'true') { + reasons.push('REDUCE_HA_RESOURCES=true'); + } + try { const raw = execSync('oc get nodes -l node-role.kubernetes.io/worker -o json', { stdio: 'pipe' }).toString(); const workers = JSON.parse(raw).items || []; - if (workers.length < 3) return true; + workerCount = workers.length; + const memParts: string[] = []; + for (const node of workers) { - const memKi = parseInt(node.status?.allocatable?.memory || '0', 10); - if (memKi > 0 && memKi < 12 * 1024 * 1024) return true; + const name = node.metadata?.name || 'worker'; + const memKi = memoryToKi(node.status?.allocatable?.memory || '0'); + memParts.push(`${name}=${memKi > 0 ? `${Math.round(memKi / 1024 / 1024)}Gi` : '?'}`); + if (memKi > 0 && memKi < MIN_WORKER_MEM_KI) { + reasons.push(`${name} allocatable memory ${Math.round(memKi / 1024 / 1024)}Gi < 12Gi`); + } + } + workerMemSummary = memParts.join(', ') || 'none'; + + if (workerCount < MIN_WORKERS) { + reasons.push(`worker count ${workerCount} < ${MIN_WORKERS}`); } - } catch {} - return false; + } catch (e: any) { + //don't treat oc errors as low capacity + console.warn(`[setup] could not inspect workers (${e?.message || e}); skipping capacity reduction`); + } + + return { + constrained: reasons.length > 0, + reasons, + workerCount, + workerMemSummary, + }; } export async function enableHA() { console.log('\n[setup] Enabling High Availability (HA) for Argo CD...'); - - const reduceResources = isClusterResourceConstrained(); + + const capacity = assessHaCapacity(); + const reduceResources = capacity.constrained; const lowRes = { requests: { cpu: '50m', memory: '64Mi' }, limits: { cpu: '250m', memory: '128Mi' } }; const patchObj = reduceResources ? { spec: { ha: { enabled: true, resources: lowRes }, redis: { resources: lowRes } } } : { spec: { ha: { enabled: true } } }; if (reduceResources) { - console.log('[setup] Resource-constrained cluster detected. Applying reduced specs for HA patch...'); + console.warn( + [ + '[setup] WARNING: cluster may lack capacity for reliable HA (continuing anyway; HA is still required).', + ` workers=${capacity.workerCount} mem=[${capacity.workerMemSummary}]`, + ` reasons: ${capacity.reasons.join('; ')}`, + ' Applying reduced HA/redis resource specs. If redis-ha rollout times out, treat as an environment/capacity issue — not a UI login bug.', + ].join('\n') + ); } execSync(`oc patch argocd openshift-gitops -n openshift-gitops --type=merge -p '${JSON.stringify(patchObj)}'`, { stdio: 'inherit', timeout: 30000 }); console.log('[setup] Polling cluster for new HA deployment (this may take a few minutes)...'); - let retries = 30; + let retries = 30; let podsReady = false; while (retries > 0 && !podsReady) { @@ -45,12 +108,26 @@ export async function enableHA() { } } - if (!podsReady) throw new Error('HA proxy deployment never appeared or became available after polling.'); + if (!podsReady) { + const hint = reduceResources + ? ' ENV_CAPACITY_WARNING: cluster was flagged as constrained before enableHA; prefer investigating redis-ha/capacity over UI selectors.' + : ''; + throw new Error('HA proxy deployment never appeared or became available after polling.' + hint); + } console.log('[setup] Waiting for Operator to roll out HA-aware components...'); - execSync('oc rollout status statefulset/openshift-gitops-redis-ha-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); - execSync('oc rollout status deployment/openshift-gitops-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); - execSync('oc rollout status deployment/openshift-gitops-dex-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + try { + execSync('oc rollout status statefulset/openshift-gitops-redis-ha-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + execSync('oc rollout status deployment/openshift-gitops-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + execSync('oc rollout status deployment/openshift-gitops-dex-server -n openshift-gitops --timeout=300s', { stdio: 'inherit' }); + } catch (e: any) { + if (reduceResources) { + throw new Error( + `HA rollout failed after capacity warning (likely environment/capacity, not UI). Underlying: ${e?.message || e}` + ); + } + throw e; + } console.log('[setup] Rollouts complete. Giving cluster time to stabilize network routes...'); await new Promise(resolve => setTimeout(resolve, 10000)); @@ -59,7 +136,7 @@ export async function enableHA() { export async function disableHA() { console.log('\n[teardown] Disabling High Availability (HA) to restore cluster state...'); - + //setting resources to get rid of any overrides from the setup const disablePatch = { spec: { ha: { enabled: false, resources: null }, redis: { resources: null } } }; execSync(`oc patch argocd openshift-gitops -n openshift-gitops --type=merge -p '${JSON.stringify(disablePatch)}'`, { stdio: 'inherit', timeout: 30000 }); @@ -84,4 +161,4 @@ export async function disableHA() { await new Promise(resolve => setTimeout(resolve, 5000)); console.log('[teardown] Cluster successfully restored to non-HA state.'); -} \ No newline at end of file +} diff --git a/test/ui-e2e/tests/app-deletion.spec.ts b/test/ui-e2e/tests/app-deletion.spec.ts new file mode 100644 index 00000000000..ee953ed9302 --- /dev/null +++ b/test/ui-e2e/tests/app-deletion.spec.ts @@ -0,0 +1,258 @@ +import { test, expect } from '../src/fixtures'; +import { execFileSync } from 'child_process'; + +test.describe('Clean Application Deletion (Pruning)', () => { + const stamp = Date.now(); + const appName = `ui-deletion-${stamp}`; + const destNs = `ui-deletion-ns-${stamp}`; + //pin revision to immutable commit SHA for reproducibility + const targetCommit = '8088f4c0d970abb09e250248cc97e35623447cb5'; + + //returns true when the application cr is still present + const applicationExists = (): boolean => { + const out = execFileSync( + 'oc', + ['get', 'application', appName, '-n', 'openshift-gitops', '--ignore-not-found', '-o', 'name'], + { stdio: 'pipe', timeout: 5000 } + ).toString().trim(); + return out.length > 0; + }; + + //guestbook always creates deploy/svc named guestbook-ui. use --ignore-not-found -o name + //(empty = gone; real oc errors still throw). avoids instance-label lookups that fail under annotation tracking. + const remainingChildResources = (): string => { + const kinds = ['deploy', 'svc'] as const; + return kinds + .map((kind) => + execFileSync( + 'oc', + ['get', kind, 'guestbook-ui', '-n', destNs, '--ignore-not-found', '-o', 'name'], + { stdio: 'pipe', timeout: 5000 } + ).toString().trim() + ) + .filter(Boolean) + .join('\n'); + }; + + test.beforeAll(async ({}, testInfo) => { + //RBAC wait + sync can exceed 3m + testInfo.setTimeout(240000); + console.log(`\n[setup] Deploying dummy application '${appName}' via CLI...`); + + 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} + syncPolicy: + automated: + prune: true + selfHeal: true +`; + try { + execFileSync('oc', ['create', 'namespace', destNs], { stdio: 'pipe', timeout: 15000 }); + //needed for controller write access + 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 = execFileSync( + 'oc', + ['get', 'rolebinding', '-n', destNs, '-o', 'name'], + { stdio: 'pipe', timeout: 5000 } + ).toString(); + if (/argocd-application-controller/i.test(rbs)) { + rbacReady = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + if (!rbacReady) { + throw new Error( + `Namespace '${destNs}' never received openshift-gitops application-controller RoleBinding ` + + `(label argocd.argoproj.io/managed-by=openshift-gitops).` + ); + } + + //clear leftover guestbook children that can leave a new app stuck Unknown/OutOfSync + for (const kind of ['deploy', 'svc'] as const) { + execFileSync( + 'oc', + ['delete', kind, 'guestbook-ui', '-n', destNs, '--ignore-not-found', '--wait=false'], + { stdio: 'pipe', timeout: 15000 } + ); + } + + //deploy dummy app via cli with process timeout + execFileSync('oc', ['apply', '-f', '-'], { input: appYaml, stdio: 'pipe', timeout: 15000 }); + + //poll until argo cd reports synced (unknown is common while repo-server warms up) + let isSynced = false; + let lastSync = ''; + let lastHealth = ''; + let lastMessage = ''; + let lastOpMessage = ''; + for (let i = 1; i <= 30; i++) { + try { + lastSync = execFileSync( + 'oc', + ['get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.sync.status}'], + { stdio: 'pipe', timeout: 3000 } + ).toString().trim(); + lastHealth = execFileSync( + 'oc', + ['get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.health.status}'], + { stdio: 'pipe', timeout: 3000 } + ).toString().trim(); + lastMessage = execFileSync( + 'oc', + ['get', 'application', appName, '-n', 'openshift-gitops', '-o', 'jsonpath={.status.conditions[0].message}'], + { stdio: 'pipe', timeout: 3000 } + ).toString().trim(); + lastOpMessage = execFileSync( + 'oc', + [ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.status.operationState.message}', + ], + { stdio: 'pipe', timeout: 3000 } + ).toString().trim(); + console.log( + `[setup] Checking sync status (Attempt ${i}/30): sync='${lastSync || 'Initializing...'}' health='${lastHealth || '-'}'` + ); + if (lastSync === 'Synced') { + isSynced = true; + break; + } + } catch (e) { + console.log(`[setup] Checking sync status (Attempt ${i}/30): Waiting for resource...`); + } + await new Promise(resolve => setTimeout(resolve, 3000)); + } + + if (!isSynced) { + throw new Error( + `Dummy application '${appName}' never reached Synced status ` + + `(last sync='${lastSync || '-'}' health='${lastHealth || '-'}' ` + + `condition='${lastMessage || '-'}' operation='${lastOpMessage || '-'}').` + ); + } + } catch (e) { + console.error('Failed to pre-deploy dummy app', e); + throw e; + } + }); + + test.afterAll(async ({}, testInfo) => { + testInfo.setTimeout(60000); + console.log(`\n[teardown] Ensuring '${appName}' and '${destNs}' are cleaned up...`); + + try { + execFileSync( + 'oc', + ['delete', 'application', appName, '-n', 'openshift-gitops', '--ignore-not-found', '--wait=true'], + { stdio: 'pipe', timeout: 15000 } + ); + } catch (e) { + console.warn(`[teardown] Initial cleanup command failed: ${(e as Error).message}`); + } + + execFileSync( + 'oc', + ['delete', 'namespace', destNs, '--ignore-not-found', '--wait=false'], + { stdio: 'pipe', timeout: 15000 } + ); + + let isDeleted = false; + for (let i = 1; i <= 5; i++) { + if (!applicationExists()) { + isDeleted = true; + break; + } + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + if (!isDeleted) { + throw new Error(`[teardown] Cleanup verification failed: '${appName}' still exists on cluster.`); + } + }); + + test('Delete application via UI and verify cascading deletion', async ({ page }) => { + //covers ui waits plus backend/child prune polling budgets + test.setTimeout(180000); + + //locate application card specifically bound to appName without broad div scanning + const appTile = page.locator('.application-tile, [class*="application-tile"], [class*="applications-list__entry"]') + .filter({ hasText: appName }); + + //ensure application tile appears on dashboard + await expect(appTile).toBeVisible({ timeout: 30000 }); + + //confirm guestbook children exist before delete so cascade assertion is meaningful + expect(remainingChildResources()).not.toBe(''); + + //click delete button scoped specifically to this app card + const deleteBtn = appTile.locator('[qe-id="applications-tiles-button-delete"]'); + await deleteBtn.click(); + + //locate modal container via dialog role or confirmation prompt text + const modal = page.getByRole('dialog') + .or(page.locator('div').filter({ hasText: /to confirm the deletion/i })) + .first(); + await expect(modal).toBeVisible({ timeout: 15000 }); + + //type application name into confirmation field + const confirmInput = modal.getByRole('textbox').or(modal.locator('input')).first(); + await confirmInput.fill(appName); + + //confirm deletion + const okBtn = modal.getByRole('button', { name: /^ok$/i }).or(modal.locator('button').filter({ hasText: /^ok$/i })).first(); + await okBtn.click(); + + //assert modal closes after confirming + await expect(modal).toBeHidden({ timeout: 15000 }); + + //assert app tile disappears from ui dashboard + await expect(appTile).toBeHidden({ timeout: 30000 }); + + //verify backend cr deletion via cli directly within test block before teardown + let backendDeleted = false; + for (let i = 1; i <= 10; i++) { + if (!applicationExists()) { + backendDeleted = true; + break; + } + //resource still present on cluster, wait before checking again + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + expect(backendDeleted).toBe(true); + + //verify cascading deletion removed guestbook deploy/svc + let childrenGone = false; + for (let i = 1; i <= 10; i++) { + const remaining = remainingChildResources(); + if (remaining === '') { + childrenGone = true; + break; + } + console.log(`[verify] Waiting for child resources to prune (Attempt ${i}/10): ${remaining}`); + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + expect(childrenGone).toBe(true); + }); +}); diff --git a/test/ui-e2e/tests/auto-sync-self-heal.spec.ts b/test/ui-e2e/tests/auto-sync-self-heal.spec.ts new file mode 100644 index 00000000000..b36fe6f926d --- /dev/null +++ b/test/ui-e2e/tests/auto-sync-self-heal.spec.ts @@ -0,0 +1,133 @@ +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('Auto-Sync and Self-Healing', () => { + const stamp = Date.now(); + const appName = `ui-autosync-${stamp}`; + const destNs = `ui-autosync-ns-${stamp}`; + 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; + }; + + const deleteGuestbookChildren = () => { + for (const kind of ['deploy', 'svc'] as const) { + execFileSync( + 'oc', + ['delete', kind, 'guestbook-ui', '-n', destNs, '--ignore-not-found', '--wait=false'], + { stdio: 'pipe', timeout: 15000 } + ); + } + }; + + test.beforeAll(async ({}, testInfo) => { + testInfo.setTimeout(120000); + console.log(`\n[setup] Deploying '${appName}' via CLI (manual sync policy)...`); + + 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 } + ); + deleteGuestbookChildren(); + + //manual sync — UI enables automated policy + 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 }); + + for (let i = 1; i <= 15; i++) { + if (applicationExists()) break; + if (i === 15) throw new Error(`Application '${appName}' never appeared`); + await new Promise((r) => setTimeout(r, 2000)); + } + }); + + 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}`); + } + + deleteGuestbookChildren(); + 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('Enable Auto-Sync, Prune, and Self Heal from App Details', async ({ page }) => { + test.setTimeout(120000); + + const appsPage = new ApplicationsPage(page); + const detailsPage = new ApplicationDetailsPage(page); + + await appsPage.navigate(); + await appsPage.openApplication(appName); + await detailsPage.openAppDetailsPanel(); + await detailsPage.enableAutoSyncWithPruneAndSelfHeal(appName); + await detailsPage.assertAutoSyncPruneSelfHealEnabled(); + + await expect + .poll( + () => { + const prune = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.spec.syncPolicy.automated.prune}' + ]); + const selfHeal = ocGet([ + 'get', 'application', appName, '-n', 'openshift-gitops', + '-o', 'jsonpath={.spec.syncPolicy.automated.selfHeal}' + ]); + return `${prune},${selfHeal}`; + }, + { timeout: 30000, message: 'waiting for prune+selfHeal on Application CR' } + ) + .toBe('true,true'); + }); +}); diff --git a/test/ui-e2e/tests/private-repo.spec.ts b/test/ui-e2e/tests/private-repo.spec.ts new file mode 100644 index 00000000000..d7143104deb --- /dev/null +++ b/test/ui-e2e/tests/private-repo.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '../src/fixtures'; +import { SettingsRepositoriesPage } from '../src/pages/SettingsRepositoriesPage'; +import { clusterCanResolveHostname, hostnameFromRepoUrl } from '../src/utils/cluster-dns'; + +test.describe('Private Git Repository Connection', () => { + const repoUrl = process.env.PRIVATE_REPO_URL || ''; + const username = process.env.PRIVATE_REPO_USERNAME || 'x-access-token'; + const password = process.env.PRIVATE_REPO_PASSWORD || process.env.PRIVATE_REPO_TOKEN || ''; + + test.beforeEach(() => { + test.skip(!repoUrl || !password, 'requires PRIVATE_REPO_URL and PRIVATE_REPO_PASSWORD (or PRIVATE_REPO_TOKEN)'); + + const host = hostnameFromRepoUrl(repoUrl); + if (!clusterCanResolveHostname(host)) { + //skip is expected on clusters without corp/private DNS + const reason = + `skipped: this cluster has no DNS for ${host}, so the private-repo test is not run here`; + console.log(`[private-repo] ${reason}`); + test.skip(true, reason); + } + }); + + test.afterEach(async ({ page }, testInfo) => { + if (!repoUrl || !password || testInfo.status === 'skipped') return; + console.log('[teardown] removing configured private repository'); + const reposPage = new SettingsRepositoriesPage(page); + await reposPage.ensureRepoRemoved(repoUrl); + }); + + test('Connect a private HTTPS repository via Settings', async ({ page }) => { + test.setTimeout(180000); + + const reposPage = new SettingsRepositoriesPage(page); + await reposPage.navigate(); + await reposPage.connectHttpsRepo(repoUrl, username, password); + await reposPage.assertConnectionSuccessful(repoUrl); + }); +}); diff --git a/test/ui-e2e/tests/resource-tree.spec.ts b/test/ui-e2e/tests/resource-tree.spec.ts index 4123e396f0b..92c2574abe7 100644 --- a/test/ui-e2e/tests/resource-tree.spec.ts +++ b/test/ui-e2e/tests/resource-tree.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../src/fixtures'; +import { test } from '../src/fixtures'; import { ApplicationDetailsPage } from '../src/pages/ApplicationDetailsPage'; import { ApplicationsPage } from '../src/pages/ApplicationsPage'; @@ -7,23 +7,17 @@ test.describe('Argo CD Resource Tree and Pod Logs', () => { test.use({ storageState: '.auth/storageState.json' }); test('Navigate to app details, open a Pod, and verify logs stream', async ({ page, managedApp }) => { - test.setTimeout(120000); + test.setTimeout(120000); const appsPage = new ApplicationsPage(page); const detailsPage = new ApplicationDetailsPage(page); - + await appsPage.navigate(); - await page.getByPlaceholder(/Search applications/i).fill(managedApp); - - //click the Application Name text/link - const appCard = page.locator('.white-box, .argo-table-list__row').filter({ hasText: managedApp }); - await appCard.getByText(managedApp, { exact: true }).first().click(); + await appsPage.openApplication(managedApp); - //on details page await detailsPage.verifyResourceTreeLoaded(); - //Deployment node await detailsPage.clickResourceNode('deploy', 'spring-petclinic'); await detailsPage.verifyPodLogs(); }); -}); \ No newline at end of file +});