fix(ui): stabilize cloud e2e prerequisites (#12024)

This commit is contained in:
Alan Buscaglia
2026-07-30 14:54:26 +02:00
committed by GitHub
parent 7a6a35afec
commit 7a62926a09
13 changed files with 298 additions and 64 deletions
+11
View File
@@ -249,6 +249,7 @@ modules:
- ui/tests/profile/**
- ui/tests/lighthouse/**
- ui/tests/home/**
- ui/tests/navigation/**
- ui/tests/attack-paths/**
- name: api-serializers
@@ -275,6 +276,7 @@ modules:
- ui/tests/profile/**
- ui/tests/lighthouse/**
- ui/tests/home/**
- ui/tests/navigation/**
- ui/tests/attack-paths/**
- name: api-filters
@@ -432,6 +434,14 @@ modules:
e2e:
- ui/tests/lighthouse/**
- name: ui-navigation
match:
- ui/components/layout/**
- ui/tests/navigation/**
tests: []
e2e:
- ui/tests/navigation/**
- name: ui-overview
match:
- ui/components/overview/**
@@ -464,6 +474,7 @@ modules:
- ui/tests/profile/**
- ui/tests/lighthouse/**
- ui/tests/home/**
- ui/tests/navigation/**
- ui/tests/attack-paths/**
- name: ui-attack-paths
+1
View File
@@ -107,6 +107,7 @@ jobs:
files: |
api/**
.github/workflows/api-tests.yml
codecov.yml
files_ignore: |
api/docs/**
api/README.md
+117
View File
@@ -36,6 +36,7 @@ jobs:
needs: impact-analysis
if: |
github.repository == 'prowler-cloud/prowler' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) &&
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
runs-on: ubuntu-latest
env:
@@ -118,6 +119,104 @@ jobs:
env:
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
- name: Validate E2E prerequisites
shell: bash
run: |
declare -A required=()
suite_selected() {
[[ "${RUN_ALL_TESTS}" == "true" ]] ||
[[ " ${E2E_TEST_PATHS} " == *"ui/tests/$1/"* ]]
}
require_vars() {
local variable
for variable in "$@"; do
required["${variable}"]=1
done
}
if suite_selected auth || suite_selected providers ||
suite_selected invitations || suite_selected scans ||
suite_selected navigation; then
require_vars E2E_ADMIN_USER E2E_ADMIN_PASSWORD
fi
if suite_selected sign-up; then
require_vars E2E_NEW_USER_PASSWORD
fi
if suite_selected invitations; then
require_vars E2E_NEW_USER_PASSWORD E2E_ORGANIZATION_ID
fi
if suite_selected scans; then
require_vars \
E2E_AWS_PROVIDER_ACCOUNT_ID \
E2E_AWS_PROVIDER_ACCESS_KEY \
E2E_AWS_PROVIDER_SECRET_KEY
fi
if suite_selected providers; then
require_vars \
E2E_AWS_PROVIDER_ACCOUNT_ID \
E2E_AWS_PROVIDER_ACCESS_KEY \
E2E_AWS_PROVIDER_SECRET_KEY \
E2E_AWS_PROVIDER_ROLE_ARN \
E2E_AZURE_SUBSCRIPTION_ID \
E2E_AZURE_CLIENT_ID \
E2E_AZURE_SECRET_ID \
E2E_AZURE_TENANT_ID \
E2E_M365_DOMAIN_ID \
E2E_M365_CLIENT_ID \
E2E_M365_SECRET_ID \
E2E_M365_TENANT_ID \
E2E_M365_CERTIFICATE_CONTENT \
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY \
E2E_GCP_PROJECT_ID \
E2E_GITHUB_APP_ID \
E2E_GITHUB_BASE64_APP_PRIVATE_KEY \
E2E_GITHUB_USERNAME \
E2E_GITHUB_PERSONAL_ACCESS_TOKEN \
E2E_GITHUB_ORGANIZATION \
E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN \
E2E_OCI_TENANCY_ID \
E2E_OCI_USER_ID \
E2E_OCI_FINGERPRINT \
E2E_OCI_KEY_CONTENT \
E2E_ALIBABACLOUD_ACCOUNT_ID \
E2E_ALIBABACLOUD_ACCESS_KEY_ID \
E2E_ALIBABACLOUD_ACCESS_KEY_SECRET \
E2E_ALIBABACLOUD_ROLE_ARN \
E2E_OKTA_DOMAIN \
E2E_OKTA_CLIENT_ID \
E2E_OKTA_BASE64_PRIVATE_KEY \
E2E_GOOGLEWORKSPACE_CUSTOMER_ID \
E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON \
E2E_GOOGLEWORKSPACE_DELEGATED_USER \
E2E_VERCEL_TEAM_ID \
E2E_VERCEL_API_TOKEN
fi
missing=()
if (( ${#required[@]} > 0 )); then
while IFS= read -r variable; do
[[ -z "${!variable:-}" ]] && missing+=("${variable}")
done < <(printf '%s\n' "${!required[@]}" | sort)
fi
if (( ${#missing[@]} > 0 )); then
echo "Missing required E2E variables:"
printf ' - %s\n' "${missing[@]}"
{
echo "## Missing E2E prerequisites"
printf -- "- \`%s\`\n" "${missing[@]}"
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
echo "E2E prerequisite preflight passed."
- name: Create k8s Kind Cluster
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1
with:
@@ -304,6 +403,24 @@ jobs:
run: |
docker compose down -v || true
# Fork pull requests cannot access the secrets required by the E2E suites.
fork-e2e-unavailable:
needs: impact-analysis
if: |
github.repository == 'prowler-cloud/prowler' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == true &&
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Report unavailable E2E tests
run: |
echo "## E2E Tests Skipped" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "UI E2E tests require repository secrets and cannot run for fork pull requests." >> "$GITHUB_STEP_SUMMARY"
# Skip job - provides clear feedback when no E2E tests needed
skip-e2e:
needs: impact-analysis
+2
View File
@@ -173,3 +173,5 @@ GEMINI.md
# Docker
docker-compose.override.yml
docker-compose-dev.override.yml
# Local Pi runtime state
.atl/
+6
View File
@@ -7,5 +7,11 @@ component_management:
paths:
- "api/**"
flags:
api:
paths:
- "api/**"
carryforward: true
comment:
layout: "header, diff, flags, components"
+29
View File
@@ -0,0 +1,29 @@
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).parents[2]
WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/api-tests.yml"
def _indented_block(text, heading):
lines = text.splitlines()
start = lines.index(heading)
indentation = len(heading) - len(heading.lstrip())
end = len(lines)
for index in range(start + 1, len(lines)):
line = lines[index]
if line.strip() and len(line) - len(line.lstrip()) <= indentation:
end = index
break
return lines[start:end]
def test_codecov_configuration_changes_run_api_tests():
workflow = WORKFLOW_PATH.read_text()
changed_files_step = _indented_block(
workflow, " - name: Check for API changes"
)
files = _indented_block("\n".join(changed_files_step), " files: |")
assert "codecov.yml" in {line.strip() for line in files[1:]}
+79
View File
@@ -0,0 +1,79 @@
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).parents[2]
WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/ui-e2e-tests-v2.yml"
NODE_IMAGE_DIGEST = (
"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"
)
def _indented_block(text, heading):
lines = text.splitlines()
start = lines.index(heading)
indentation = len(heading) - len(heading.lstrip())
end = len(lines)
for index in range(start + 1, len(lines)):
line = lines[index]
if line.strip() and len(line) - len(line.lstrip()) <= indentation:
end = index
break
return "\n".join(lines[start:end])
def _multiline_value(block, key):
lines = block.splitlines()
heading = next(line for line in lines if line.strip() == f"{key}: |")
value_block = _indented_block(block, heading)
return "\n".join(value_block.splitlines()[1:])
def _normalize(expression):
return " ".join(expression.split())
def test_fork_pull_requests_use_explicit_skip_route():
workflow = WORKFLOW_PATH.read_text()
e2e_job = _indented_block(workflow, " e2e-tests:")
fork_job = _indented_block(workflow, " fork-e2e-unavailable:")
assert _normalize(_multiline_value(e2e_job, "if")) == (
"github.repository == 'prowler-cloud/prowler' && "
"(github.event_name != 'pull_request' || "
"github.event.pull_request.head.repo.fork == false) && "
"(needs.impact-analysis.outputs.has-ui-e2e == 'true' || "
"needs.impact-analysis.outputs.run-all == 'true')"
)
assert _normalize(_multiline_value(fork_job, "if")) == (
"github.repository == 'prowler-cloud/prowler' && "
"github.event_name == 'pull_request' && "
"github.event.pull_request.head.repo.fork == true && "
"(needs.impact-analysis.outputs.has-ui-e2e == 'true' || "
"needs.impact-analysis.outputs.run-all == 'true')"
)
assert _indented_block(fork_job, " permissions:") == (
" permissions:\n contents: read"
)
reporting_step = _indented_block(
fork_job, " - name: Report unavailable E2E tests"
)
assert "GITHUB_STEP_SUMMARY" in reporting_step
assert (
"UI E2E tests require repository secrets and cannot run for fork pull requests."
in reporting_step
)
prerequisite_step = _indented_block(
e2e_job, " - name: Validate E2E prerequisites"
)
assert "IS_FORK_PR" not in prerequisite_step
assert "exit 0" not in prerequisite_step
def test_docker_node_image_matches_nvmrc():
node_version = (REPOSITORY_ROOT / "ui/.nvmrc").read_text().strip()
dockerfile = (REPOSITORY_ROOT / "ui/Dockerfile").read_text()
assert f"FROM node:{node_version}-alpine@{NODE_IMAGE_DIGEST} AS base" in dockerfile
+1 -1
View File
@@ -1 +1 @@
24.13.0
24.15.0
+1 -1
View File
@@ -1,5 +1,5 @@
# Keep in sync with ui/.nvmrc.
FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b181aa49e22c14383e AS base
FROM node:24.15.0-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS base
LABEL maintainer="https://github.com/prowler-cloud"
+40 -57
View File
@@ -129,76 +129,50 @@ export async function addAWSProvider(
}
/**
* Waits for the providers page to settle and reports whether the data table is
* present. With zero providers the page renders a full-page empty state
* ("No Providers Configured") instead of the table, so callers must not assume
* the table is always there.
* Finds a provider after the filtered providers page reaches a loaded state.
* The loading skeleton also renders a table, so table visibility alone cannot
* prove that provider data is ready.
*/
async function providersTableVisibleOrEmptyState(
async function findProviderRow(
page: ProvidersPage,
): Promise<boolean> {
providerUID: string,
): Promise<Locator | null> {
await page.page.goto(
`/providers?filter%5Bsearch%5D=${encodeURIComponent(providerUID)}`,
);
const providerRow = page.providersTable
.getByRole("row")
.filter({ hasText: providerUID })
.first();
const noResults = page.providersTable.getByRole("cell", {
name: "No results.",
exact: true,
});
const emptyState = page.page.getByRole("region", {
name: /no providers configured/i,
});
await expect(page.providersTable.or(emptyState)).toBeVisible({
await expect(providerRow.or(noResults).or(emptyState)).toBeVisible({
timeout: 10000,
});
return page.providersTable.isVisible().catch(() => false);
if (await providerRow.isVisible().catch(() => false)) {
return providerRow;
}
return null;
}
export async function deleteProviderIfExists(
page: ProvidersPage,
providerUID: string,
): Promise<void> {
// Delete the provider if it exists
// Navigate to providers page
await page.goto();
// With zero providers the page shows the empty state, not the table, so there
// is nothing to delete.
if (!(await providersTableVisibleOrEmptyState(page))) {
return;
}
const allRows = page.providersTable.locator("tbody tr");
const isNoResultsRow = async (row: Locator): Promise<boolean> => {
const text = await row.textContent();
return text?.includes("No results") || text?.includes("No data") || false;
};
const findProviderRow = async (): Promise<Locator | null> => {
const rowByText = page.providersTable
.locator("tbody tr")
.filter({ hasText: providerUID })
.first();
if (await rowByText.isVisible().catch(() => false)) {
return rowByText;
}
const count = await allRows.count();
for (let i = 0; i < count; i++) {
const row = allRows.nth(i);
if (await isNoResultsRow(row)) {
continue;
}
const rowText = await row.textContent();
if (rowText?.includes(providerUID)) {
return row;
}
}
return null;
};
// Find the provider row
const targetRow = await findProviderRow();
const targetRow = await findProviderRow(page, providerUID);
if (!targetRow) {
// Provider not found, nothing to delete
// Navigate back to providers page to ensure clean state
await page.goto();
await providersTableVisibleOrEmptyState(page);
return;
}
@@ -239,8 +213,17 @@ export async function deleteProviderIfExists(
// Wait for modal to close (this indicates deletion was initiated)
await expect(modal).not.toBeVisible({ timeout: 10000 });
// Navigate back to providers page to ensure clean state. Deleting the last
// provider reveals the empty state instead of an empty table.
await page.goto();
await providersTableVisibleOrEmptyState(page);
// The success notification is shown only after the delete request completes.
await expect(
page.page.getByText("The provider was removed successfully.", {
exact: true,
}),
).toBeVisible({ timeout: 10000 });
// Reload a server-filtered view and prove the provider no longer exists.
const deletedProviderRow = await findProviderRow(page, providerUID);
expect(
deletedProviderRow,
`Provider ${providerUID} still exists after deletion`,
).toBeNull();
}
+2 -2
View File
@@ -40,8 +40,8 @@ export class HomePage extends BasePage {
this.dashboardCards = page.locator('[data-testid="dashboard-card"]');
this.overviewSection = page.locator('[data-testid="overview-section"]');
// UI elements
this.logo = page.locator('svg[width="300"]');
// UI elements - the sidebar logo is the home link's accessible content.
this.logo = page.getByRole("link", { name: "Prowler home" });
}
// Navigation methods
+5 -1
View File
@@ -107,7 +107,11 @@ export class ScansPage extends BasePage {
await expect(this.launchScanDialog).toBeVisible();
await this.scanProviderSelect.click();
await this.scanProviderSearchInput.fill(uid);
await this.scanProviderOption.first().click();
const providerOption = this.scanProviderOption.first();
await expect(providerOption).toBeVisible();
await expect(providerOption).toHaveAttribute("aria-disabled", "false");
await providerOption.click();
}
async fillScanNote(note: string): Promise<void> {
+4 -2
View File
@@ -67,8 +67,10 @@ export class SignInPage extends BasePage {
this.signUpLink = page.getByRole("link", { name: "Sign up" });
this.backButton = page.getByRole("button", { name: "Back" });
// UI elements - title is a <p> element, not a heading
this.logo = page.getByRole("img", { name: /Prowler/ });
// UI elements - ProwlerBrand exposes the deployed brand variant as an image.
this.logo = page.getByRole("img", {
name: /^Prowler (Cloud|Local Server)$/,
});
// Use text matching with exact=true to avoid matching other elements
this.pageTitle = page.getByText("Welcome back", { exact: true });