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
45 changes: 32 additions & 13 deletions test/ui-e2e/.auth/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { test as setup, expect } from '@playwright/test';

const authFile = '.auth/storageState.json';

//centralized timeouts to appease the linter
const TIMEOUTS = { short: 5000, medium: 10000, default: 15000, long: 20000 };

setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => {
// Navigate to the OpenShift Console
//navigate to the OpenShift Console
const targetUrl = baseURL || process.env.CONSOLE_URL || process.env.BASE_URL;

if (!targetUrl) {
Expand All @@ -13,35 +16,35 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => {
console.log(`Navigating to OpenShift Console: ${targetUrl}`);
await page.goto(targetUrl);

// Set locators
//set locators
const idpScreenText = page.getByText(/Log in with/i);
const usernameInput = page.getByLabel(/Username/i)
.or(page.locator('input[name="username"]'))
.or(page.getByPlaceholder(/Username/i));

// Fail loudly if the page is dead so we don't get weird errors later
//fail loudly if the page is dead so we don't get weird errors later
await expect(
idpScreenText.or(usernameInput).first(),
"OpenShift login page failed to load. Check cluster health and URL."
).toBeVisible({ timeout: 20000 });
).toBeVisible({ timeout: TIMEOUTS.long });

const idpName = process.env.IDP || 'kube:admin';
const user = process.env.CLUSTER_USER || 'kubeadmin';

if (await idpScreenText.isVisible()) {
console.log(`IDP selection screen detected. Selecting provider: "${idpName}"`);

// Look for the specific IDP
const idpLink = page.getByRole('link', { name: new RegExp(idpName, 'i') });
//look for the specific IDP
const idpLink = page.getByRole('link', { name: idpName, exact: true });

await idpLink.waitFor({ state: 'visible', timeout: 5000 });
await idpLink.waitFor({ state: 'visible', timeout: TIMEOUTS.short });
await idpLink.click();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
console.log("No IDP screen detected (or already selected), proceeding to credentials...");
}

// Fill in the credentials
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
//fill in the credentials
await usernameInput.waitFor({ state: 'visible', timeout: TIMEOUTS.medium });
await usernameInput.fill(user);

const passwordInput = page.getByLabel(/Password/i)
Expand All @@ -55,9 +58,25 @@ setup('authenticate to OpenShift Cluster', async ({ page, baseURL }) => {
await passwordInput.fill(process.env.CLUSTER_PASSWORD);
await page.getByRole('button', { name: /Log in/i }).click();

// Save the auth state
await expect(page.getByRole('navigation').first()).toBeVisible({ timeout: 15000 });
await expect(page).toHaveURL(/(console|k8s|overview|dashboards)/i, { timeout: 15000 });
await page.context().storageState({ path: authFile });
//handle the OpenShift 4.x Welcome Tour modal if it appears
try {
const skipTourButton = page.getByRole('button', { name: /skip tour/i });
//wait up to 5 seconds for the modal to pop up
await skipTourButton.waitFor({ state: 'visible', timeout: TIMEOUTS.short });
await skipTourButton.click();
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 error;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

//save the auth state
await expect(page.getByRole('navigation').first()).toBeVisible({ timeout: TIMEOUTS.long });
await expect(page).toHaveURL(/(console|k8s|overview|dashboards)/i, { timeout: TIMEOUTS.default });
await page.context().storageState({ path: authFile });
});
13 changes: 8 additions & 5 deletions test/ui-e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ All executions are driven via the ./run-ui-tests.sh wrapper script. This wrapper

| Target | Command |
| --- | --- |
| **Run All Tests (Headless/CI Mode)** | `./run-ui-tests.sh --project=chromium` |
| **Run All Tests (Headed + Visual Tracing)** | `./run-ui-tests.sh --project=chromium --headed --trace on` |
| **Run a Specific Spec File** | `./run-ui-tests.sh tests/create-application.spec.ts --project=chromium --headed --trace on` |
| **Run All Tests (Local Headless)** | `./run-ui-tests.sh --project=chromium` |
| **Run All Tests (Local Headed + Trace)** | `./run-ui-tests.sh --project=chromium --headed --trace on` |
| **Run All Tests (Simulate CI)** | `./run-ui-tests.sh --env=ci --project=chromium` |
| **Run a Specific Spec File** | `./run-ui-tests.sh tests/resource-tree.spec.ts --project=chromium --headed` |

### Playwright Flags Reference

Expand All @@ -67,6 +68,7 @@ All executions are driven via the ./run-ui-tests.sh wrapper script. This wrapper
| `--headed` | Launches the visible Chromium browser UI. Excellent for local debugging. |
| `--trace on` | Records a granular execution trace (DOM snapshots, network calls, actions) for visual triage. |
| `--reporter=list` | Switches stdout to a clean line-by-line format, ideal for monitoring real-time execution steps. |
| --env=<ci\|pipeline> | Overrides the local setup to simulate automation. It forces headless execution, performs a clean `npm ci`, and installs required browser binaries dynamically. |

### Visual Debugging (Trace Viewer)

Expand All @@ -91,8 +93,9 @@ npx playwright show-trace test-results/create-application-chromium/trace.zip
│ └── pages/ # Page Object Models (POM) isolating UI selectors from spec logic
│ └── ApplicationsPage.ts
├── tests/ # Test specs organized by feature epic
│ ├── login.spec.ts
│ └── create-application.spec.ts
│ ├── admin-login.spec.ts
│ ├── create-application.spec.ts
│ └── resource-tree.spec.ts
├── .env # Local runtime environment overrides (Git ignored)
└── run-ui-tests.sh # Context-aware orchestrator & URL discovery engine

Expand Down
22 changes: 22 additions & 0 deletions test/ui-e2e/global.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { execSync } from 'child_process';

async function globalSetup() {
console.log(' * Running pre-flight cleanup...');

try {
console.log(' -> Sweeping ghost applications...');
//no hangs on dead controllers
execSync('oc delete applications.argoproj.io --all -n openshift-gitops --wait=false', { stdio: 'ignore' });

console.log(' -> Sweeping orphaned Spring Petclinic resources...');
//no hangs on dead controllers
execSync('oc delete all -l app=spring-petclinic -n openshift-gitops --wait=false', { stdio: 'ignore' });

console.log('* Cluster sanitized. Starting test suite.');
} catch (error) {
console.error('Pre-flight cleanup failed. Check your cluster connection.', error);
throw error;
}
}

export default globalSetup;
37 changes: 17 additions & 20 deletions test/ui-e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,40 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';

/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/

// top of playwright.config.ts
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({ path: path.resolve(__dirname, '.env') });

/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
//register pre-flight script
globalSetup: require.resolve('./global.setup.ts'),
//global test timeout to 5 min
timeout: 5 * 60 * 1000,

testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Turn off parallel execution inside files */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,

//stops parallel execution so they don't fight over the openshift-gitops namespace.
workers: 1,

/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
['list'],
['html', { open: process.env.CI ? 'never' : 'on-failure' }]
],

/* GLOBAL FOUNDATION: These apply to everything */
/* GLOBAL FOUNDATION: These apply to everything */
use: {
baseURL: process.env.ARGOCD_URL,
ignoreHTTPSErrors: true,
Expand All @@ -44,7 +49,8 @@ export default defineConfig({
testMatch: '**/.auth/setup.ts',
/* Only changes the URL for this specific project */
use: {
baseURL: process.env.CONSOLE_URL, },
baseURL: process.env.CONSOLE_URL,
},
},

// Update chromium project
Expand All @@ -62,16 +68,7 @@ export default defineConfig({
name: 'firefox',
use: {
...devices['Desktop Firefox'],
// storageState and dependencies here later if we want to run Firefox tests but for now just focus on Chromium
},
},
// ... webkit etc ...
],

/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});
});
52 changes: 44 additions & 8 deletions test/ui-e2e/run-ui-tests.sh
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
#!/bin/bash

# use arguments to extract --env and keep the rest for Playwright
ENV="local"
TEST_ARGS=()

while [[ "$#" -gt 0 ]]; do
case $1 in
--env=*) ENV="${1#*=}" ;;
*) TEST_ARGS+=("$1") ;; # Save all other args (files, --headed, etc.)
esac
shift
done

#making sure we are in the correct dir
cd "$(dirname "$0")" || exit 1

if [ -f .env ]; then
echo "Loading variables from .env file..."
set -a #export all variables
source .env
set +a # stop automatically exporting
set +a #stop auto export
fi

#making sure we are in the correct dir
cd "$(dirname "$0")" || exit 1

# username (might be something different for rosa - can be overwritten with export CLUSTER_USER)
#username (might be something different for rosa - can be overwritten with export CLUSTER_USER)
export CLUSTER_USER=${CLUSTER_USER:-"kubeadmin"}
export IDP=${IDP:-"kube:admin"}

Expand All @@ -26,11 +38,11 @@ if [ -n "$OC_API_URL" ] && [ -n "$CLUSTER_PASSWORD" ]; then
exit 1
fi
elif ! oc whoami > /dev/null 2>&1; then
# If variables don't exist AND we aren't logged in, fail out
#if variables don't exist AND we aren't logged in fail out
echo "Error: Not logged in. Missing OC_API_URL or CLUSTER_PASSWORD."
exit 1
else
# If variables don't exist but we ARE logged in locally, just use the current session
#if variables don't exist but we ARE logged in locally just use the current session
echo "No .env credentials found. Using existing oc CLI session..."
fi

Expand All @@ -53,4 +65,28 @@ rm -f .auth/storageState.json || true

#run Playwright
echo " Starting Playwright tests..."
npx playwright test "$@"

# 2. Execute based on the environment
if [ "$ENV" = "ci" ] || [ "$ENV" = "pipeline" ]; then
echo "Running headlessly in automation ($ENV)..."
npm ci
if [ "$(uname -s)" = "Darwin" ]; then
npx playwright install chromium
else
npx playwright install chromium --with-deps
fi

#headed from args
FILTERED_ARGS=()
for arg in "${TEST_ARGS[@]}"; do
if [[ "$arg" != "--headed" ]]; then
FILTERED_ARGS+=("$arg")
fi
done

npx playwright test "${FILTERED_ARGS[@]}" --reporter=list

else
echo "Running Locally..."
npx playwright test "${TEST_ARGS[@]}"
fi
39 changes: 33 additions & 6 deletions test/ui-e2e/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export const test = base.extend<MyFixtures>({
await use(page);
},

//app setup/teardown
managedApp: [ async ({ page }, use) => {
const appName = `e2e-app-${Date.now()}`;
const appsPage = new ApplicationsPage(page);
Expand All @@ -50,17 +49,45 @@ export const test = base.extend<MyFixtures>({

//teardown
console.log(`[teardown] deleting ${appName} via api`);
const response = await page.request.delete(`/api/v1/applications/${appName}?cascade=true`, {

//page.request
const deleteResponse = await page.request.delete(`/api/v1/applications/${appName}?cascade=true`, {
headers: { 'Content-Type': 'application/json' }
});

// 4. Update the teardown to only ignore 404s, treating 403s as failures
if (response.status() === 404) {
// If it's already 404 (or 403), we have nothing left to do
if (deleteResponse.status() === 404 || deleteResponse.status() === 403) {
console.log(`[teardown] ${appName} was already deleted.`);
return;
} else {
expect(response.status()).toBeLessThan(400);
// Ensure the delete request itself was accepted (200/202)
expect(deleteResponse.status()).toBeLessThan(400);

console.log(`[teardown] waiting for background cleanup of ${appName} to finish...`);
await expect.poll(async () => {
try {
const checkResponse = await page.request.get(`/api/v1/applications/${appName}`);
const status = checkResponse.status();

//404 (Not Found) or 403 (Forbidden due to RBAC project scoping)
return status === 404 || status === 403;
} catch (error) {
//router blips or drops the socket swallow it and keep polling
if (error instanceof Error && (error.message.includes('hang up') || error.message.includes('RESET') || error.message.includes('closed'))) {
return false;
}
//fail fast
throw error;
}
}, {
message: `Waiting for ${appName} to completely delete from the cluster.`,
timeout: 60000,
intervals: [2000, 5000, 10000],
}).toBeTruthy();

console.log(`[teardown] ${appName} successfully removed from the cluster.`);
}
}, { timeout: 120000 } ],
}, { timeout: 300000 } ],
});

//export it so spec files can use it
Expand Down
Loading
Loading