fix(ui): stabilize provider wizard modal and DataTable rendering (#10194)

This commit is contained in:
Alejandro Bailo
2026-02-27 14:35:13 +01:00
committed by GitHub
parent fff80a920b
commit 80e84d1da4
14 changed files with 512 additions and 154 deletions

View File

@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("providers page", () => {
it("does not use unstable Date.now keys for the providers DataTable", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
expect(source).not.toContain("key={`providers-${Date.now()}`}");
});
});

View File

@@ -104,7 +104,6 @@ const ProvidersTable = async ({
<div className="grid grid-cols-12 gap-4">
<div className="col-span-12">
<DataTable
key={`providers-${Date.now()}`}
columns={ColumnProviders}
data={enrichedProviders || []}
metadata={providersData?.meta}

View File

@@ -11,16 +11,6 @@ import {
import { useProviderWizardController } from "./use-provider-wizard-controller";
const { pushMock } = vi.hoisted(() => ({
pushMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
}),
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({
data: null,
@@ -30,9 +20,9 @@ vi.mock("next-auth/react", () => ({
describe("useProviderWizardController", () => {
beforeEach(() => {
vi.useRealTimers();
sessionStorage.clear();
localStorage.clear();
pushMock.mockReset();
useProviderWizardStore.getState().reset();
useOrgSetupStore.getState().reset();
});
@@ -131,7 +121,7 @@ describe("useProviderWizardController", () => {
expect(onOpenChange).not.toHaveBeenCalled();
});
it("closes and navigates when launch footer action is triggered", () => {
it("does not override launch footer config in the controller", () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
@@ -146,15 +136,10 @@ describe("useProviderWizardController", () => {
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
});
const { resolvedFooterConfig } = result.current;
act(() => {
resolvedFooterConfig.onAction?.();
});
// Then
expect(pushMock).toHaveBeenCalledWith("/scans");
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT);
expect(result.current.resolvedFooterConfig.showAction).toBe(false);
expect(result.current.resolvedFooterConfig.showBack).toBe(false);
expect(onOpenChange).not.toHaveBeenCalled();
});
it("does not reset organizations step when org store updates while modal is open", () => {
@@ -188,4 +173,69 @@ describe("useProviderWizardController", () => {
expect(result.current.wizardVariant).toBe("organizations");
expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.VALIDATE);
});
it("does not rehydrate wizard state when initial data changes while modal remains open", async () => {
// Given
const onOpenChange = vi.fn();
const { result, rerender } = renderHook(
({
open,
initialData,
}: {
open: boolean;
initialData?: {
providerId: string;
providerType: "gcp";
providerUid: string;
providerAlias: string;
secretId: string | null;
mode: "add" | "update";
};
}) =>
useProviderWizardController({
open,
onOpenChange,
initialData,
}),
{
initialProps: {
open: true,
initialData: {
providerId: "provider-1",
providerType: "gcp",
providerUid: "project-123",
providerAlias: "gcp-main",
secretId: null,
mode: PROVIDER_WIZARD_MODE.ADD,
},
},
},
);
await waitFor(() => {
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS);
});
act(() => {
useProviderWizardStore.getState().setVia("service-account");
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST);
});
// When: provider data refreshes while modal is still open
rerender({
open: true,
initialData: {
providerId: "provider-1",
providerType: "gcp",
providerUid: "project-123",
providerAlias: "gcp-main",
secretId: "secret-1",
mode: PROVIDER_WIZARD_MODE.UPDATE,
},
});
// Then: keep user progress in the current flow
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.TEST);
expect(useProviderWizardStore.getState().via).toBe("service-account");
});
});

View File

@@ -1,7 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls";
import { useOrgSetupStore } from "@/store/organizations/store";
@@ -63,7 +62,7 @@ export function useProviderWizardController({
const initialSecretId = initialData?.secretId ?? null;
const initialVia = initialData?.via ?? null;
const initialMode = initialData?.mode ?? null;
const router = useRouter();
const hasHydratedForCurrentOpenRef = useRef(false);
const [wizardVariant, setWizardVariant] = useState<WizardVariant>(
WIZARD_VARIANT.PROVIDER,
);
@@ -95,9 +94,15 @@ export function useProviderWizardController({
useEffect(() => {
if (!open) {
hasHydratedForCurrentOpenRef.current = false;
return;
}
if (hasHydratedForCurrentOpenRef.current) {
return;
}
hasHydratedForCurrentOpenRef.current = true;
if (initialProviderId && initialProviderType && initialProviderUid) {
setWizardVariant(WIZARD_VARIANT.PROVIDER);
setProvider({
@@ -198,25 +203,7 @@ export function useProviderWizardController({
const docsLink = isProviderFlow
? getProviderHelpText(providerTypeHint ?? providerType ?? "").link
: DOCS_URLS.AWS_ORGANIZATIONS;
const resolvedFooterConfig: WizardFooterConfig =
isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH
? {
showBack: true,
backLabel: "Back",
onBack: () => setCurrentStep(PROVIDER_WIZARD_STEP.TEST),
showSecondaryAction: false,
secondaryActionLabel: "",
secondaryActionVariant: "outline",
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
showAction: true,
actionLabel: "Go to scans",
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: () => {
handleClose();
router.push("/scans");
},
}
: footerConfig;
const resolvedFooterConfig: WizardFooterConfig = footerConfig;
const modalTitle = getProviderWizardModalTitle(mode);
return {

View File

@@ -13,7 +13,10 @@ import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard";
import { useProviderWizardController } from "./hooks/use-provider-wizard-controller";
import { getOrganizationsStepperOffset } from "./provider-wizard-modal.utils";
import {
getOrganizationsStepperOffset,
getProviderWizardDocsDestination,
} from "./provider-wizard-modal.utils";
import { ConnectStep } from "./steps/connect-step";
import { CredentialsStep } from "./steps/credentials-step";
import { WIZARD_FOOTER_ACTION_TYPE } from "./steps/footer-controls";
@@ -62,6 +65,7 @@ export function ProviderWizardModal({
enabled: open,
refreshToken: scrollHintRefreshToken,
});
const docsDestination = getProviderWizardDocsDestination(docsLink);
return (
<Modal
@@ -80,7 +84,7 @@ export function ProviderWizardModal({
<Button variant="link" size="link-sm" className="h-auto p-0" asChild>
<a href={docsLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-3.5 shrink-0" />
<span>Prowler Docs</span>
<span>{`Prowler Docs (${docsDestination})`}</span>
</a>
</Button>
</div>
@@ -136,7 +140,11 @@ export function ProviderWizardModal({
)}
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH && (
<LaunchStep />
<LaunchStep
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
onClose={handleClose}
onFooterChange={setFooterConfig}
/>
)}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && (

View File

@@ -5,6 +5,7 @@ import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import {
getOrganizationsStepperOffset,
getProviderWizardDocsDestination,
getProviderWizardModalTitle,
} from "./provider-wizard-modal.utils";
@@ -50,3 +51,21 @@ describe("getProviderWizardModalTitle", () => {
expect(title).toBe("Update Provider Credentials");
});
});
describe("getProviderWizardDocsDestination", () => {
it("returns a compact provider label for short provider docs links", () => {
const destination = getProviderWizardDocsDestination(
"https://goto.prowler.com/provider-aws",
);
expect(destination).toBe("aws");
});
it("returns a compact destination label for long docs links", () => {
const destination = getProviderWizardDocsDestination(
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
);
expect(destination).toBe("aws-organizations");
});
});

View File

@@ -27,3 +27,21 @@ export function getProviderWizardModalTitle(mode: ProviderWizardMode) {
return "Adding A Cloud Provider";
}
export function getProviderWizardDocsDestination(docsLink: string) {
try {
const parsed = new URL(docsLink);
const pathSegments = parsed.pathname
.split("/")
.filter((segment) => segment.length > 0);
const lastSegment = pathSegments.at(-1);
if (!lastSegment) {
return parsed.hostname;
}
return lastSegment.replace(/^provider-/, "").replace(/^prowler-cloud-/, "");
} catch {
return docsLink;
}
}

View File

@@ -0,0 +1,85 @@
import { act, render, waitFor } from "@testing-library/react";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { LaunchStep } from "./launch-step";
const { scheduleDailyMock, scanOnDemandMock, toastMock } = vi.hoisted(() => ({
scheduleDailyMock: vi.fn(),
scanOnDemandMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("@/actions/scans", () => ({
scheduleDaily: scheduleDailyMock,
scanOnDemand: scanOnDemandMock,
}));
vi.mock("@/components/ui", () => ({
ToastAction: ({ children, ...props }: ComponentProps<"button">) => (
<button {...props}>{children}</button>
),
useToast: () => ({
toast: toastMock,
}),
}));
describe("LaunchStep", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
scheduleDailyMock.mockReset();
scanOnDemandMock.mockReset();
toastMock.mockReset();
useProviderWizardStore.getState().reset();
});
it("launches a daily scan and shows toast", async () => {
// Given
const onClose = vi.fn();
const onFooterChange = vi.fn();
useProviderWizardStore.setState({
providerId: "provider-1",
providerType: "gcp",
providerUid: "project-123",
mode: "add",
});
scheduleDailyMock.mockResolvedValue({ data: { id: "scan-1" } });
render(
<LaunchStep
onBack={vi.fn()}
onClose={onClose}
onFooterChange={onFooterChange}
/>,
);
await waitFor(() => {
expect(onFooterChange).toHaveBeenCalled();
});
// When
const initialFooterConfig = onFooterChange.mock.calls.at(-1)?.[0];
await act(async () => {
initialFooterConfig.onAction?.();
});
// Then
await waitFor(() => {
expect(scheduleDailyMock).toHaveBeenCalledTimes(1);
});
const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData;
expect(sentFormData.get("providerId")).toBe("provider-1");
expect(onClose).toHaveBeenCalledTimes(1);
expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Scan Launched",
}),
);
});
});

View File

@@ -1,15 +1,162 @@
"use client";
import { CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { scanOnDemand, scheduleDaily } from "@/actions/scans";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
import { ToastAction, useToast } from "@/components/ui";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { TREE_ITEM_STATUS } from "@/types/tree";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "./footer-controls";
const SCAN_SCHEDULE = {
DAILY: "daily",
SINGLE: "single",
} as const;
type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE];
interface LaunchStepProps {
onBack: () => void;
onClose: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
}
export function LaunchStep({
onBack,
onClose,
onFooterChange,
}: LaunchStepProps) {
const { toast } = useToast();
const { providerId } = useProviderWizardStore();
const [isLaunching, setIsLaunching] = useState(false);
const [scheduleOption, setScheduleOption] = useState<ScanScheduleOption>(
SCAN_SCHEDULE.DAILY,
);
const launchActionRef = useRef<() => void>(() => {});
const handleLaunchScan = async () => {
if (!providerId) {
return;
}
setIsLaunching(true);
const formData = new FormData();
formData.set("providerId", providerId);
const result =
scheduleOption === SCAN_SCHEDULE.DAILY
? await scheduleDaily(formData)
: await scanOnDemand(formData);
if (result?.error) {
setIsLaunching(false);
toast({
variant: "destructive",
title: "Unable to launch scan",
description: String(result.error),
});
return;
}
setIsLaunching(false);
onClose();
toast({
title: "Scan Launched",
description:
scheduleOption === SCAN_SCHEDULE.DAILY
? "Daily scan scheduled successfully."
: "Single scan launched successfully.",
action: (
<ToastAction altText="Go to scans" asChild>
<Link href="/scans">Go to scans</Link>
</ToastAction>
),
});
};
launchActionRef.current = () => {
void handleLaunchScan();
};
useEffect(() => {
onFooterChange({
showBack: true,
backLabel: "Back",
backDisabled: isLaunching,
onBack,
showAction: true,
actionLabel: isLaunching ? "Launching scans..." : "Launch scan",
actionDisabled: isLaunching || !providerId,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: () => {
launchActionRef.current();
},
});
}, [isLaunching, onBack, onFooterChange, providerId]);
if (isLaunching) {
return (
<div className="flex min-h-[320px] items-center justify-center">
<div className="flex items-center gap-3 py-2">
<TreeSpinner className="size-6" />
<p className="text-sm font-medium">Launching scans...</p>
</div>
</div>
);
}
export function LaunchStep() {
return (
<div className="flex min-h-[320px] flex-col items-center justify-center gap-4 text-center">
<CheckCircle2 className="text-success size-12" />
<h3 className="text-xl font-semibold">Provider connected successfully</h3>
<p className="text-muted-foreground text-sm">
Continue with the action button to go to scans.
<div className="flex min-h-0 flex-1 flex-col gap-6">
<div className="flex items-center gap-3">
<TreeStatusIcon status={TREE_ITEM_STATUS.SUCCESS} className="size-6" />
<h3 className="text-sm font-semibold">Connection validated!</h3>
</div>
<p className="text-text-neutral-secondary text-sm">
Choose how you want to launch scans for this provider.
</p>
{!providerId && (
<p className="text-text-error-primary text-sm">
Provider data is missing. Go back and test the connection again.
</p>
)}
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm">Scan schedule</p>
<Select
value={scheduleOption}
onValueChange={(value) =>
setScheduleOption(value as ScanScheduleOption)
}
disabled={isLaunching || !providerId}
>
<SelectTrigger className="w-full max-w-[376px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={SCAN_SCHEDULE.DAILY}>
Scan Daily (every 24 hours)
</SelectItem>
<SelectItem value={SCAN_SCHEDULE.SINGLE}>
Run a single scan (no recurring schedule)
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
@@ -6,8 +7,9 @@ import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { TestConnectionStep } from "./test-connection-step";
const { getProviderMock } = vi.hoisted(() => ({
const { getProviderMock, loadingFromFormMock } = vi.hoisted(() => ({
getProviderMock: vi.fn(),
loadingFromFormMock: { current: false },
}));
vi.mock("@/actions/providers", () => ({
@@ -15,7 +17,19 @@ vi.mock("@/actions/providers", () => ({
}));
vi.mock("../../workflow/forms/test-connection-form", () => ({
TestConnectionForm: () => <div data-testid="test-connection-form" />,
TestConnectionForm: ({
onLoadingChange,
}: {
onLoadingChange?: (isLoading: boolean) => void;
}) => {
useEffect(() => {
if (loadingFromFormMock.current) {
onLoadingChange?.(true);
}
}, [onLoadingChange]);
return <div data-testid="test-connection-form" />;
},
}));
describe("TestConnectionStep", () => {
@@ -23,6 +37,7 @@ describe("TestConnectionStep", () => {
sessionStorage.clear();
localStorage.clear();
getProviderMock.mockReset();
loadingFromFormMock.current = false;
useProviderWizardStore.getState().reset();
});
@@ -64,4 +79,54 @@ describe("TestConnectionStep", () => {
});
expect(useProviderWizardStore.getState().secretId).toBe("secret-1");
});
it("updates footer action label to checking while connection test is in progress", async () => {
// Given
loadingFromFormMock.current = true;
useProviderWizardStore.setState({
providerId: "provider-1",
providerType: "gcp",
mode: PROVIDER_WIZARD_MODE.ADD,
});
getProviderMock.mockResolvedValue({
data: {
id: "provider-1",
attributes: {
uid: "project-123",
provider: "gcp",
alias: "Main",
connection: { connected: false, last_checked_at: null },
scanner_args: {},
},
relationships: {
secret: { data: { type: "provider-secrets", id: "secret-1" } },
},
},
});
const onFooterChange = vi.fn();
// When
render(
<TestConnectionStep
onSuccess={vi.fn()}
onResetCredentials={vi.fn()}
onFooterChange={onFooterChange}
/>,
);
// Then
await waitFor(() => {
expect(onFooterChange).toHaveBeenCalled();
});
await waitFor(() => {
const footerConfigs = onFooterChange.mock.calls.map((call) => call[0]);
const hasCheckingState = footerConfigs.some(
(config) =>
config.actionLabel === "Checking connection..." &&
config.actionDisabled === true,
);
expect(hasCheckingState).toBe(true);
});
});
});

View File

@@ -92,10 +92,9 @@ export function TestConnectionStep({
backDisabled: isFormLoading,
onBack: onResetCredentials,
showAction: canSubmit,
actionLabel:
mode === PROVIDER_WIZARD_MODE.UPDATE
? "Check connection"
: "Launch scan",
actionLabel: isFormLoading
? "Checking connection..."
: "Check connection",
actionDisabled: isFormLoading,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,

View File

@@ -1,6 +1,5 @@
"use client";
import { Checkbox } from "@heroui/checkbox";
import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react";
import { Loader2 } from "lucide-react";
@@ -14,9 +13,8 @@ import {
checkConnectionProvider,
deleteCredentials,
} from "@/actions/providers";
import { scanOnDemand, scheduleDaily } from "@/actions/scans";
import { getTask } from "@/actions/task/tasks";
import { CheckIcon, RocketIcon } from "@/components/icons";
import { CheckIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { Form } from "@/components/ui/form";
@@ -83,7 +81,6 @@ export const TestConnectionForm = ({
error: string | null;
} | null>(null);
const [isResettingCredentials, setIsResettingCredentials] = useState(false);
const [isRedirecting, setIsRedirecting] = useState(false);
const formSchema = testConnectionFormSchema;
@@ -91,7 +88,6 @@ export const TestConnectionForm = ({
resolver: zodResolver(formSchema),
defaultValues: {
providerId,
runOnce: false,
},
});
@@ -149,44 +145,12 @@ export const TestConnectionForm = ({
}
if (connected && !isUpdated) {
try {
// Check if the runOnce checkbox is checked
const runOnce = form.watch("runOnce");
let data;
if (runOnce) {
data = await scanOnDemand(formData);
} else {
data = await scheduleDaily(formData);
}
if (data.error) {
setApiErrorMessage(data.error);
form.setError("providerId", {
type: "server",
message: data.error,
});
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: data.error,
});
} else {
if (onSuccess) {
onSuccess();
return;
}
setIsRedirecting(true);
router.push("/scans");
}
} catch (_error) {
form.setError("providerId", {
type: "server",
message: "An unexpected error occurred. Please try again.",
});
if (onSuccess) {
onSuccess();
return;
}
return router.push("/providers");
} else {
setConnectionStatus({
connected: false,
@@ -235,25 +199,6 @@ export const TestConnectionForm = ({
}
};
if (isRedirecting) {
return (
<div className="flex flex-col items-center justify-center gap-6 py-12">
<div className="relative">
<div className="bg-primary/20 h-24 w-24 animate-pulse rounded-full" />
<div className="border-primary absolute inset-0 h-24 w-24 animate-spin rounded-full border-4 border-t-transparent" />
</div>
<div className="text-center">
<p className="text-primary text-xl font-medium">
Scan initiated successfully
</p>
<p className="text-small mt-2 font-bold text-gray-500">
Redirecting to scans job details...
</p>
</div>
</div>
);
}
return (
<Form {...form}>
<form
@@ -262,14 +207,10 @@ export const TestConnectionForm = ({
className="flex flex-col gap-4"
>
<div className="text-left">
<div className="mb-2 text-xl font-medium">
{!isUpdated
? "Check connection and launch scan"
: "Check connection"}
</div>
<div className="mb-2 text-xl font-medium">Check connection</div>
<p className="text-small text-default-500 py-2">
{!isUpdated
? "After a successful connection, a scan will automatically run every 24 hours. To run a single scan instead, select the checkbox below."
? "After a successful connection, continue to the launch step to configure and start your scan."
: "A successful connection will redirect you to the providers page."}
</p>
</div>
@@ -309,20 +250,6 @@ export const TestConnectionForm = ({
providerUID={providerData.data.attributes.uid}
/>
{!isUpdated && !connectionStatus?.error && (
<Checkbox
{...form.register("runOnce")}
isSelected={!!form.watch("runOnce")}
classNames={{
label: "text-small",
wrapper: "checkbox-update",
}}
color="default"
>
Run a single scan (no recurring schedule).
</Checkbox>
)}
{isUpdated && !connectionStatus?.error && (
<p className="text-small text-default-500 py-2">
Check the new credentials and test the connection.
@@ -372,13 +299,13 @@ export const TestConnectionForm = ({
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
!isUpdated && <RocketIcon size={24} />
<CheckIcon size={24} />
)}
{isLoading
? "Loading"
? "Checking"
: isUpdated
? "Check connection"
: "Launch scan"}
: "Continue"}
</Button>
)}
</div>

View File

@@ -756,6 +756,9 @@ export class ProvidersPage extends BasePage {
});
if (await button.isVisible().catch(() => false)) {
await button.click();
if (actionName === "Check connection") {
await this.handleCheckConnectionCompletion();
}
if (actionName === "Launch scan") {
await this.handleLaunchScanCompletion();
}
@@ -768,9 +771,9 @@ export class ProvidersPage extends BasePage {
);
}
private async handleLaunchScanCompletion(): Promise<void> {
const goToScansButton = this.page.getByRole("button", {
name: "Go to scans",
private async handleCheckConnectionCompletion(): Promise<void> {
const launchScanButton = this.page.getByRole("button", {
name: "Launch scan",
exact: true,
});
const connectionError = this.page.locator(
@@ -779,9 +782,9 @@ export class ProvidersPage extends BasePage {
try {
await Promise.race([
this.page.waitForURL(/\/scans/, { timeout: 20000 }),
goToScansButton.waitFor({ state: "visible", timeout: 20000 }),
connectionError.waitFor({ state: "visible", timeout: 20000 }),
launchScanButton.waitFor({ state: "visible", timeout: 30000 }),
this.wizardModal.waitFor({ state: "hidden", timeout: 30000 }),
connectionError.waitFor({ state: "visible", timeout: 30000 }),
]);
} catch {
// Continue and inspect visible state below.
@@ -794,14 +797,47 @@ export class ProvidersPage extends BasePage {
);
}
if (this.page.url().includes("/scans")) {
return;
if (await launchScanButton.isVisible().catch(() => false)) {
await launchScanButton.click();
await this.handleLaunchScanCompletion();
}
}
private async handleLaunchScanCompletion(): Promise<void> {
const connectionError = this.page.locator(
"div.border-border-error p.text-text-error-primary",
);
const launchErrorToast = this.page.getByRole("alert").filter({
hasText: /Unable to launch scan/i,
});
try {
await Promise.race([
this.wizardModal.waitFor({ state: "hidden", timeout: 30000 }),
connectionError.waitFor({ state: "visible", timeout: 30000 }),
launchErrorToast.waitFor({ state: "visible", timeout: 30000 }),
]);
} catch {
// Continue and inspect visible state below.
}
if (await goToScansButton.isVisible().catch(() => false)) {
await goToScansButton.click();
await this.page.waitForURL(/\/scans/, { timeout: 30000 });
if (await connectionError.isVisible().catch(() => false)) {
const errorText = await connectionError.textContent();
throw new Error(
`Test connection failed with error: ${errorText?.trim() || "Unknown error"}`,
);
}
if (await launchErrorToast.isVisible().catch(() => false)) {
const errorText = await launchErrorToast.textContent();
throw new Error(
`Launch scan failed with error: ${errorText?.trim() || "Unknown error"}`,
);
}
await expect(this.wizardModal).not.toBeVisible({ timeout: 30000 });
await this.page.waitForURL(/\/providers/, { timeout: 30000 });
await expect(this.providersTable).toBeVisible({ timeout: 30000 });
}
async selectCredentialsType(type: AWSCredentialType): Promise<void> {

View File

@@ -38,6 +38,9 @@ export class ScansPage extends BasePage {
async verifyPageLoaded(): Promise<void> {
// Verify the scans page is loaded
if (!this.page.url().includes("/scans")) {
await this.goto();
}
await expect(this.page).toHaveTitle(/Prowler/);
await expect(this.scanTable).toBeVisible();