test(ui): refactoring tests

Correct serial execution
Best process for deleting a provider
New scans page to manage properly when creating a provider
This commit is contained in:
StylusFrost
2025-10-21 13:10:43 +02:00
parent ccd8908128
commit 642efecd37
5 changed files with 240 additions and 127 deletions
+8 -3
View File
@@ -1,6 +1,9 @@
name: UI - E2E Tests
on:
push:
branches:
- "PROWLER-182-kubernetes-add-and-connect-the-provider"
pull_request:
branches:
- master
@@ -10,6 +13,7 @@ on:
- 'ui/**'
jobs:
e2e-tests:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
@@ -33,9 +37,10 @@ jobs:
E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }}
E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }}
E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }}
E2E_KUBERNETES_CONTEXT: ${{ secrets.E2E_KUBERNETES_CONTEXT }}
E2E_KUBERNETES_KUBECONFIG_PATH: ${{ secrets.E2E_KUBERNETES_KUBECONFIG_PATH }}
E2E_KUBERNETES_CONTEXT: 'kind-kind'
E2E_KUBERNETES_KUBECONFIG_PATH: '/home/runner/.kube/config'
E2E_NEW_PASSWORD: ${{ secrets.E2E_NEW_PASSWORD }}
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@@ -115,4 +120,4 @@ jobs:
run: |
echo "Shutting down services..."
docker compose down -v || true
echo "Cleanup completed"
echo "Cleanup completed"
+3 -8
View File
@@ -147,7 +147,9 @@ export async function authenticateAndSaveState(
storagePath: string,
) {
if (!email || !password) {
throw new Error('Email and password are required for authentication and save state');
throw new Error(
"Email and password are required for authentication and save state",
);
}
// Create SignInPage instance
@@ -188,10 +190,3 @@ export async function verifySessionValid(page: Page) {
expect(session.refreshToken).toBeTruthy();
return session;
}
export async function deleteProviderIfExists(page: Page, accountId: string) {
const providersPage = new ProvidersPage(page);
if (await providersPage.verifyProviderExists(accountId)) {
await providersPage.actionDeleteProvider(accountId);
}
}
+149 -87
View File
@@ -239,10 +239,11 @@ export class ProvidersPage extends BasePage {
this.accessKeyIdInput = page.getByRole("textbox", { name: "Access Key ID" });
this.secretAccessKeyInput = page.getByRole("textbox", { name: "Secret Access Key" });
// Delete button
this.deleteProviderConfirmationButton = page.locator(
'button[aria-label="Delete"]',
);
// Delete button in confirmation modal
this.deleteProviderConfirmationButton = page.getByRole("button", {
name: "Delete",
exact: true,
});
}
async goto(): Promise<void> {
@@ -365,6 +366,43 @@ export class ProvidersPage extends BasePage {
await buttonByText.click();
await this.waitForPageLoad();
// Wait for either success (redirect to scans) or error message to appear
// The error container has multiple p.text-danger elements, we want the first one with the technical error
const errorMessage = this.page.locator("p.text-danger").first();
try {
// Wait up to 15 seconds for either the error message or redirect
await Promise.race([
// Wait for error message to appear
errorMessage.waitFor({ state: "visible", timeout: 15000 }),
// Wait for redirect to scans page (success case)
this.page.waitForURL(/\/scans/, { timeout: 15000 }),
]);
// If we're still on test-connection page, check for error
if (/\/providers\/test-connection/.test(this.page.url())) {
const isErrorVisible = await errorMessage.isVisible().catch(() => false);
if (isErrorVisible) {
const errorText = await errorMessage.textContent();
throw new Error(
`Test connection failed with error: ${errorText?.trim() || "Unknown error"}`,
);
}
}
} catch (error) {
// If timeout or other error, check if error message is present
const isErrorVisible = await errorMessage.isVisible().catch(() => false);
if (isErrorVisible) {
const errorText = await errorMessage.textContent();
throw new Error(
`Test connection failed with error: ${errorText?.trim() || "Unknown error"}`,
);
}
// Re-throw original error if no error message found
throw error;
}
return;
}
@@ -534,8 +572,18 @@ export class ProvidersPage extends BasePage {
// Verify the M365 credentials page is loaded
await expect(this.page).toHaveTitle(/Prowler/);
await expect(this.m365StaticCredentialsRadio).toBeVisible();
await expect(this.m365CertificateCredentialsRadio).toBeVisible();
await expect(this.m365ClientIdInput).toBeVisible();
await expect(this.m365ClientSecretInput).toBeVisible();
await expect(this.m365TenantIdInput).toBeVisible();
}
async verifyM365CertificateCredentialsPageLoaded(): Promise<void> {
// Verify the M365 certificate credentials page is loaded
await expect(this.page).toHaveTitle(/Prowler/);
await expect(this.m365ClientIdInput).toBeVisible();
await expect(this.m365TenantIdInput).toBeVisible();
await expect(this.m365CertificateContentInput).toBeVisible();
}
async verifyKubernetesCredentialsPageLoaded(): Promise<void> {
@@ -559,20 +607,10 @@ export class ProvidersPage extends BasePage {
await expect(launchScanButton).toBeVisible();
}
async verifyProviderExists(providerUID: string): Promise<boolean> {
// Filter search by providerUID
await this.filterProvidersByProviderUID(providerUID);
// Verify if table has 1 row
if (!(await this.verifySingleRowForProviderUID(providerUID))) {
return false;
}
return true;
}
async verifyLoadProviderPageAfterNewProvider(): Promise<void> {
// Verify the provider page is loaded
await this.waitForPageLoad();
await expect(this.page).toHaveTitle(/Prowler/);
await expect(this.providersTable).toBeVisible();
}
@@ -593,95 +631,119 @@ export class ProvidersPage extends BasePage {
return true;
}
async filterProvidersByProviderUID(providerUID: string): Promise<void> {
// Filter providers by providerUID
async deleteProviderIfExists(providerUID: string): Promise<void> {
// Delete the provider if it exists
// Go to the providers page
// Navigate to providers page
await this.goto();
await expect(this.providersTable).toBeVisible({ timeout: 10000 });
// Verify the providers table is visible
await expect(this.providersTable).toBeVisible();
// Get the search input
// Find and use the search input to filter the table
const searchInput = this.page.getByPlaceholder(/search|filter/i);
if (!searchInput) {
throw new Error("No search input available");
}
// Clear existing content and type the providerUID
await searchInput.fill("");
await searchInput.type(providerUID, { delay: 20 });
// Try to submit the filter if the input acts on Enter
try {
await searchInput.press("Enter");
} catch (error) {
// Enter press might not be needed for all UIs
}
// Wait for table to update
await expect(searchInput).toBeVisible({ timeout: 5000 });
// Clear and search for the specific provider
await searchInput.clear();
await searchInput.fill(providerUID);
await searchInput.press("Enter");
// Wait for the table to finish loading/filtering
await this.waitForPageLoad();
}
// Additional wait for React table to re-render with the server-filtered data
// The filtering happens on the server, but the table component needs time
// to process the response and update the DOM after network idle
await this.page.waitForTimeout(1500);
async actionDeleteProvider(providerUID: string): Promise<void> {
// Delete the provider
// Get all rows from the table
const allRows = this.providersTable.locator("tbody tr");
// Helper function to check if a row is the "No results" row
const isNoResultsRow = async (row: Locator): Promise<boolean> => {
const text = await row.textContent();
return text?.includes("No results") || text?.includes("No data") || false;
};
// Filter search by providerUID
await this.filterProvidersByProviderUID(providerUID);
// Helper function to find the row with the specific UID
const findProviderRow = async (): Promise<Locator | null> => {
const count = await allRows.count();
for (let i = 0; i < count; i++) {
const row = allRows.nth(i);
// Skip "No results" rows
if (await isNoResultsRow(row)) {
continue;
}
// Check if this row contains the UID in the UID column (column 3)
const uidCell = row.locator("td").nth(3);
const uidText = await uidCell.textContent();
if (uidText?.includes(providerUID)) {
return row;
}
}
return null;
};
// Verify the providers table is visible
await expect(this.providersTable).toBeVisible();
// Wait for filtering to complete (max 0 or 1 data rows)
await expect(async () => {
const targetRow = await findProviderRow();
const count = await allRows.count();
// Count only real data rows (not "No results")
let dataRowCount = 0;
for (let i = 0; i < count; i++) {
if (!(await isNoResultsRow(allRows.nth(i)))) {
dataRowCount++;
}
}
// Should have 0 or 1 data row
expect(dataRowCount).toBeLessThanOrEqual(1);
}).toPass({ timeout: 20000 });
// Find the first row in the filtered results
const firstRow = this.providersTable.locator("tbody tr").first();
// Find the provider row
const targetRow = await findProviderRow();
if (!targetRow) {
// Provider not found, nothing to delete
// Navigate back to providers page to ensure clean state
await this.goto();
await expect(this.providersTable).toBeVisible({ timeout: 10000 });
return;
}
// Verify the first row is visible
await expect(firstRow).toBeVisible();
// Get button in the last cell
const lastCell = firstRow.locator("td").last();
const actionButton = lastCell.locator("button").first();
// Verify the action button is visible
// Find and click the action button (last cell = actions column)
const actionButton = targetRow.locator("td").last().locator("button").first();
await expect(actionButton).toBeVisible({ timeout: 5000 });
await actionButton.click();
// Wait for the dropdown menu to appear
await this.page.waitForSelector('[role="menu"], [role="menuitem"]', {
timeout: 5000,
});
// Find the delete menu item
// Wait for dropdown menu to appear and find delete option
const deleteMenuItem = this.page.getByRole("menuitem", {
name: /delete.*provider/i,
});
await expect(deleteMenuItem).toBeVisible({ timeout: 5000 });
await deleteMenuItem.click();
// Verify the delete menu item is visible
await expect(deleteMenuItem).toBeVisible();
// Wait for confirmation modal to appear
const modal = this.page.locator('[role="dialog"], .modal, [data-testid*="modal"]').first();
await expect(modal).toBeVisible({ timeout: 10000 });
// Click the delete menu item with force to handle unstable elements
await deleteMenuItem.click({ force: true });
// Find and click the delete confirmation button
await expect(this.deleteProviderConfirmationButton).toBeVisible({ timeout: 5000 });
await this.deleteProviderConfirmationButton.click();
// Wait for the delete confirmation modal to appear
await this.page.waitForSelector(
'[role="dialog"], .modal, [data-testid*="modal"]',
{ timeout: 10000 },
);
// Wait for modal to close (this indicates deletion was initiated)
await expect(modal).not.toBeVisible({ timeout: 10000 });
// Find the delete confirmation button with multiple approaches
let deleteButton = this.deleteProviderConfirmationButton;
await expect(deleteButton).toBeVisible();
// Click the delete button with force to handle unstable elements
await deleteButton.click({ force: true });
// Wait for the modal to disappear and the page to update
const modalToCheck = this.page
.locator('[role="dialog"], .modal, [data-testid*="modal"]')
.first();
// Verify the modal is not visible
await expect(modalToCheck).not.toBeVisible();
// Wait for page to reload
await this.waitForPageLoad();
// Navigate back to providers page to ensure clean state
await this.goto();
await expect(this.providersTable).toBeVisible({ timeout: 10000 });
}
}
+52 -29
View File
@@ -15,13 +15,14 @@ import {
KubernetesProviderCredential,
KUBERNETES_CREDENTIAL_OPTIONS,
} from "./providers-page";
import { ScansPage } from "../scans/scans-page";
import fs from "fs";
test.describe.serial("Add Provider", () => {
test.describe("Add AWS Provider", () => {
test.describe("Add Provider", () => {
test.describe.serial("Add AWS Provider", () => {
// Providers page object
let providersPage: ProvidersPage;
let scansPage: ScansPage;
// Test data from environment variables
const accountId = process.env.E2E_AWS_PROVIDER_ACCOUNT_ID;
const accessKey = process.env.E2E_AWS_PROVIDER_ACCESS_KEY;
@@ -39,7 +40,7 @@ test.describe.serial("Add Provider", () => {
test.beforeEach(async ({ page }) => {
providersPage = new ProvidersPage(page);
// Clean up existing provider to ensure clean test state
await helpers.deleteProviderIfExists(page, accountId);
await providersPage.deleteProviderIfExists(accountId);
});
// Use admin authentication for provider management
@@ -108,7 +109,8 @@ test.describe.serial("Add Provider", () => {
await providersPage.clickNext();
// Wait for redirect to provider page
await providersPage.verifyLoadProviderPageAfterNewProvider();
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
},
);
@@ -176,14 +178,16 @@ test.describe.serial("Add Provider", () => {
await providersPage.clickNext();
// Wait for redirect to provider page
await providersPage.verifyLoadProviderPageAfterNewProvider();
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
},
);
});
test.describe("Add AZURE Provider", () => {
test.describe.serial("Add AZURE Provider", () => {
// Providers page object
let providersPage: ProvidersPage;
let scansPage: ScansPage;
// Test data from environment variables
const subscriptionId = process.env.E2E_AZURE_SUBSCRIPTION_ID;
@@ -202,7 +206,7 @@ test.describe.serial("Add Provider", () => {
test.beforeEach(async ({ page }) => {
providersPage = new ProvidersPage(page);
// Clean up existing provider to ensure clean test state
await helpers.deleteProviderIfExists(page, subscriptionId);
await providersPage.deleteProviderIfExists(subscriptionId);
});
// Use admin authentication for provider management
@@ -258,15 +262,17 @@ test.describe.serial("Add Provider", () => {
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to provider page
await providersPage.verifyLoadProviderPageAfterNewProvider();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
},
);
});
test.describe("Add M365 Provider", () => {
test.describe.serial("Add M365 Provider", () => {
// Providers page object
let providersPage: ProvidersPage;
let scansPage: ScansPage;
// Test data from environment variables
const domainId = process.env.E2E_M365_DOMAIN_ID;
@@ -284,7 +290,7 @@ test.describe.serial("Add Provider", () => {
test.beforeEach(async ({ page }) => {
providersPage = new ProvidersPage(page);
// Clean up existing provider to ensure clean test state
await helpers.deleteProviderIfExists(page, domainId);
await providersPage.deleteProviderIfExists(domainId);
});
// Use admin authentication for provider management
@@ -342,6 +348,8 @@ test.describe.serial("Add Provider", () => {
await providersPage.selectM365CredentialsType(
M365_CREDENTIAL_OPTIONS.M365_CREDENTIALS,
);
// Verify M365 credentials page is loaded
await providersPage.verifyM365CredentialsPageLoaded();
// Fill static credentials details
@@ -352,8 +360,9 @@ test.describe.serial("Add Provider", () => {
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to provider page
await providersPage.verifyLoadProviderPageAfterNewProvider();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
},
);
@@ -412,7 +421,9 @@ test.describe.serial("Add Provider", () => {
await providersPage.selectM365CredentialsType(
M365_CREDENTIAL_OPTIONS.M365_CERTIFICATE_CREDENTIALS,
);
await providersPage.verifyM365CredentialsPageLoaded();
// Verify M365 certificate credentials page is loaded
await providersPage.verifyM365CertificateCredentialsPageLoaded();
// Fill static credentials details
await providersPage.fillM365CertificateCredentials(m365Credentials);
@@ -422,15 +433,17 @@ test.describe.serial("Add Provider", () => {
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to provider page
await providersPage.verifyLoadProviderPageAfterNewProvider();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
},
);
});
test.describe("Add Kubernetes Provider", () => {
test.describe.skip("Add Kubernetes Provider", () => {
// Providers page object
let providersPage: ProvidersPage;
let scansPage: ScansPage;
// Test data from environment variables
const context = process.env.E2E_KUBERNETES_CONTEXT;
@@ -443,19 +456,12 @@ test.describe.serial("Add Provider", () => {
);
}
// Read kubeconfig content from file
const kubeconfigContent = fs.readFileSync(kubeconfigPath, 'utf8');
// Verify kubeconfig file exists
if (!fs.existsSync(kubeconfigPath)) {
throw new Error(`Kubeconfig file not found at ${kubeconfigPath}`);
}
// Setup before each test
test.beforeEach(async ({ page }) => {
providersPage = new ProvidersPage(page);
// Clean up existing provider to ensure clean test state
await helpers.deleteProviderIfExists(page, context);
await providersPage.deleteProviderIfExists(context);
});
// Use admin authentication for provider management
@@ -474,6 +480,21 @@ test.describe.serial("Add Provider", () => {
],
},
async ({ page }) => {
// Verify kubeconfig file exists
if (!fs.existsSync(kubeconfigPath)) {
throw new Error(`Kubeconfig file not found at ${kubeconfigPath}`);
}
// Read kubeconfig content from file
let kubeconfigContent: string;
try {
kubeconfigContent = fs.readFileSync(kubeconfigPath, "utf8");
} catch (error) {
throw new Error(
`Failed to read kubeconfig file at ${kubeconfigPath}: ${error}`,
);
}
// Prepare test data for Kubernetes provider
const kubernetesProviderData: KubernetesProviderData = {
context: context,
@@ -498,7 +519,9 @@ test.describe.serial("Add Provider", () => {
await providersPage.selectKubernetesProvider();
// Fill provider details
await providersPage.fillKubernetesProviderDetails(kubernetesProviderData);
await providersPage.fillKubernetesProviderDetails(
kubernetesProviderData,
);
await providersPage.clickNext();
// Verify credentials page is loaded
@@ -513,9 +536,9 @@ test.describe.serial("Add Provider", () => {
await providersPage.clickNext();
// Wait for redirect to provider page
await providersPage.verifyLoadProviderPageAfterNewProvider();
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
},
);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { Page, Locator, expect } from "@playwright/test";
import { BasePage } from "../base-page";
// Scan page
export class ScansPage extends BasePage {
// Main content elements
readonly scanTable: Locator;
constructor(page: Page) {
super(page);
// Main content elements
this.scanTable = page.locator("table");
}
// Navigation methods
async goto(): Promise<void> {
await super.goto("/scans");
}
// Verification methods
async verifyPageLoaded(): Promise<void> {
await expect(this.page).toHaveTitle(/Prowler/);
await expect(this.scanTable).toBeVisible();
await this.waitForPageLoad();
}
}