From ddb6c03c0e387a74168e211334ba2bcb1cc39596 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:12:54 +0100 Subject: [PATCH] test(ui): fix provider E2E test selectors and reliability (#10178) --- .../workflow/forms/send-invitation-form.tsx | 2 +- .../use-provider-wizard-controller.test.tsx | 3 + .../hooks/use-provider-wizard-controller.ts | 8 +- .../wizard/provider-wizard-modal.tsx | 6 +- ui/hooks/use-scroll-hint.ts | 74 ++--- ui/lib/external-urls.ts | 2 + ui/playwright.config.ts | 7 + ui/tests/auth/auth-middleware.spec.ts | 41 ++- ui/tests/helpers.ts | 55 +--- ui/tests/invitations/invitations-page.ts | 23 +- ui/tests/invitations/invitations.spec.ts | 67 ++-- ui/tests/providers/providers-page.ts | 242 ++++++++++----- ui/tests/providers/providers.md | 57 ++++ ui/tests/providers/providers.spec.ts | 288 +++++++++--------- ui/tests/sign-up/sign-up-page.ts | 4 +- ui/types/formSchemas.test.ts | 48 +++ ui/types/formSchemas.ts | 40 ++- 17 files changed, 601 insertions(+), 366 deletions(-) create mode 100644 ui/types/formSchemas.test.ts diff --git a/ui/components/invitations/workflow/forms/send-invitation-form.tsx b/ui/components/invitations/workflow/forms/send-invitation-form.tsx index 843885d772..729dc89d28 100644 --- a/ui/components/invitations/workflow/forms/send-invitation-form.tsx +++ b/ui/components/invitations/workflow/forms/send-invitation-form.tsx @@ -123,7 +123,7 @@ export const SendInvitationForm = ({ onValueChange={field.onChange} disabled={isSelectorDisabled} > - + diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx index 6830d65a4c..9a1b119bad 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx @@ -95,6 +95,9 @@ describe("useProviderWizardController", () => { expect(result.current.wizardVariant).toBe("organizations"); expect(result.current.isProviderFlow).toBe(false); expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.SETUP); + expect(result.current.docsLink).toBe( + "https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations", + ); // When act(() => { diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts index f6675bd4f0..9e0f6b4636 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -3,7 +3,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { getProviderHelpText } from "@/lib/external-urls"; +import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls"; import { useOrgSetupStore } from "@/store/organizations/store"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; import { @@ -195,9 +195,9 @@ export function useProviderWizardController({ }; const isProviderFlow = wizardVariant === WIZARD_VARIANT.PROVIDER; - const docsLink = getProviderHelpText( - isProviderFlow ? (providerTypeHint ?? providerType ?? "") : "aws", - ).link; + const docsLink = isProviderFlow + ? getProviderHelpText(providerTypeHint ?? providerType ?? "").link + : DOCS_URLS.AWS_ORGANIZATIONS; const resolvedFooterConfig: WizardFooterConfig = isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH ? { diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index 71cc88b8d2..c59a3082bb 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -58,7 +58,7 @@ export function ProviderWizardModal({ initialData, }); const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`; - const { containerRef, showScrollHint, handleScroll } = useScrollHint({ + const { containerRef, sentinelRef, showScrollHint } = useScrollHint({ enabled: open, refreshToken: scrollHintRefreshToken, }); @@ -106,7 +106,6 @@ export function ProviderWizardModal({
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.CONNECT && ( )} + + {/* Sentinel element for IntersectionObserver scroll detection */} +
{showScrollHint && ( diff --git a/ui/hooks/use-scroll-hint.ts b/ui/hooks/use-scroll-hint.ts index 4d18c10d30..6a88db1d88 100644 --- a/ui/hooks/use-scroll-hint.ts +++ b/ui/hooks/use-scroll-hint.ts @@ -1,63 +1,67 @@ "use client"; -import { UIEvent, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; interface UseScrollHintOptions { enabled?: boolean; refreshToken?: string | number; } -const SCROLL_THRESHOLD_PX = 4; - -function shouldShowScrollHint(element: HTMLDivElement) { - const hasOverflow = - element.scrollHeight - element.clientHeight > SCROLL_THRESHOLD_PX; - const isAtBottom = - element.scrollTop + element.clientHeight >= - element.scrollHeight - SCROLL_THRESHOLD_PX; - - return hasOverflow && !isAtBottom; -} - +/** + * Detects whether a scrollable container has overflow using an + * IntersectionObserver on a sentinel element placed at the end of the content. + * + * Uses callback refs (stored in state) so the observer is set up only after + * the DOM elements actually mount — critical for Radix Dialog portals where + * useRef would be null when the first useEffect fires. + * + * When the sentinel is NOT visible inside the container → content overflows + * and the user hasn't scrolled to the bottom → show hint. + */ export function useScrollHint({ enabled = true, refreshToken, }: UseScrollHintOptions = {}) { - const containerRef = useRef(null); + const [containerEl, setContainerEl] = useState(null); + const [sentinelEl, setSentinelEl] = useState(null); const [showScrollHint, setShowScrollHint] = useState(false); useEffect(() => { - if (!enabled) { + if (!enabled || !containerEl || !sentinelEl) { setShowScrollHint(false); return; } - const element = containerRef.current; - if (!element) return; + const observer = new IntersectionObserver( + ([entry]) => { + setShowScrollHint(!entry.isIntersecting); + }, + { + root: containerEl, + // Small margin so the hint hides slightly before the absolute bottom + rootMargin: "0px 0px 4px 0px", + threshold: 0, + }, + ); - const recalculate = () => { - const el = containerRef.current; - if (!el) return; - setShowScrollHint(shouldShowScrollHint(el)); - }; + observer.observe(sentinelEl); - const observer = new ResizeObserver(recalculate); - observer.observe(element); + return () => observer.disconnect(); + }, [enabled, refreshToken, containerEl, sentinelEl]); - recalculate(); - - return () => { - observer.disconnect(); - }; - }, [enabled, refreshToken]); - - const handleScroll = (event: UIEvent) => { - setShowScrollHint(shouldShowScrollHint(event.currentTarget)); - }; + // Stable callback refs — setState setters never change identity + const containerRef = useCallback( + (node: HTMLDivElement | null) => setContainerEl(node), + [], + ); + const sentinelRef = useCallback( + (node: HTMLDivElement | null) => setSentinelEl(node), + [], + ); return { containerRef, + sentinelRef, showScrollHint, - handleScroll, }; } diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index dbd1bd043e..1fe10ef603 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -4,6 +4,8 @@ import { IntegrationType } from "../types/integrations"; export const DOCS_URLS = { FINDINGS_ANALYSIS: "https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings", + AWS_ORGANIZATIONS: + "https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations", } as const; export const getProviderHelpText = (provider: string) => { diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 8c9d053944..34f3155680 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -1,4 +1,11 @@ import { defineConfig, devices } from "@playwright/test"; +import fs from "fs"; +import path from "path"; + +const localEnvPath = path.resolve(__dirname, ".env.local"); +if (fs.existsSync(localEnvPath)) { + process.loadEnvFile(localEnvPath); +} export default defineConfig({ testDir: "./tests", diff --git a/ui/tests/auth/auth-middleware.spec.ts b/ui/tests/auth/auth-middleware.spec.ts index d8e89640c9..959f08250d 100644 --- a/ui/tests/auth/auth-middleware.spec.ts +++ b/ui/tests/auth/auth-middleware.spec.ts @@ -1,8 +1,7 @@ import { expect, test } from "@playwright/test"; -import { getSessionWithoutCookies, TEST_CREDENTIALS } from "../helpers"; +import { TEST_CREDENTIALS } from "../helpers"; import { ProvidersPage } from "../providers/providers-page"; -import { ScansPage } from "../scans/scans-page"; import { SignInPage } from "../sign-in-base/sign-in-base-page"; import { SignUpPage } from "../sign-up/sign-up-page"; @@ -30,24 +29,46 @@ test.describe("Middleware Error Handling", () => { test( "should maintain protection after session error", { tag: ["@e2e", "@auth", "@middleware", "@AUTH-MW-E2E-002"] }, - async ({ page, context }) => { + async ({ page, context, browser }) => { const signInPage = new SignInPage(page); const providersPage = new ProvidersPage(page); - const scansPage = new ScansPage(page); await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID); await providersPage.goto(); await providersPage.verifyPageLoaded(); - // Remove auth cookies to simulate a broken/expired session deterministically. - await context.clearCookies(); + // Build an isolated context with an explicitly invalid auth token. + // This avoids races from active tabs rehydrating cookies in the original context. + const authenticatedState = await context.storageState(); + const authCookies = authenticatedState.cookies.filter((cookie) => + /(authjs|next-auth)/i.test(cookie.name), + ); + expect(authCookies.length).toBeGreaterThan(0); - const expiredSession = await getSessionWithoutCookies(page); - expect(expiredSession).toBeNull(); + const invalidSessionContext = await browser.newContext({ + storageState: { + origins: authenticatedState.origins, + cookies: authenticatedState.cookies.map((cookie) => + /(authjs|next-auth)/i.test(cookie.name) + ? { ...cookie, value: "invalid.session.token" } + : cookie, + ), + }, + }); - await scansPage.goto(); - await signInPage.verifyOnSignInPage(); + try { + // Use a fresh page to force a full navigation through proxy in Next.js 16. + const freshPage = await invalidSessionContext.newPage(); + const freshSignInPage = new SignInPage(freshPage); + const cacheBuster = Date.now(); + await freshPage.goto(`/scans?e2e_mw=${cacheBuster}`, { + waitUntil: "commit", + }); + await freshSignInPage.verifyRedirectWithCallback("/scans"); + } finally { + await invalidSessionContext.close(); + } }, ); diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts index 71e944f9bd..d4f01f8217 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -135,47 +135,30 @@ export async function deleteProviderIfExists(page: ProvidersPage, providerUID: s await page.goto(); await expect(page.providersTable).toBeVisible({ timeout: 10000 }); - // Find and use the search input to filter the table - const searchInput = page.page.getByPlaceholder(/search|filter/i); - - await expect(searchInput).toBeVisible({ timeout: 5000 }); - - // Clear and search for the specific provider - await searchInput.clear(); - await searchInput.fill(providerUID); - await searchInput.press("Enter"); - - // 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 page.page.waitForTimeout(1500); - - // Get all rows from the table const allRows = page.providersTable.locator("tbody tr"); - // Helper function to check if a row is the "No results" row const isNoResultsRow = async (row: Locator): Promise => { const text = await row.textContent(); return text?.includes("No results") || text?.includes("No data") || false; }; - // Helper function to find the row with the specific UID const findProviderRow = async (): Promise => { - const count = await allRows.count(); + 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); - - // 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)) { + const rowText = await row.textContent(); + if (rowText?.includes(providerUID)) { return row; } } @@ -183,24 +166,6 @@ export async function deleteProviderIfExists(page: ProvidersPage, providerUID: s return null; }; - // Wait for filtering to complete (max 0 or 1 data rows) - await expect(async () => { - - 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 provider row const targetRow = await findProviderRow(); diff --git a/ui/tests/invitations/invitations-page.ts b/ui/tests/invitations/invitations-page.ts index 6e1e61c550..4b658ac380 100644 --- a/ui/tests/invitations/invitations-page.ts +++ b/ui/tests/invitations/invitations-page.ts @@ -40,11 +40,10 @@ export class InvitationsPage extends BasePage { // Form inputs this.emailInput = page.getByRole("textbox", { name: "Email" }); - // Form select - this.roleSelect = page - .getByRole("combobox", { name: /Role|Select a role/i }) - .or(page.getByRole("button", { name: /Role|Select a role/i })) - .first(); + // Form select (Radix Select renders